src/main/ipc-handlers.ts
Rama main · réplica de lectura del repositorio original.
import { randomBytes } from 'crypto'
import type { BrowserWindow, IpcMainInvokeEvent, WebContents } from 'electron'
import { app, dialog, ipcMain, shell } from 'electron'
import { autoUpdater } from 'electron-updater'
import {
closeSync,
constants,
copyFileSync,
existsSync,
fstatSync,
openSync,
readFileSync,
readSync,
writeFileSync
} from 'fs'
import { release } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { CURRENT_VERSION_MIN, Prefs, TAG_NAME_MAX } from '@shared/types'
import type { PendingVault } from '@shared/types'
import { clearClipboardIfOurs, writeClipboard } from './clipboard-guard'
import { dialogDefaultPath, rememberDialogDir } from './dialog-dir'
import { drainRenderer } from './drain-renderer'
import { appendError, clearLog, ensureLogDir, readLogTail } from './error-log'
import { logError } from './log'
import { setPendingVault } from './pending-vault'
import { dedupeName, parseMarkdownFile, serializeNote, toSafeFilename } from './markdown-io'
import type { SchemaMigrationGate } from './db/migrations'
import type {
CreateNoteInput,
CreateTagInput,
ImportRead,
Note,
NoteFilter,
NotePatch,
TagPatch
} from './db/queries'
import {
addTagToNote,
createNote,
createTag,
createTagAndAssign,
deleteNote,
deleteTag,
duplicateNote,
emptyTrash,
getAllNoteTags,
getNote,
getNoteCountPerTag,
getNoteCounts,
getNoteTags,
getNoteTitle,
importNotes,
listNotes,
listTags,
removeTagFromNote,
restoreNote,
trashNote,
updateNote,
updateTag
} from './db/queries'
import {
clearRecoveryUnconfirmed,
getCurrentVaultPath,
getPref,
getPrefs,
isLocalVaultPath,
isPrefKey,
isRecoveryUnconfirmed,
isValidPref,
markRecoveryUnconfirmed,
promoteVaultToTop,
recordVaultUsed,
setPref
} from './prefs'
import { isSafeExternalUrl, isTrustedFrame } from './url-guard'
import {
createVaultBackup,
ensureVaultBackupDir,
getVaultBackupDir,
hasAnyBackups
} from './vault/backups'
import { isValidNotvexFile, readContainer, shouldWarnOpeningInDevBuild } from './vault/container'
import { KEY_FILE_MAX_BYTES, memzero, readKeyFileContents } from './vault/crypto'
import {
changePassword,
closeVault,
configureKeyFile,
createVault,
getDb,
getHasKeyFileFromOpenVault,
getMasterKey,
getVaultPath,
isVaultOpen,
migrateHeaderIfNeeded,
openVault,
openVaultWithRecovery,
packContainer,
removeKeyFile,
rotateVaultCredentials,
syncContainer,
withVaultLock
} from './vault/vault'
// ─── IPC envelope helper ─────────────────────────────────────────────────────
type IpcResult<T> = { success: true; data: T } | { success: false; error: string }
function ok<T>(data: T): IpcResult<T> {
return { success: true, data }
}
function fail(error: unknown): IpcResult<never> {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
// Every handler registers through this so a call from any frame other than the
// app's own page is refused before it runs. ipcMain.handle has no
// middleware, and a guard line per handler is easy to forget on a new channel.
function handle(
channel: string,
fn: (event: IpcMainInvokeEvent, ...args: never[]) => unknown
): void {
ipcMain.handle(channel, (event, ...args: unknown[]) =>
isTrustedFrame(event) ? fn(event, ...(args as never[])) : fail('Untrusted sender')
)
}
function requireVault(): void {
if (!isVaultOpen()) throw new Error('Vault is locked')
}
// Set while a new recovery key may still be on screen, so the soft locks (inactivity, minimize)
// wait for the user to confirm it. Memory only and dropped on any close: the prefs flag is what
// warns the next unlock, this one must never keep a later session from locking.
let recoveryConfirmPending = false
export function isRecoveryConfirmPending(): boolean {
return recoveryConfirmPending
}
// Called by every handler that hands out a new recovery key, before it returns. The old key is
// already invalid, so a failed prefs write must not keep the new one from reaching the user: the
// next unlock just won't warn if it is lost.
function markIssuedRecoveryKey(vaultPath: string): void {
recoveryConfirmPending = true
try {
markRecoveryUnconfirmed(vaultPath)
} catch (e) {
logError('recovery key: mark unconfirmed failed:', e)
}
}
// The renderer's maxLength and color picker only limit what the UI offers, so any caller of
// window.notvex.tags.* reaches here unchecked. Validated on write only: tags stored before these
// checks stay readable.
function validTagName(name: unknown): string {
const trimmed = typeof name === 'string' ? name.trim() : ''
if (trimmed.length === 0) throw new Error('Tag name cannot be empty')
if (trimmed.length > TAG_NAME_MAX) {
throw new Error(`Tag name must be at most ${TAG_NAME_MAX} characters`)
}
return trimmed
}
// The renderer builds a translucent background by appending a hex alpha, so only #rrggbb works.
function validTagColor(color: unknown): string {
if (typeof color !== 'string' || !/^#[0-9a-f]{6}$/i.test(color)) {
throw new Error('Tag color must be a #rrggbb hex value')
}
return color.toLowerCase()
}
// The caller must memzero the result once written.
// Exported files are plaintext: on Linux and macOS the default 0644 would let other local
// accounts read them. Applies when a file is created; Windows ignores it.
const EXPORT_MODE = 0o600
function toMarkdownBytes(note: Note): Buffer {
const tags = note.tags.map((t) => t.name)
return Buffer.from(serializeNote({ ...note, tags }), 'utf8')
}
// 'wx' fails instead of replacing a file the user already has in `dir`; on EEXIST the next free
// " (n)" name is tried. `used` also catches collisions within this export.
function writeNewFile(dir: string, base: string, used: Set<string>, bytes: Uint8Array): void {
for (;;) {
try {
writeFileSync(join(dir, dedupeName(base, used)), bytes, { flag: 'wx', mode: EXPORT_MODE })
return
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
}
}
}
// Imported files are untrusted: size is checked before reading (and again after, in case the
// file grew in between), and decoding is strict so a binary renamed to .md is rejected rather
// than stored as mojibake. A file that can't be read is skipped, not fatal to the import.
const IMPORT_MAX_FILES = 500
const IMPORT_MAX_BYTES = 5 * 1024 * 1024
// Far above any real note; only bounds what an untrusted renderer can push onto the clipboard.
const CLIPBOARD_MAX_CHARS = 10_000_000
const utf8Strict = new TextDecoder('utf-8', { fatal: true })
function decodeUtf8(raw: Uint8Array): string | null {
try {
return utf8Strict.decode(raw)
} catch {
return null
}
}
// A short read is not EOF; stopping early would silently drop bytes.
function readFully(fd: number, buf: Uint8Array): number {
let length = 0
while (length < buf.length) {
const n = readSync(fd, buf, length, buf.length - length, length)
if (n === 0) break
length += n
}
return length
}
// Checked and read through one descriptor, so the file that was checked is the file that is read.
async function readImportFile(file: string): Promise<ImportRead> {
let fd: number
try {
// O_NONBLOCK: opening a FIFO would otherwise wait for a writer forever. No-op on Windows.
fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0))
} catch (e) {
logError('notes:import: open failed:', e)
return { skip: "couldn't be read" }
}
let buf: Buffer | undefined
try {
const stat = fstatSync(fd)
// FIFOs and devices report size 0 and may never reach EOF, so the size cap can't hold.
if (!stat.isFile()) return { skip: 'not a regular file' }
if (stat.size > IMPORT_MAX_BYTES) return { skip: 'larger than 5 MB' }
// One byte past the stated size, to notice a file that grew since fstat.
buf = Buffer.alloc(stat.size + 1)
const length = readFully(fd, buf)
if (length > stat.size) return { skip: 'changed while being read' }
const text = decodeUtf8(buf.subarray(0, length))
if (text === null) return { skip: 'not valid UTF-8' }
return { note: parseMarkdownFile(text, basename(file)) }
} catch (e) {
logError('notes:import: read failed:', e)
return { skip: "couldn't be read" }
} finally {
if (buf) memzero(buf)
closeSync(fd)
}
}
// ─── Auto-lock + periodic sync state ─────────────────────────────────────────
const VAULT_SYNC_INTERVAL_MS = 30_000
// A lock keeps the renderer, so a save that misses the short drain bound still fails visibly; a
// reload takes the page and that error with it. Nobody is racing a reload the user asked for.
const RELOAD_DRAIN_TIMEOUT_MS = 2_000
const LOG_STACK_MAX = 4 * 1024
const LOG_RATE_LIMIT = 10
const LOG_RATE_WINDOW_MS = 60_000
const REPORT_LOG_LINES = 30
let rendererErrorTimes: number[] = []
let lastActivityAt = Date.now()
let autoLockTimer: ReturnType<typeof setInterval> | null = null
let syncTimer: ReturnType<typeof setInterval> | null = null
function touchActivity(): void {
lastActivityAt = Date.now()
}
// The close path for a vault the user can see: lets the renderer flush pending autosaves
// (drainRenderer()) before closeVault(), then reports a failed pack to the window if it can
// still show it; otherwise doCloseVault()'s log is the only record. win = null skips the
// drain when there's no renderer left to flush.
//
// Callers, and why each sits where it does:
// - via lockAndRecycle(): manual lock (vault:close), vault switch, opening a different .nvx
// (file-opener.ts), Reload Window, and auto-lock, suspend / lock-screen, minimize (through
// lockVaultAndNotify()).
// - a bare win.close(): window.ts's 'close' intercept, while webContents can still be
// messaged (window-all-closed is too late for that).
// - Cmd+Q / app.quit(): index.ts's before-quit, which fires before any window's 'close'; the
// window intercept then finds the vault already closed.
// - macOS window-all-closed (the app stays alive): index.ts, a safety net with win = null.
// It can fire mid-quitAndInstall(); harmless, closeVault() is serialized and idempotent.
// - renderer reloaded or crashed: window.ts, with win = null.
// relockOrphanedVault() is the exception: the renderer that asked for the unlock is gone, so
// there's nothing to drain and it calls closeVault() directly.
export async function closeVaultDrained(
win: BrowserWindow | null,
drainTimeoutMs?: number
): Promise<{ packFailed: boolean }> {
if (!isVaultOpen()) return { packFailed: false }
if (win) await drainRenderer(win, drainTimeoutMs)
try {
const { packFailed } = await closeVault()
if (packFailed && win && !win.isDestroyed()) win.webContents.send('vault:pack-failed')
return { packFailed }
} finally {
// After the close, not before: closeVault() queues behind any running rekey, whose handler
// marks its new key before the vault actually closes.
recoveryConfirmPending = false
await clearClipboardIfOurs('lock')
}
}
// ─── Renderer recycle ────────────────────────────────────────────────────────
// After a lock the old renderer's heap still holds decrypted titles, note text and the typed
// password, and a same-site reload() keeps the same process. So the lock kills it, and
// render-process-gone, seeing the mark (takeRecycleMark()), reloads into a new process without
// logging the kill or treating it as a crash. A reload() sent right after the kill is dropped.
// SIGKILL, not forcefullyCrashRenderer(): that one makes the renderer crash itself, which runs
// the OS crash-dump path. That can write the heap being discarded to disk, or hang the exit so
// render-process-gone never fires.
const recycling = new WeakSet<WebContents>()
export function takeRecycleMark(webContents: WebContents): boolean {
return recycling.delete(webContents)
}
// next: the vault the new renderer opens on, since nothing of the old renderer's state survives.
function recycleRenderer(win: BrowserWindow | null, next: PendingVault | null): boolean {
if (!win || win.isDestroyed()) return false
if (next) setPendingVault(next)
// A kill already in flight reloads into a new process; a second one would hit a dead pid
if (recycling.has(win.webContents)) return true
// pid 0 means no live renderer (and kill(0) would hit our own group); a plain reload then
// already starts a new process
const pid = win.webContents.getOSProcessId()
if (pid) {
recycling.add(win.webContents)
try {
process.kill(pid, 'SIGKILL')
return true
} catch (e) {
recycling.delete(win.webContents)
// Unless the process is already gone, the reload below keeps the old heap: leave a trace
const code = (e as NodeJS.ErrnoException).code ?? 'Error'
appendError('renderer-recycle', { name: code, stack: '' })
}
}
win.webContents.reload()
return true
}
// closeVaultDrained() for a lock the user stays in the app for. A failed pack keeps the
// renderer, since its pack-failed toast is the only notice of the lost changes. next defaults
// to the vault being locked.
export async function lockAndRecycle(
win: BrowserWindow | null,
opts: { drainTimeoutMs?: number; next?: PendingVault } = {}
): Promise<{ recycled: boolean }> {
const lockedPath = getVaultPath()
const next = opts.next ?? (lockedPath ? { path: lockedPath, mode: 'open' } : null)
const { packFailed } = await closeVaultDrained(win, opts.drainTimeoutMs)
if (packFailed) return { recycled: false }
return { recycled: recycleRenderer(win, next) }
}
export async function lockVaultAndNotify(win: BrowserWindow | null): Promise<void> {
if (!isVaultOpen()) return
const { recycled } = await lockAndRecycle(win)
if (!recycled && win && !win.isDestroyed()) win.webContents.send('vault:auto-locked')
}
// A failed credential rotation closes the vault itself, outside closeVaultDrained(), so the
// clipboard clear every lock does happens here.
async function notifyIfRotationLocked(): Promise<void> {
if (isVaultOpen()) return
recoveryConfirmPending = false
await clearClipboardIfOurs('lock')
recycleRenderer(liveWin(), null)
}
// ─── Current window ──────────────────────────────────────────────────────────
// On macOS the app outlives its last window and `activate` builds a new one,
// so createWindow() calls registerIpcHandlers() more than once. ipcMain.handle
// throws on a second registration, so handlers are registered once and read
// the window through liveWin() instead of capturing the first one.
let currentWin: BrowserWindow | null = null
let handlersRegistered = false
function liveWin(): BrowserWindow | null {
return currentWin && !currentWin.isDestroyed() ? currentWin : null
}
// Whether the renderer that made a call is still the window the user sees. On
// macOS `activate` can replace it mid-unlock with a window that never asked for
// the unlock, so long-running handlers check this instead of liveWin().
function isLiveSender(sender: WebContents): boolean {
return !sender.isDestroyed() && sender === liveWin()?.webContents
}
// For renderer-invoked handlers, which only run while their window is alive.
function requireWin(): BrowserWindow {
const win = liveWin()
if (!win) throw new Error('No window is open')
return win
}
function startAutoLockTimer(): void {
if (autoLockTimer) clearInterval(autoLockTimer)
autoLockTimer = setInterval((): void => {
void (async (): Promise<void> => {
const minutes = getPref('autoLockMinutes')
if (minutes === 0) return
if (!isVaultOpen() || recoveryConfirmPending) return
const elapsed = (Date.now() - lastActivityAt) / 60000
if (elapsed >= minutes) {
await lockVaultAndNotify(liveWin())
}
})()
}, 60_000)
if (syncTimer) clearInterval(syncTimer)
syncTimer = setInterval((): void => {
void syncContainer()
}, VAULT_SYNC_INTERVAL_MS)
}
export function stopAutoLockTimer(): void {
if (autoLockTimer) {
clearInterval(autoLockTimer)
autoLockTimer = null
}
if (syncTimer) {
clearInterval(syncTimer)
syncTimer = null
}
}
// ─── Unlock throttle ─────────────────────────────────────────────────────────
interface ThrottleState {
failedAttempts: number
lockedUntil: number
}
const unlockThrottle: ThrottleState = { failedAttempts: 0, lockedUntil: 0 }
let isUnlocking = false
// createVault() only reports the vault open once it returns, so until then a reload would find it
// closed, skip the confirmation and drop the recovery phrase the page is about to show.
let isCreating = false
// Bumped when the renderer is replaced while its window stays alive (reload,
// crash). An unlock started under an older renderer has nobody left to show
// the vault to, and isLiveSender() can't tell: the webContents is the same.
let rendererGeneration = 0
export function markRendererReplaced(): void {
rendererGeneration++
}
function throttleDelaySeconds(attempts: number): number {
if (attempts <= 3) return 0
if (attempts === 4) return 5
if (attempts === 5) return 15
if (attempts === 6) return 30
return 60 * (attempts - 6)
}
function checkAndSetThrottle(): IpcResult<never> | null {
if (isUnlocking) return fail('Unlock already in progress')
if (Date.now() < unlockThrottle.lockedUntil) {
const waitSecs = Math.ceil((unlockThrottle.lockedUntil - Date.now()) / 1000)
return fail(`Too many failed attempts. Try again in ${waitSecs}s`)
}
return null
}
// ─── Migration coordinator ────────────────────────────────────────────────────
let migrationResolver: ((result: { confirmed: boolean; createBackup: boolean }) => void) | null =
null
let migrationBackupTimestamp: number | null = null
// The renderer the pending gate was sent to; only its answer counts. Never reset: every gate
// assigns it before parking, and it's only read while one is pending.
let gateRequester: WebContents | null = null
type MigrationPayload =
| { reason: 'header'; fromVersion: number; toVersion: number }
| { reason: 'schema'; fromVersion: number; toVersion: number }
// Prompts the renderer that requested the unlock and waits for its answer. If it's gone
// nobody can answer, so it counts as cancelled; never re-routed to another window.
async function confirmMigrationAndBackup(
sender: WebContents,
filePath: string,
payload: MigrationPayload
): Promise<boolean> {
if (migrationResolver !== null) {
throw new Error('A migration dialog is already open. Complete or cancel it first.')
}
if (!isLiveSender(sender)) return false
gateRequester = sender
const backupTimestamp = Date.now()
migrationBackupTimestamp = backupTimestamp
sender.send('vault:migration-required', {
vaultPath: filePath,
backupTimestamp,
...payload
})
const migResult = await new Promise<{ confirmed: boolean; createBackup: boolean }>((resolve) => {
migrationResolver = resolve
})
if (migResult.confirmed && migResult.createBackup && migrationBackupTimestamp !== null) {
createVaultBackup(filePath, payload.reason, payload.fromVersion, payload.toVersion)
}
migrationBackupTimestamp = null
return migResult.confirmed
}
// ─── Dev-build vault warning ──────────────────────────────────────────────────
let devBuildWarningResolver: ((confirmed: boolean) => void) | null = null
// Same contract as confirmMigrationAndBackup(), for the dev-build warning.
async function confirmDevBuildWarning(sender: WebContents, filePath: string): Promise<boolean> {
if (devBuildWarningResolver !== null) {
throw new Error('A dev-build warning dialog is already open. Complete or cancel it first.')
}
if (!isLiveSender(sender)) return false
gateRequester = sender
sender.send('vault:dev-build-warning-required', { vaultPath: filePath })
return new Promise<boolean>((resolve) => {
devBuildWarningResolver = resolve
})
}
// Settles whichever gate is pending as cancelled (they run in sequence: at most one). Resolving,
// not just nulling, lets the parked open unwind through its own cleanup instead of hanging.
// Called by the -cancelled handlers and when the window closes or its renderer is replaced.
export function cancelPendingConfirmations(): void {
migrationResolver?.({ confirmed: false, createBackup: false })
migrationResolver = null
devBuildWarningResolver?.(false)
devBuildWarningResolver = null
}
// The unlock's renderer is gone: its window closed mid-KDF (macOS), another window replaced
// it, or it reloaded/crashed. An open vault would boot the next page straight into the notes.
function isUnlockOrphaned(sender: WebContents, generation: number): boolean {
return !isLiveSender(sender) || generation !== rendererGeneration
}
async function relockOrphanedVault(): Promise<IpcResult<never>> {
await closeVault()
return fail('No window is open')
}
// Runs after the vault change already happened: failing the handler here would leave the
// renderer believing the change didn't, while main holds the result (open vault, new mnemonic).
function recordVaultUsedNonFatal(tag: string, path: string, hasKeyFile = false): void {
try {
recordVaultUsed(path, hasKeyFile)
} catch (e) {
logError(`[${tag}] record vault used failed:`, e)
}
}
// Maps the internal error sentinels thrown by readContainer/runMigrations to
// user-facing messages. 'MIGRATION_CANCELLED' is the same sentinel unlock.tsx
// already special-cases to silently return to the idle unlock form.
function mapOpenVaultError(e: unknown): IpcResult<never> {
if (e instanceof Error) {
if (e.message === 'VERSION_TOO_NEW') {
return fail(
'This vault was created using a newer version of Notvex. Please update the app to open it.'
)
}
if (e.message === 'NOT_NOTVEX_FILE') {
return fail('The selected file is not a Notvex vault.')
}
if (e.message === 'SCHEMA_VERSION_TOO_NEW') {
return fail(
'This vault was created or modified by a newer version of Notvex. Please update the app to open it.'
)
}
if (e.message === 'SCHEMA_MIGRATION_CANCELLED') {
return fail('MIGRATION_CANCELLED')
}
}
return fail(e)
}
// ─── Register all handlers ────────────────────────────────────────────────────
export function registerIpcHandlers(
win: BrowserWindow,
takePendingVault: () => PendingVault | null
): void {
currentWin = win
startAutoLockTimer()
if (handlersRegistered) return
handlersRegistered = true
// ── Vault ──────────────────────────────────────────────────────────────────
handle('vault:get-pending-file', () => {
try {
return ok(takePendingVault())
} catch (e) {
return fail(e)
}
})
handle('vault:has-vault', (_e, filePath?: string) => {
try {
const path = filePath ?? getCurrentVaultPath()
if (!path) return ok(false)
if (!isLocalVaultPath(path)) return fail('Vaults on network shares are not supported')
return ok(existsSync(path) && isValidNotvexFile(path))
} catch (e) {
return fail(e)
}
})
handle('vault:create', async (_e, filePath: string, password: string) => {
isCreating = true
try {
const result = await createVault(filePath, password, !app.isPackaged)
markIssuedRecoveryKey(filePath)
recordVaultUsedNonFatal('vault:create', filePath)
touchActivity()
return ok(result)
} catch (e) {
return fail(e)
} finally {
isCreating = false
}
})
handle(
'vault:open',
async (event, filePath: string, password: string, keyFileContents?: Uint8Array) => {
const throttleErr = checkAndSetThrottle()
if (throttleErr) return throttleErr
isUnlocking = true
const generation = rendererGeneration
try {
// Pre-check container header before authenticating to surface format errors early
let fileBytes: Buffer
try {
fileBytes = readFileSync(filePath)
} catch {
return fail("Vault file not found or couldn't be read.")
}
let migrationOccurred = false
try {
const header = readContainer(fileBytes)
// Checked before any migration runs: a migration is part of what it guards against.
if (shouldWarnOpeningInDevBuild(app.isPackaged, header)) {
const confirmed = await confirmDevBuildWarning(event.sender, filePath)
if (!confirmed) return fail('DEV_BUILD_WARNING_CANCELLED')
}
// Minor version: show migration dialog if needed (no-op for v1.0)
if (header.versionMin < CURRENT_VERSION_MIN) {
const confirmed = await confirmMigrationAndBackup(event.sender, filePath, {
reason: 'header',
fromVersion: header.versionMin,
toVersion: CURRENT_VERSION_MIN
})
if (!confirmed) return fail('MIGRATION_CANCELLED')
await migrateHeaderIfNeeded(filePath, header.versionMin)
migrationOccurred = true
}
} catch (e) {
migrationBackupTimestamp = null
return mapOpenVaultError(e)
}
ensureVaultBackupDir(filePath)
const kfContents = keyFileContents ? readKeyFileContents(keyFileContents) : undefined
const onSchemaMigrationNeeded: SchemaMigrationGate = ({ fromVersion, toVersion }) =>
confirmMigrationAndBackup(event.sender, filePath, {
reason: 'schema',
fromVersion,
toVersion
})
// Pass pre-read bytes to avoid a second readFileSync; after migration the file was
// rewritten so openVault must re-read it (pass undefined to trigger the internal read).
let vaultVersion: Awaited<ReturnType<typeof openVault>>
try {
vaultVersion = await openVault(
filePath,
password,
kfContents,
migrationOccurred ? undefined : fileBytes,
onSchemaMigrationNeeded
)
} catch (e) {
return mapOpenVaultError(e)
}
// await, not return: the finally that clears isUnlocking must wait for
// the close, or a retry from the reloaded page races it.
if (vaultVersion !== null && isUnlockOrphaned(event.sender, generation)) {
return await relockOrphanedVault()
}
if (vaultVersion !== null) {
unlockThrottle.failedAttempts = 0
unlockThrottle.lockedUntil = 0
recordVaultUsedNonFatal('vault:open', filePath, keyFileContents !== undefined)
touchActivity()
} else {
unlockThrottle.failedAttempts += 1
const delaySecs = throttleDelaySeconds(unlockThrottle.failedAttempts)
if (delaySecs > 0) unlockThrottle.lockedUntil = Date.now() + delaySecs * 1000
}
return ok(vaultVersion)
} catch (e) {
return fail(e)
} finally {
isUnlocking = false
}
}
)
// The four gate answers below are no-ops unless they come from gateRequester.
handle('vault:migration-confirmed', (event, createBackup: boolean) => {
try {
if (event.sender !== gateRequester) return ok(null)
// Don't clear migrationBackupTimestamp here: resolve() only queues the awaiting code, which
// still reads it.
migrationResolver?.({ confirmed: true, createBackup })
migrationResolver = null
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('vault:migration-cancelled', (event) => {
try {
if (event.sender !== gateRequester) return ok(null)
cancelPendingConfirmations()
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('vault:dev-build-warning-confirmed', (event) => {
try {
if (event.sender !== gateRequester) return ok(null)
devBuildWarningResolver?.(true)
devBuildWarningResolver = null
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('vault:dev-build-warning-cancelled', (event) => {
try {
if (event.sender !== gateRequester) return ok(null)
cancelPendingConfirmations()
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('vault:unlock-throttle-status', () => {
try {
const now = Date.now()
const waitMs = Math.max(0, unlockThrottle.lockedUntil - now)
return ok({
isThrottled: waitMs > 0,
waitSeconds: Math.ceil(waitMs / 1000),
failedAttempts: unlockThrottle.failedAttempts
})
} catch (e) {
return fail(e)
}
})
handle(
'vault:open-with-recovery',
async (event, filePath: string, mnemonic: string, keyFileContents?: Uint8Array) => {
try {
const throttleErr = checkAndSetThrottle()
if (throttleErr) return throttleErr
isUnlocking = true
const generation = rendererGeneration
try {
let fileBytes: Buffer
try {
fileBytes = readFileSync(filePath)
} catch {
return fail("Vault file not found or couldn't be read.")
}
try {
const header = readContainer(fileBytes)
if (shouldWarnOpeningInDevBuild(app.isPackaged, header)) {
const confirmed = await confirmDevBuildWarning(event.sender, filePath)
if (!confirmed) return fail('DEV_BUILD_WARNING_CANCELLED')
}
if (header.versionMin < CURRENT_VERSION_MIN) {
const confirmed = await confirmMigrationAndBackup(event.sender, filePath, {
reason: 'header',
fromVersion: header.versionMin,
toVersion: CURRENT_VERSION_MIN
})
if (!confirmed) return fail('MIGRATION_CANCELLED')
await migrateHeaderIfNeeded(filePath, header.versionMin)
}
} catch (e) {
migrationBackupTimestamp = null
return mapOpenVaultError(e)
}
ensureVaultBackupDir(filePath)
const kfContents = keyFileContents ? readKeyFileContents(keyFileContents) : undefined
const onSchemaMigrationNeeded: SchemaMigrationGate = ({ fromVersion, toVersion }) =>
confirmMigrationAndBackup(event.sender, filePath, {
reason: 'schema',
fromVersion,
toVersion
})
let vaultVersion: Awaited<ReturnType<typeof openVaultWithRecovery>>
try {
vaultVersion = await openVaultWithRecovery(
filePath,
mnemonic,
kfContents,
onSchemaMigrationNeeded
)
} catch (e) {
return mapOpenVaultError(e)
}
if (vaultVersion !== null && isUnlockOrphaned(event.sender, generation)) {
return await relockOrphanedVault()
}
if (vaultVersion !== null) {
unlockThrottle.failedAttempts = 0
unlockThrottle.lockedUntil = 0
recordVaultUsedNonFatal(
'vault:open-with-recovery',
filePath,
keyFileContents !== undefined
)
touchActivity()
} else {
unlockThrottle.failedAttempts += 1
const delaySecs = throttleDelaySeconds(unlockThrottle.failedAttempts)
if (delaySecs > 0) unlockThrottle.lockedUntil = Date.now() + delaySecs * 1000
}
return ok(vaultVersion)
} finally {
isUnlocking = false
}
} catch (e) {
return fail(e)
}
}
)
handle(
'vault:change-password',
async (_e, currentPassword: string, newPassword: string, keyFileContents?: Uint8Array) => {
try {
requireVault()
touchActivity()
const kfContents = keyFileContents ? readKeyFileContents(keyFileContents) : undefined
const result = await changePassword(currentPassword, newPassword, kfContents)
markIssuedRecoveryKey(getVaultPath()!)
return ok(result)
} catch (e) {
await notifyIfRotationLocked()
return fail(e)
}
}
)
// A new recovery key for a user who lost the last one before saving it: re-keying under the
// same password is what issues it, and invalidates the lost one.
handle(
'vault:regenerate-recovery-key',
async (_e, password: string, keyFileContents?: Uint8Array) => {
try {
requireVault()
touchActivity()
const kfContents = keyFileContents ? readKeyFileContents(keyFileContents) : undefined
const result = await changePassword(password, password, kfContents)
markIssuedRecoveryKey(getVaultPath()!)
return ok(result)
} catch (e) {
await notifyIfRotationLocked()
return fail(e)
}
}
)
handle('vault:rotate-credentials', async (_e, newPassword: string) => {
try {
requireVault()
touchActivity()
const result = await rotateVaultCredentials(newPassword)
markIssuedRecoveryKey(getVaultPath()!)
return ok(result)
} catch (e) {
await notifyIfRotationLocked()
return fail(e)
}
})
handle('vault:confirm-recovery-saved', () => {
try {
recoveryConfirmPending = false
// Reading and confirming the key was activity: the auto-lock restarts from here.
touchActivity()
const vaultPath = getVaultPath()
if (vaultPath) clearRecoveryUnconfirmed(vaultPath)
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('vault:recovery-unconfirmed', () => {
try {
const vaultPath = getVaultPath()
return ok(vaultPath ? isRecoveryUnconfirmed(vaultPath) : false)
} catch (e) {
return fail(e)
}
})
// createPath: a vault the user is about to create, so the next renderer opens Setup for it
handle('vault:close', async (_e, createPath?: unknown) => {
try {
const next: PendingVault | undefined =
typeof createPath === 'string' ? { path: createPath, mode: 'create' } : undefined
await lockAndRecycle(liveWin(), { next })
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('vault:switch', async (_e, filePath: string) => {
try {
// Validate before closeVaultDrained() so a bad target never locks the current vault
if (!existsSync(filePath)) {
return fail('Vault not found. It may have been moved or deleted.')
}
if (!isValidNotvexFile(filePath)) {
return fail('This file is not a valid Notvex vault')
}
await lockAndRecycle(liveWin(), { next: { path: filePath, mode: 'open' } })
try {
promoteVaultToTop(filePath)
} catch (e) {
// The vault is already closed: failing here would strand the renderer unlocked over it
logError('[switch] promote failed:', e)
}
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('vault:clear-decrypted', () => {
try {
// Main process has no accumulated plaintext — the belt-and-suspenders signal is enough.
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('vault:get-has-key-file', () => {
try {
requireVault()
return ok(getHasKeyFileFromOpenVault())
} catch (e) {
return fail(e)
}
})
handle('vault:save-copy-as', async () => {
try {
const vaultPath = getVaultPath()
if (!vaultPath) return fail('No vault open')
// packContainer(), not syncContainer(): a failed sync must surface as a
// failed backup, not silently copy stale data. Its fs errors embed local
// paths, so the renderer only gets a generic message.
try {
await packContainer()
} catch (e) {
logError('vault:save-copy-as: sync before copy failed:', e)
return fail("Couldn't sync the vault before copying. Try again.")
}
const vaultDir = dirname(vaultPath)
const vaultName = basename(vaultPath, '.nvx')
const { canceled, filePath } = await dialog.showSaveDialog(requireWin(), {
title: 'Save a copy of your vault',
defaultPath: join(vaultDir, `${vaultName}_copy.nvx`),
filters: [{ name: 'Notvex Vault', extensions: ['nvx'] }],
buttonLabel: 'Save copy'
})
if (canceled || !filePath) return ok(null)
rememberDialogDir(filePath)
copyFileSync(vaultPath, filePath)
return ok(filePath)
} catch (e) {
return fail(e)
}
})
handle('vault:open-backups-folder', async () => {
try {
const filePath = getVaultPath()
if (!filePath) return fail('No vault is currently open.')
if (!hasAnyBackups(filePath)) return fail('No backups yet.')
const result = await shell.openPath(getVaultBackupDir(filePath))
return result ? fail(result) : ok(null)
} catch (e) {
return fail(e)
}
})
handle('vault:generate-key-file', async () => {
try {
const result = await dialog.showSaveDialog(requireWin(), {
title: 'Save key file',
defaultPath: dialogDefaultPath('notvex.nvxkey'),
filters: [{ name: 'Notvex Key File', extensions: ['nvxkey'] }]
})
if (result.canceled || !result.filePath) return ok(null)
rememberDialogDir(result.filePath)
const bytes = randomBytes(32)
writeFileSync(result.filePath, bytes)
const filename = basename(result.filePath)
return ok({ path: result.filePath, contents: new Uint8Array(bytes), filename })
} catch {
// fs errors quote the path, and the renderer shows this error as is.
return fail("Couldn't save the key file")
}
})
handle('vault:select-key-file', async () => {
try {
const result = await dialog.showOpenDialog(requireWin(), {
title: 'Select key file',
defaultPath: dialogDefaultPath(),
properties: ['openFile'],
filters: [
{ name: 'Notvex Key File', extensions: ['nvxkey'] },
{ name: 'All Files', extensions: ['*'] }
]
})
if (result.canceled || !result.filePaths[0]) return ok(null)
const filePath = result.filePaths[0]
rememberDialogDir(filePath)
const filename = basename(filePath)
const fd = openSync(filePath, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0))
try {
const stat = fstatSync(fd)
if (!stat.isFile()) return fail("Couldn't read the selected key file")
// Only the first KEY_FILE_MAX_BYTES feed the key, so a file of any size costs at most that.
const buf = new Uint8Array(Math.min(stat.size, KEY_FILE_MAX_BYTES))
const read = readFully(fd, buf)
if (read === 0) return fail('The selected key file is empty')
return ok({ contents: buf.subarray(0, read), filename, sizeBytes: stat.size })
} finally {
closeSync(fd)
}
} catch {
// fs errors quote the path, and the renderer shows this error as is.
return fail("Couldn't read the selected key file")
}
})
handle('vault:configure-key-file', async (_e, password: string, keyFileContents: Uint8Array) => {
try {
requireVault()
touchActivity()
const kfContents = readKeyFileContents(keyFileContents)
const result = await configureKeyFile(password, kfContents)
markIssuedRecoveryKey(getVaultPath()!)
recordVaultUsedNonFatal('vault:configure-key-file', getVaultPath()!, true)
return ok(result)
} catch (e) {
await notifyIfRotationLocked()
return fail(e)
}
})
handle('vault:remove-key-file', async (_e, password: string, keyFileContents: Uint8Array) => {
try {
requireVault()
touchActivity()
const kfContents = readKeyFileContents(keyFileContents)
const result = await removeKeyFile(password, kfContents)
markIssuedRecoveryKey(getVaultPath()!)
recordVaultUsedNonFatal('vault:remove-key-file', getVaultPath()!, false)
return ok(result)
} catch (e) {
await notifyIfRotationLocked()
return fail(e)
}
})
handle('vault:status', async () => {
try {
// closeVault() packs the container before it clears the path, so queue
// behind any in-flight close: a renderer reloaded mid-close must not
// read "open" and boot into the notes over a closing vault.
const path = await withVaultLock(async () => getVaultPath())
return ok(
path !== null ? { isOpen: true as const, vaultPath: path } : { isOpen: false as const }
)
} catch (e) {
return fail(e)
}
})
handle('vault:choose-file', async (_e, mode: 'new' | 'existing') => {
try {
if (mode === 'new') {
const result = await dialog.showSaveDialog(requireWin(), {
title: 'Create new vault',
defaultPath: dialogDefaultPath('vault.nvx'),
filters: [{ name: 'Notvex Vault', extensions: ['nvx'] }]
})
if (result.canceled) return ok(null)
rememberDialogDir(result.filePath)
return ok(result.filePath)
} else {
const result = await dialog.showOpenDialog(requireWin(), {
title: 'Open existing vault',
defaultPath: dialogDefaultPath(),
properties: ['openFile'],
filters: [{ name: 'Notvex Vault', extensions: ['nvx'] }]
})
if (result.canceled) return ok(null)
rememberDialogDir(result.filePaths[0])
return ok(result.filePaths[0])
}
} catch (e) {
return fail(e)
}
})
// ── Notes ─────────────────────────────────────────────────────────────────
handle('notes:create', async (_e, input: CreateNoteInput) => {
try {
requireVault()
touchActivity()
return ok(await withVaultLock(() => createNote(getDb(), input, getMasterKey())))
} catch (e) {
return fail(e)
}
})
handle('notes:get', async (_e, id: string) => {
try {
requireVault()
touchActivity()
return ok(await withVaultLock(() => getNote(getDb(), id, getMasterKey())))
} catch (e) {
return fail(e)
}
})
handle('notes:list', async (_e, filter: NoteFilter = {}) => {
try {
requireVault()
touchActivity()
return ok(await withVaultLock(() => listNotes(getDb(), getMasterKey(), filter)))
} catch (e) {
return fail(e)
}
})
handle('notes:update', async (_e, id: string, patch: NotePatch) => {
try {
requireVault()
touchActivity()
await withVaultLock(() => updateNote(getDb(), id, patch, getMasterKey()))
return ok(null)
} catch (e) {
return fail(e)
}
})
// Takes only the id: the content is read and re-encrypted here, never round-tripped
// through the renderer.
handle('notes:duplicate', async (_e, id: string) => {
try {
requireVault()
touchActivity()
return ok(await withVaultLock(() => duplicateNote(getDb(), id, getMasterKey())))
} catch (e) {
return fail(e)
}
})
handle('notes:trash', async (_e, id: string) => {
try {
requireVault()
touchActivity()
await withVaultLock(() => trashNote(getDb(), id))
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('notes:restore', async (_e, id: string) => {
try {
requireVault()
touchActivity()
await withVaultLock(() => restoreNote(getDb(), id))
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('notes:delete', async (_e, id: string) => {
try {
requireVault()
touchActivity()
await withVaultLock(() => deleteNote(getDb(), id))
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('notes:empty-trash', async () => {
try {
requireVault()
touchActivity()
await withVaultLock(() => emptyTrash(getDb()))
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('notes:counts', async () => {
try {
requireVault()
touchActivity()
return ok(await withVaultLock(() => getNoteCounts(getDb())))
} catch (e) {
return fail(e)
}
})
// ── Markdown export / import ────────────────────────────────────────────────
// The renderer sends only ids and gets back only counts: note content is read, decrypted and
// written here and never crosses IPC.
handle('notes:export', async (_e, noteId: string) => {
try {
requireVault()
touchActivity()
// Only the title before the dialog: the content isn't decrypted while it waits on the user.
const title = await withVaultLock(() => getNoteTitle(getDb(), noteId, getMasterKey()))
if (title === null) return fail('Note not found')
// Outside the vault lock: an open dialog must not hold up the periodic sync or a lock.
const { canceled, filePath } = await dialog.showSaveDialog(requireWin(), {
title: 'Export note',
defaultPath: dialogDefaultPath(`${toSafeFilename(title)}.md`),
filters: [{ name: 'Markdown', extensions: ['md'] }]
})
if (canceled || !filePath) return ok(null)
rememberDialogDir(filePath)
// A lock while the dialog was open means the user may have walked away: don't write.
requireVault()
// Re-read from whatever vault is open now; another one may have been unlocked meanwhile.
const note = await withVaultLock(() => getNote(getDb(), noteId, getMasterKey()))
if (!note) return fail('Note not found')
const bytes = toMarkdownBytes(note)
try {
writeFileSync(filePath, bytes, { mode: EXPORT_MODE })
} catch (e) {
logError('notes:export: write failed:', e)
return fail("Couldn't write the exported file")
} finally {
memzero(bytes)
}
return ok(true)
} catch (e) {
return fail(e)
}
})
handle('notes:export-all', async () => {
try {
requireVault()
touchActivity()
// The folder is picked before any note is decrypted, so no plaintext waits on the user.
const { canceled, filePaths } = await dialog.showOpenDialog(requireWin(), {
title: 'Export all notes',
defaultPath: dialogDefaultPath(),
properties: ['openDirectory', 'createDirectory'],
buttonLabel: 'Export here'
})
const dir = filePaths[0]
if (canceled || !dir) return ok(null)
rememberDialogDir(dir)
requireVault()
// One note decrypted at a time. Exporting holds the vault lock, but it's a one-off action
// the user is waiting on.
return await withVaultLock(async () => {
const ids = (await listNotes(getDb(), getMasterKey(), { trashed: false })).map((n) => n.id)
const used = new Set<string>()
let written = 0
for (const id of ids) {
const note = await getNote(getDb(), id, getMasterKey())
if (!note) continue
const bytes = toMarkdownBytes(note)
try {
writeNewFile(dir, toSafeFilename(note.title), used, bytes)
} catch (e) {
logError('notes:export-all: write failed:', e)
return fail(
`Export stopped after ${written} of ${ids.length} notes: a file couldn't be written`
)
} finally {
memzero(bytes)
}
written++
}
return ok(written)
})
} catch (e) {
return fail(e)
}
})
handle('notes:import', async () => {
try {
requireVault()
touchActivity()
const { canceled, filePaths } = await dialog.showOpenDialog(requireWin(), {
title: 'Import Markdown',
defaultPath: dialogDefaultPath(),
properties: ['openFile', 'multiSelections'],
filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'txt'] }]
})
if (canceled || filePaths.length === 0) return ok(null)
rememberDialogDir(filePaths[0])
if (filePaths.length > IMPORT_MAX_FILES) {
return fail(`Select at most ${IMPORT_MAX_FILES} files per import`)
}
requireVault()
// importNotes reads each file inside its transaction, so this holds the vault lock for the
// whole import; it's a one-off action the user is waiting on.
const result = await withVaultLock(() =>
importNotes(getDb(), filePaths, getMasterKey(), readImportFile)
)
return ok(result)
} catch (e) {
return fail(e)
}
})
// ── Tags ──────────────────────────────────────────────────────────────────
handle('tags:create', async (_e, input: CreateTagInput) => {
try {
requireVault()
const name = validTagName(input?.name)
const color = validTagColor(input?.color)
touchActivity()
return ok(await withVaultLock(() => createTag(getDb(), { ...input, name, color })))
} catch (e) {
return fail(e)
}
})
handle(
'tags:create-and-assign',
async (_e, input: { noteId: string; name: string; color: string }) => {
try {
requireVault()
const name = validTagName(input?.name)
const color = validTagColor(input?.color)
touchActivity()
return ok(await withVaultLock(() => createTagAndAssign(getDb(), { ...input, name, color })))
} catch (e) {
return fail(e)
}
}
)
handle('tags:list', async () => {
try {
requireVault()
touchActivity()
return ok(await withVaultLock(() => listTags(getDb())))
} catch (e) {
return fail(e)
}
})
handle('tags:update', async (_e, id: string, patch: TagPatch) => {
try {
requireVault()
const checked = {
...patch,
...(patch?.name !== undefined && { name: validTagName(patch.name) }),
...(patch?.color !== undefined && { color: validTagColor(patch.color) })
}
touchActivity()
await withVaultLock(() => updateTag(getDb(), id, checked))
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('tags:delete', async (_e, id: string) => {
try {
requireVault()
touchActivity()
await withVaultLock(() => deleteTag(getDb(), id))
return ok(null)
} catch (e) {
return fail(e)
}
})
// ── Note-tags ─────────────────────────────────────────────────────────────
handle('note-tags:add', async (_e, noteId: string, tagId: string) => {
try {
requireVault()
touchActivity()
await withVaultLock(() => addTagToNote(getDb(), noteId, tagId))
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('note-tags:remove', async (_e, noteId: string, tagId: string) => {
try {
requireVault()
touchActivity()
await withVaultLock(() => removeTagFromNote(getDb(), noteId, tagId))
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('note-tags:list', async (_e, noteId: string) => {
try {
requireVault()
touchActivity()
return ok(await withVaultLock(() => getNoteTags(getDb(), noteId)))
} catch (e) {
return fail(e)
}
})
handle('note-tags:counts', async () => {
try {
requireVault()
touchActivity()
return ok(await withVaultLock(() => getNoteCountPerTag(getDb())))
} catch (e) {
return fail(e)
}
})
handle('note-tags:all', async () => {
try {
requireVault()
touchActivity()
return ok(await withVaultLock(() => getAllNoteTags(getDb())))
} catch (e) {
return fail(e)
}
})
// ── Prefs ─────────────────────────────────────────────────────────────────
handle('prefs:get', () => {
try {
const prefs = getPrefs()
const validatedVaults = prefs.recentVaults.filter(
(v) => existsSync(v.path) && isValidNotvexFile(v.path)
)
if (validatedVaults.length !== prefs.recentVaults.length) {
setPref('recentVaults', validatedVaults)
}
return ok({ ...prefs, recentVaults: validatedVaults })
} catch (e) {
return fail(e)
}
})
handle('prefs:set', (_e, key: keyof Prefs, value: Prefs[keyof Prefs]) => {
try {
if (!isPrefKey(key)) return fail(`Unknown preference key: ${key}`)
if (!isValidPref(key, value)) return fail(`Invalid value for preference: ${key}`)
setPref(key, value)
return ok(null)
} catch (e) {
return fail(e)
}
})
// ── Clipboard ────────────────────────────────────────────────────────────
handle('clipboard:write', async (_e, text: unknown, opts: unknown) => {
try {
if (typeof text !== 'string' || text === '' || text.length > CLIPBOARD_MAX_CHARS) {
return fail('Invalid clipboard value')
}
const flags = opts as { secret?: unknown; fromUnlockedView?: unknown } | null | undefined
const secret = flags?.secret === true
// The renderer hears of a lock only after main cleared the clipboard, so a copy from the
// view it still shows can arrive with the vault closed. The recovery key is shown once, so
// its copy goes through regardless. Nothing may await between this check and
// writeClipboard(): a lock in that gap would be missed by both this and its lock generation.
if (!secret && flags?.fromUnlockedView === true && !isVaultOpen()) {
return fail('Vault is locked')
}
await writeClipboard(text, { secret })
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('shell:open-external', async (_e, url: string) => {
try {
if (!isSafeExternalUrl(url)) return fail('URL must start with http:// or https://')
await shell.openExternal(url)
return ok(null)
} catch (e) {
return fail(e)
}
})
// ── Error log ──────────────────────────────────────────────────────────────
// The renderer is untrusted here: an error loop must not fill the disk, so past the limit
// errors are dropped, and ok() because the renderer can do nothing useful with a refusal.
handle('log:error', (_e, payload: unknown) => {
try {
const { name, stack } = (
typeof payload === 'object' && payload !== null ? payload : {}
) as Record<string, unknown>
if (typeof name !== 'string' || typeof stack !== 'string') {
return fail('Invalid error payload')
}
const now = Date.now()
rendererErrorTimes = rendererErrorTimes.filter((t) => now - t < LOG_RATE_WINDOW_MS)
if (rendererErrorTimes.length >= LOG_RATE_LIMIT) return ok(null)
rendererErrorTimes.push(now)
appendError('renderer', { name, stack: stack.slice(0, LOG_STACK_MAX) })
return ok(null)
} catch (e) {
return fail(e)
}
})
// Returned as parts, not one text: the renderer drops log lines to fit the issue URL limit.
handle('log:build-report', () => {
try {
const environment = [
`**Version:** ${app.getVersion()}`,
`**Electron:** ${process.versions.electron ?? 'unknown'}`,
`**OS:** ${process.platform} ${release()} ${process.arch}`
].join(' · ')
return ok({ environment, log: readLogTail(REPORT_LOG_LINES) })
} catch (e) {
return fail(e)
}
})
handle('log:open-folder', async () => {
try {
const result = await shell.openPath(ensureLogDir())
return result ? fail(result) : ok(null)
} catch (e) {
return fail(e)
}
})
handle('log:clear', () => {
try {
clearLog()
return ok(null)
} catch (e) {
return fail(e)
}
})
// ── Updater ────────────────────────────────────────────────────────────────
const checkForUpdates = async (): Promise<IpcResult<string | null>> => {
try {
const result = await autoUpdater.checkForUpdates()
const available = result?.isUpdateAvailable ?? false
return ok(available ? (result?.updateInfo.version ?? null) : null)
} catch (e) {
return fail(e)
}
}
handle('updater:check-now', checkForUpdates)
// The opt-out is enforced here, not in the renderer, so a renderer bug can't leak the request.
// Each check downloads latest.yml from the real release, and its download count is the only
// signal of how many installs exist, so dev launches must not add to it.
handle('updater:check-on-startup', () =>
app.isPackaged && getPref('checkUpdatesOnStartup') ? checkForUpdates() : ok(null)
)
handle('updater:download', async () => {
try {
await autoUpdater.downloadUpdate()
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('updater:install-now', () => {
try {
autoUpdater.quitAndInstall(false, true)
return ok(null)
} catch (e) {
return fail(e)
}
})
handle('updater:get-current-version', () => {
try {
return ok(app.getVersion())
} catch (e) {
return fail(e)
}
})
handle('app:is-dev', () => {
try {
return ok(!app.isPackaged)
} catch (e) {
return fail(e)
}
})
// The app's only renderer reload. Resolves true once reloading, false when it locked instead.
handle('app:reload-window', async () => {
try {
if (isCreating) return fail('A vault is being created. Try again once it is ready.')
const win = requireWin()
cancelPendingConfirmations()
const { recycled } = await lockAndRecycle(win, { drainTimeoutMs: RELOAD_DRAIN_TIMEOUT_MS })
if (!recycled) {
win.webContents.send('vault:auto-locked')
return ok(false)
}
return ok(true)
} catch (e) {
return fail(e)
}
})
}