src/main/prefs.ts
Rama main · réplica de lectura del repositorio original.
import { app } from 'electron'
import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs'
import { isAbsolute, join } from 'path'
import type { NoteSort, Prefs, RecentVault } from '@shared/types'
const DEFAULTS: Prefs = {
recentVaults: [],
unconfirmedRecoveryVaults: [],
autoLockMinutes: 15,
allowScreenCapture: false,
lockOnMinimize: false,
clipboardClearSeconds: 60,
clearClipboardOnLock: true,
noteSort: { field: 'updatedAt', direction: 'desc' },
checkUpdatesOnStartup: true
}
const MAX_RECENT_VAULTS = 5
function prefsPath(): string {
return join(app.getPath('userData'), 'prefs.json')
}
function isNoteSort(v: unknown): v is NoteSort {
if (typeof v !== 'object' || v === null || Array.isArray(v)) return false
const { field, direction, ...rest } = v as Record<string, unknown>
return (
Object.keys(rest).length === 0 &&
['updatedAt', 'createdAt', 'title'].includes(field as string) &&
(direction === 'asc' || direction === 'desc')
)
}
export function isLocalVaultPath(p: string): boolean {
// a leading \\ or // is a UNC path: any fs call on it opens an SMB connection to that host
return isAbsolute(p) && !/^[\\/]{2}/.test(p)
}
function isRecentVault(v: unknown): v is RecentVault {
if (typeof v !== 'object' || v === null) return false
const { path, hasKeyFile, lastOpenedAt } = v as Record<string, unknown>
return (
typeof path === 'string' &&
isLocalVaultPath(path) &&
typeof hasKeyFile === 'boolean' &&
typeof lastOpenedAt === 'number'
)
}
const PREFS_VALIDATORS = {
autoLockMinutes: (v: unknown) =>
typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 480,
allowScreenCapture: (v: unknown) => typeof v === 'boolean',
lockOnMinimize: (v: unknown) => typeof v === 'boolean',
checkUpdatesOnStartup: (v: unknown) => typeof v === 'boolean',
clearClipboardOnLock: (v: unknown) => typeof v === 'boolean',
clipboardClearSeconds: (v: unknown) =>
typeof v === 'number' && [0, 10, 30, 60, 120, 300].includes(v),
noteSort: isNoteSort
} satisfies Record<
Exclude<keyof Prefs, 'recentVaults' | 'unconfirmedRecoveryVaults'>,
(v: unknown) => boolean
>
// Object.hasOwn, not `in` or a bare lookup: 'toString' or 'constructor' would resolve to
// Object.prototype members and pass as validators.
export function isPrefKey(key: unknown): key is keyof typeof PREFS_VALIDATORS {
return typeof key === 'string' && Object.hasOwn(PREFS_VALIDATORS, key)
}
export function isValidPref<K extends keyof typeof PREFS_VALIDATORS>(
key: K,
value: unknown
): value is Prefs[K] {
return PREFS_VALIDATORS[key](value)
}
function resetToDefault<K extends keyof Prefs>(prefs: Prefs, key: K): void {
prefs[key] = DEFAULTS[key]
}
export function getPrefs(): Prefs {
const path = prefsPath()
if (!existsSync(path)) return { ...DEFAULTS }
try {
const stored: unknown = JSON.parse(readFileSync(path, 'utf8'))
// a top-level string or array would spread its indices into prefs as keys
if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) {
return { ...DEFAULTS }
}
const prefs = { ...DEFAULTS, ...(stored as Partial<Prefs>) }
// prefs.json can be hand-edited, so a stored value is as untrusted as one from the renderer.
for (const key of Object.keys(PREFS_VALIDATORS) as (keyof typeof PREFS_VALIDATORS)[]) {
if (!isValidPref(key, prefs[key])) resetToDefault(prefs, key)
}
prefs.recentVaults = Array.isArray(prefs.recentVaults)
? prefs.recentVaults
.filter(isRecentVault)
.slice(0, MAX_RECENT_VAULTS)
.map(({ path, hasKeyFile, lastOpenedAt }) => ({ path, hasKeyFile, lastOpenedAt }))
: []
prefs.unconfirmedRecoveryVaults = Array.isArray(prefs.unconfirmedRecoveryVaults)
? prefs.unconfirmedRecoveryVaults.filter((p) => typeof p === 'string' && p !== '')
: []
return prefs
} catch {
return { ...DEFAULTS }
}
}
export function setPrefs(patch: Partial<Prefs>): Prefs {
const updated = { ...getPrefs(), ...patch }
const path = prefsPath()
const tmp = `${path}.tmp`
// a crash mid-write must leave the old prefs.json, not a truncated one getPrefs reads as defaults
writeFileSync(tmp, JSON.stringify(updated, null, 2))
renameSync(tmp, path)
return updated
}
export function getPref<K extends keyof Prefs>(key: K): Prefs[K] {
return getPrefs()[key]
}
export function setPref<K extends keyof Prefs>(key: K, value: Prefs[K]): void {
setPrefs({ [key]: value })
}
export function samePath(a: string, b: string): boolean {
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
}
export function recordVaultUsed(path: string, hasKeyFile: boolean = false): void {
const recentVaults = getPrefs().recentVaults
const rest = recentVaults.filter((recentVault) => !samePath(recentVault.path, path))
setPrefs({
recentVaults: [{ path, hasKeyFile, lastOpenedAt: Date.now() }, ...rest].slice(
0,
MAX_RECENT_VAULTS
)
})
}
// Vaults whose current recovery key was issued but never confirmed as saved. Kept outside the
// vault so it survives a lock, quit or crash before the user confirms.
export function isRecoveryUnconfirmed(path: string): boolean {
return getPrefs().unconfirmedRecoveryVaults.some((p) => samePath(p, path))
}
export function markRecoveryUnconfirmed(path: string): void {
if (isRecoveryUnconfirmed(path)) return
setPrefs({ unconfirmedRecoveryVaults: [...getPrefs().unconfirmedRecoveryVaults, path] })
}
export function clearRecoveryUnconfirmed(path: string): void {
if (!isRecoveryUnconfirmed(path)) return
setPrefs({
unconfirmedRecoveryVaults: getPrefs().unconfirmedRecoveryVaults.filter(
(p) => !samePath(p, path)
)
})
}
export function getCurrentVaultPath(): string | null {
return getPrefs().recentVaults[0]?.path ?? null
}
export function promoteVaultToTop(vaultPath: string): void {
const recentVaults = getPref('recentVaults')
const existing = recentVaults.find((v) => samePath(v.path, vaultPath))
if (!existing) {
recordVaultUsed(vaultPath, false)
return
}
const rest = recentVaults.filter((v) => !samePath(v.path, vaultPath))
setPrefs({ recentVaults: [existing, ...rest].slice(0, MAX_RECENT_VAULTS) })
}