/* ============================================================ Vault — UI V2 app.js Clean state + render layer. Crypto helpers preserved verbatim from legacy. Backend (PHP api.php / Delphi loopback) is detected from URL path. ============================================================ */ // ---- Backend detection ------------------------------------- const API = (location.pathname.indexOf('/password-manager/') === 0) ? '/password-manager/api.php' : ''; // ---- Delphi native bridge ---------------------------------- // Active only when running inside the Delphi-hosted WebView2 (API === ''). // Falls back to navigator.clipboard for the standalone PHP frontend. const prefResolvers = {}; const fileSaveResolvers = {}; const fileChunkResolvers = {}; // Stream a base64 string to Delphi in URL-sized pieces via cmd://file/chunk, // each acked (onFileChunkAck) before the next is sent. Returns true when all // pieces are buffered server-side, false on a stalled chunk. The ack CLEARS // the pending timeout — without that, a resolved chunk's stale 30s timeout // would later delete the CURRENT chunk's resolver and hang the transfer // forever (only the last location.href navigation "wins" per event loop, so // resolvers must be strictly 1-at-a-time and their timers torn down). async function _streamChunks(reqId, b64, onProgress) { const CHUNK = 1000000; const total = Math.ceil(b64.length / CHUNK); let done = 0; for (let off = 0; off < b64.length; off += CHUNK) { const piece = b64.slice(off, off + CHUNK); const ok = await new Promise(res => { const timer = setTimeout(() => { if (fileChunkResolvers[reqId]) { delete fileChunkResolvers[reqId]; res(false); } }, 30000); fileChunkResolvers[reqId] = { resolve: res, timer }; window.location.href = 'cmd://file/chunk?reqId=' + encodeURIComponent(reqId) + '&data=' + encodeURIComponent(piece); }); if (!ok) return false; done++; if (typeof onProgress === 'function') onProgress(Math.round(done / total * 100)); } return true; } let versionResolver = null; let launchModeResolver = null; const folderPickResolvers = {}; const fileWriteResolvers = {}; const fileListResolvers = {}; const fileDeleteResolvers = {}; let autoStartResolver = null; const faviconResolvers = {}; let _faviconReqSeq = 0; const Bridge = (() => { const active = (API === ''); // Navigate to a cmd:// URL — intercepted synchronously by // TTMSFNCWebBrowser OnBeforeNavigate before any actual navigation occurs. function cmd(path) { window.location.href = path; } return { active, // Copy text to clipboard, excluded from Win+V history. // clearAfterMs: Delphi auto-clears after this many ms (0 = never). // Returns true when the bridge handled the copy, false as fallback signal. copySecure(text, clearAfterMs = 30000) { if (!active) return false; cmd('cmd://clipboard/copy?text=' + encodeURIComponent(text) + '&clear=' + clearAfterMs); return true; }, // Called by Delphi (ExecuteJavaScript) on WTS_SESSION_LOCK and // PBT_APMSUSPEND. When quick-unlock is enabled on this device the // DPAPI blob already gates access via the Windows user account, so // re-locking is redundant — we just stay unlocked and the user is // back where they left off when they return. onSystemLock() { if (state.quickUnlockEnabled) { if (typeof toast === 'function') toast('System lock — vault kept unlocked (quick unlock active)'); return; } if (typeof lockVault === 'function') lockVault(); }, // Called by Delphi (ExecuteJavaScript) when the user restores the // window from the tray icon. Useful for resetting auto-lock state // and giving a subtle visual cue. onTrayRestore() { // If the user has been away long enough that the auto-lock // should fire, lockVault was already called by either WTS lock // or the local idle timer — so we only reset here when still // unlocked. if (state.cryptoKey && !state.locked) { if (typeof resetAutoLock === 'function') resetAutoLock(); } }, // ---- Autofill (Ctrl+Shift+L / Ctrl+Shift+P global hotkeys) ---------- // Called by Delphi when a hotkey fires. windowTitle = foreground // window title at hotkey time. kind = "full" (user+Tab+pwd) or // "password" (password only — for step-2 forms, unlock screens). onAutofillRequest(windowTitle, kind) { autofillHandleRequest(windowTitle, kind || 'full'); }, // Called by Delphi after cmd://autofill/execute with whether the // keystrokes were actually sent (false = elevated target, UIPI). onAutofillResult(ok) { autofillReportResult(!!ok); }, // Called by Delphi on Ctrl+Shift+A. Opens the new-entry modal with // the foreground window title pre-filled (browser suffix stripped). onNewEntryFromTitle(windowTitle) { if (!state.cryptoKey || state.locked || !state.token) { toast('Unlock the vault first', 'warning'); return; } const cleaned = autofillStripBrowserSuffix(windowTitle); openSlideOver(null, { presetTitle: cleaned }); }, // Tell Delphi to simulate keystrokes. Empty username = password only // (no Tab is sent). hideAfter=true asks Delphi to hide our window // back to the tray AFTER SendInput completes — necessary for the // Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or // Win10/11 anti-focus-stealing rules block the target). executeAutofill(username, password, hideAfter, field) { if (!active) return; // field='user' → type only the username (no Tab / password). cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) + '&password=' + encodeURIComponent(password) + (hideAfter ? '&hide_after=1' : '') + (field === 'user' ? '&field=user' : '') + (state.autofillClearField ? '' : '&clear=0') + (state.autofillFailBalloon ? '' : '¬ify=0')); }, // Ask Delphi to bring the main window to front (used when the // multi-match picker opens, so the user definitely sees it even // if the app was minimised to tray or hidden behind other apps). focusApp() { if (!active) return; cmd('cmd://app/focus'); }, // Tell Delphi the page is rendered and waiting for input — Delphi // calls WebBrowser.SetFocus (the FMX control needs OS-level focus // before any input.focus() inside the DOM can work) then injects // a focus script targeting the visible auth field. appReady() { if (!active) return; cmd('cmd://app/ready'); }, // Sync the Windows title bar with the app theme (dark vs light). // Calls DwmSetWindowAttribute DWMWA_USE_IMMERSIVE_DARK_MODE on the // form's HWND. No-op on Windows < 10 build 19044. syncTitleBarTheme(mode) { if (!active) return; cmd('cmd://app/theme?mode=' + (mode === 'light' ? 'light' : 'dark')); }, // Tell Delphi we couldn't find a match / user cancelled. cancelAutofill() { if (!active) return; cmd('cmd://autofill/cancel'); }, // Sync the hotkey registration state with this device's preference. configureAutofill(enabled) { if (!active) return; cmd('cmd://autofill/configure?enabled=' + (enabled ? '1' : '0')); }, // Send the full hotkey configuration to Delphi (enabled + combos). // combos = { full: {ctrl,shift,alt,win,key}, password: {…} }. setAutofillHotkeys(enabled, combos) { if (!active) return; const f = autofillComboToWin32(combos.full); const p = autofillComboToWin32(combos.password); let qs = ''; if (combos.quickSearch) { const q = autofillComboToWin32(combos.quickSearch); qs = '&qs_mods=' + q.mods + '&qs_vk=' + q.vk; } cmd('cmd://autofill/hotkeys?enabled=' + (enabled ? '1' : '0') + '&full_mods=' + f.mods + '&full_vk=' + f.vk + '&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk + qs); }, // Called by Delphi after a setAutofillHotkeys request, with true if // BOTH combos registered successfully, false otherwise (clash with // another app holding a global hotkey). Surfaces a toast. onAutofillHotkeysResult(allOk) { if (allOk) { toast('Autofill hotkeys updated'); } else { toast('Autofill: one or both hotkeys are already used by another app', 'warning'); } }, // ---- Device-bound prefs (DPAPI-backed) ---------------------------- // localStorage is keyed by origin, and our HTTP port is random on // every launch — so anything we put there is wiped at reboot. For // prefs that need to survive a reboot (remembered username, etc.), // round-trip through Delphi which persists via DPAPI. getPref(key) { if (!active) return Promise.resolve(''); return new Promise(resolve => { prefResolvers[key] = resolve; cmd('cmd://prefs/get?key=' + encodeURIComponent(key)); setTimeout(() => { if (prefResolvers[key] === resolve) { delete prefResolvers[key]; resolve(''); } }, 2000); }); }, setPref(key, value) { if (!active) return; cmd('cmd://prefs/set?key=' + encodeURIComponent(key) + '&value=' + encodeURIComponent(value || '')); }, onPrefResult(key, value) { const r = prefResolvers[key]; if (r) { delete prefResolvers[key]; r(value || ''); } }, // Native Save As dialog — bypasses WebView2's browser download UI // (which Edge wraps with "Téléchargements" popup + Open File prompt). // The caller passes raw bytes as Uint8Array OR a string; we base64 // it and ship to Delphi which writes the file after the user picks. saveFile(name, content, onProgress) { if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' }); let bytes; if (typeof content === 'string') bytes = new TextEncoder().encode(content); else bytes = content; const b64 = bytesToBase64(bytes); const reqId = 'fs_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); // cmd:// goes through window.location.href, which WebView2 caps at // roughly a couple MB of URL. A big attachment base64'd blows past // that → the navigation blanks the document (black screen). So for // anything large we stream the data in chunks small enough to fit // a URL, each acknowledged before the next is sent (sequential — // otherwise repeated location.href assignments coalesce and only // the last lands). Small payloads keep the fast single-shot path. // ~1 MB base64 per chunk — comfortably under WebView2's ~2 MB // URL cap even after percent-encoding, while keeping the number // of round-trips (and total time) low on big exports. const CHUNK = 1000000; if (b64.length <= CHUNK) { return new Promise(resolve => { fileSaveResolvers[reqId] = resolve; cmd('cmd://file/save?name=' + encodeURIComponent(name) + '&data=' + encodeURIComponent(b64) + '&reqId=' + encodeURIComponent(reqId)); setTimeout(() => { if (fileSaveResolvers[reqId]) { delete fileSaveResolvers[reqId]; resolve({ ok: false, error: 'timeout' }); } }, 120000); }); } return (async () => { const ok = await _streamChunks(reqId, b64, onProgress); if (!ok) return { ok: false, error: 'chunk transfer failed' }; return await new Promise(resolve => { fileSaveResolvers[reqId] = resolve; cmd('cmd://file/save-commit?reqId=' + encodeURIComponent(reqId) + '&name=' + encodeURIComponent(name)); setTimeout(() => { if (fileSaveResolvers[reqId]) { delete fileSaveResolvers[reqId]; resolve({ ok: false, error: 'timeout' }); } }, 120000); }); })(); }, onFileChunkAck(reqId) { const r = fileChunkResolvers[reqId]; if (r) { delete fileChunkResolvers[reqId]; if (r.timer) clearTimeout(r.timer); r.resolve(true); } }, onFileSaveResult(reqId, ok, path, err) { const r = fileSaveResolvers[reqId]; if (r) { delete fileSaveResolvers[reqId]; r({ ok: !!ok, path: path || '', error: err || '' }); } }, // ---- Auto-backup helpers (folder pick + silent write + list + delete) pickFolder() { if (!active) return Promise.resolve(''); const reqId = 'fp_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); return new Promise(resolve => { folderPickResolvers[reqId] = resolve; cmd('cmd://folder/pick?reqId=' + encodeURIComponent(reqId)); setTimeout(() => { if (folderPickResolvers[reqId]) { delete folderPickResolvers[reqId]; resolve(''); } }, 120000); }); }, onFolderPickResult(reqId, path) { const r = folderPickResolvers[reqId]; if (r) { delete folderPickResolvers[reqId]; r(path || ''); } }, writeFile(path, content, onProgress) { if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' }); let bytes; if (typeof content === 'string') bytes = new TextEncoder().encode(content); else bytes = content; const b64 = bytesToBase64(bytes); const reqId = 'fw_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); // Same URL-length trap as saveFile: a big auto-backup base64'd // into a single cmd:// URL blows past WebView2's cap and the // write fails (silently, or blanks the page). Chunk it. const CHUNK = 1000000; if (b64.length <= CHUNK) { return new Promise(resolve => { fileWriteResolvers[reqId] = resolve; cmd('cmd://file/write?path=' + encodeURIComponent(path) + '&data=' + encodeURIComponent(b64) + '&reqId=' + encodeURIComponent(reqId)); setTimeout(() => { if (fileWriteResolvers[reqId]) { delete fileWriteResolvers[reqId]; resolve({ ok: false, error: 'timeout' }); } }, 30000); }); } return (async () => { const ok = await _streamChunks(reqId, b64, onProgress); if (!ok) return { ok: false, error: 'chunk transfer failed' }; return await new Promise(resolve => { fileWriteResolvers[reqId] = resolve; cmd('cmd://file/write-commit?reqId=' + encodeURIComponent(reqId) + '&path=' + encodeURIComponent(path)); setTimeout(() => { if (fileWriteResolvers[reqId]) { delete fileWriteResolvers[reqId]; resolve({ ok: false, error: 'timeout' }); } }, 30000); }); })(); }, onFileWriteResult(reqId, ok, err) { const r = fileWriteResolvers[reqId]; if (r) { delete fileWriteResolvers[reqId]; r({ ok: !!ok, error: err || '' }); } }, listFiles(dir, prefix) { if (!active) return Promise.resolve([]); const reqId = 'fl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); return new Promise(resolve => { fileListResolvers[reqId] = resolve; cmd('cmd://file/listMatch?dir=' + encodeURIComponent(dir) + '&prefix=' + encodeURIComponent(prefix || '') + '&reqId=' + encodeURIComponent(reqId)); setTimeout(() => { if (fileListResolvers[reqId]) { delete fileListResolvers[reqId]; resolve([]); } }, 10000); }); }, onFileListResult(reqId, json) { const r = fileListResolvers[reqId]; if (!r) return; delete fileListResolvers[reqId]; try { r(JSON.parse(json || '[]')); } catch { r([]); } }, deleteFile(path) { if (!active) return Promise.resolve(false); const reqId = 'fd_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); return new Promise(resolve => { fileDeleteResolvers[reqId] = resolve; cmd('cmd://file/delete?path=' + encodeURIComponent(path) + '&reqId=' + encodeURIComponent(reqId)); setTimeout(() => { if (fileDeleteResolvers[reqId]) { delete fileDeleteResolvers[reqId]; resolve(false); } }, 10000); }); }, onFileDeleteResult(reqId, ok) { const r = fileDeleteResolvers[reqId]; if (r) { delete fileDeleteResolvers[reqId]; r(!!ok); } }, // ---- Start with Windows (HKCU Run registry) ----------------------- getAutoStart() { if (!active) return Promise.resolve(false); return new Promise(resolve => { autoStartResolver = resolve; cmd('cmd://autostart/get'); setTimeout(() => { if (autoStartResolver === resolve) { autoStartResolver = null; resolve(false); } }, 2000); }); }, setAutoStart(enabled) { if (!active) return; cmd('cmd://autostart/set?enabled=' + (enabled ? '1' : '0')); }, onAutoStartStatus(enabled) { if (autoStartResolver) { const r = autoStartResolver; autoStartResolver = null; r(!!enabled); } state.autoStartEnabled = !!enabled; const cb = document.getElementById('settingAutoStart'); if (cb) cb.checked = !!enabled; }, // ---- Favicon fetch (via Delphi proxy → DuckDuckGo icons) ---------- // Returns a Promise. Multiple in-flight requests for // distinct hosts are tracked per reqId so they can't collide. fetchFavicon(host) { if (!active) return Promise.resolve(''); if (!host) return Promise.resolve(''); const reqId = 'fav_' + (++_faviconReqSeq); return new Promise(resolve => { faviconResolvers[reqId] = resolve; cmd('cmd://favicon/fetch?host=' + encodeURIComponent(host) + '&reqId=' + encodeURIComponent(reqId)); setTimeout(() => { if (faviconResolvers[reqId]) { delete faviconResolvers[reqId]; resolve(''); } }, 8000); }); }, onFaviconResult(reqId, host, dataUri) { const r = faviconResolvers[reqId]; if (r) { delete faviconResolvers[reqId]; r(dataUri || ''); } }, // Tray menu "Quick search…" → open a compact modal. wasHidden // (passed by Delphi) tells us whether the window was in the tray // before — if so, after the user picks an entry we ask Delphi to // hide the window again so the paste workflow is one keystroke // (Ctrl+V in the target app). // Locked vault → fall through to the master-password screen. openQuickSearch(wasHidden, forFill) { if (state.locked || !state.cryptoKey || !state.token) { const pwd = document.getElementById('loginPassword'); if (pwd && !document.getElementById('authScreen').classList.contains('is-hidden')) { setTimeout(() => pwd.focus(), 60); } if (typeof toast === 'function') toast('Vault is locked — unlock to search', 'warning'); // Tell Delphi we cancelled so the captured HWND doesn't // linger waiting for a never-coming /execute. if (forFill && Bridge.cancelAutofill) Bridge.cancelAutofill(); return; } if (typeof openQuickSearchModal === 'function') openQuickSearchModal(!!wasHidden, !!forFill); }, // Hide the window back to the tray icon. Used by Quick search to // restore "was in tray" state after a password copy. minimizeToTray(keepClipboard) { if (!active) return; // keepClipboard=true → the just-copied password survives the // minimise (quick-search copy-then-hide). Default clears it. cmd('cmd://app/minimize' + (keepClipboard ? '?keepclip=1' : '')); }, // Push the "show tray notifications" preference to Delphi so the // bridge gates the Shell_NotifyIcon NIF_INFO balloons accordingly. setTrayNotifications(enabled) { if (!active) return; cmd('cmd://tray/notifications?enabled=' + (enabled ? '1' : '0')); }, // Hardcoded build version surfaced from the Delphi host. Promise- // based with a 2s safety timeout — if the host is unreachable the // resolver fires with '' rather than hanging the UI. getAppVersion() { if (!active) return Promise.resolve(''); return new Promise(resolve => { versionResolver = resolve; cmd('cmd://app/version'); setTimeout(() => { if (versionResolver === resolve) { versionResolver = null; resolve(''); } }, 2000); }); }, onVersionResult(v) { if (versionResolver) { const r = versionResolver; versionResolver = null; r(v || ''); } }, // Was the app launched at Windows boot (via the HKCU Run entry's // -tray flag), or did the user double-click the exe? // Returns 'auto' | 'manual' | '' (bridge offline). getLaunchMode() { if (!active) return Promise.resolve(''); return new Promise(resolve => { launchModeResolver = resolve; cmd('cmd://app/launch-mode'); setTimeout(() => { if (launchModeResolver === resolve) { launchModeResolver = null; resolve(''); } }, 2000); }); }, onLaunchModeResult(mode) { if (launchModeResolver) { const r = launchModeResolver; launchModeResolver = null; r(mode || ''); } }, // Open an http(s) URL in the user's default browser via ShellExecute. // Delphi validates the scheme so a malformed entry can't smuggle a // file:// or custom handler. openUrl(url) { if (!active) return; cmd('cmd://app/open-url?url=' + encodeURIComponent(url)); }, }; })(); // Expose Bridge on window so Delphi's ExecuteJavaScript can reach it. window.Bridge = Bridge; // ---- Global state ------------------------------------------ const state = { token: sessionStorage.getItem('authToken') || '', csrf: sessionStorage.getItem('csrfToken') || '', salt: sessionStorage.getItem('salt') || '', username: sessionStorage.getItem('username') || '', // Profile picture as a data URI. Loaded from the server at enterApp // (users.avatar_b64). Empty → the initials avatar is shown instead. avatarDataUri: '', // KDF iteration count of the currently-logged-in user. Cached so reauth // and on-the-fly verifier computations don't need a /login/challenge // round trip every time. Refreshed from every auth response. kdfIterations: parseInt(sessionStorage.getItem('kdfIterations') || '0') || 0, // Auth-hash scheme of the current account. Drives which verifier formula // the client sends: 'pbkdf2-sha256-v2' → SHA256(keyHex + domain) so the // transmitted verifier is NOT the raw AES key; anything else → keyHex // (legacy / pre-decoupling accounts, byte-identical to before). Set from // the /login/challenge response, from the cold-start blob, or hardcoded // to v2 on register / master-pw change. hashAlgo: sessionStorage.getItem('hashAlgo') || '', // Argon2id KDF params { m, t, p } for the current account, or null for // PBKDF2 accounts. Set from /login/challenge, the cold-start blobs, or // ARGON2_DEFAULT_PARAMS on register / master-pw change. Needed wherever a // key/verifier is DERIVED FROM THE PASSWORD (login, reauth, rotation) — // NOT for cold-start verifier-from-raw-key paths. argon2Params: (() => { try { return JSON.parse(sessionStorage.getItem('argon2Params') || 'null'); } catch { return null; } })(), cryptoKey: null, entries: [], trashed: [], trashedCount: 0, // server-side count, updated separately from state.trashed folders: [{ name: 'All', color: '', icon: '' }], // Index in filteredEntries() of the keyboard-focused card (j/k nav). // -1 = no cursor. Cleared on view/search change so it never points // outside the current list. cursorIdx: -1, view: 'all', // 'all' | 'favorites' | 'folder:' | 'tag:' | 'trash' search: '', selectedId: null, theme: localStorage.getItem('theme') || 'dark', locked: false, // true after user clicks Lock (token still valid server-side) autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'), askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false // Show the raw site/URL under the display name on cards. Default OFF // because the display name is meant to be the user-friendly label; // most users don't want the hostname cluttering the card layout. showSiteOnCards: localStorage.getItem('showSiteOnCards') === '1', // default false compactActions: localStorage.getItem('compactActions') === '1', // default false viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list' | 'table' // Sort criterion + direction. Defaults: alphabetical by display name — // the most common pattern for a password manager (predictable lookup). // Other values: 'updated' (last modified), 'created' (creation order), // 'site' (raw site/URL, distinct from name when user set a title). sortBy: localStorage.getItem('sortBy') || 'name', sortDir: localStorage.getItem('sortDir') || 'asc', pageSize: parseInt(localStorage.getItem('pageSize') || '25') || 25, currentPage: 1, checked: new Set(), // entry IDs checked for batch operations // Advanced filters — runtime only (cleared on lockVault). Set of string // keys; see FILTER_DEFS for the available predicates. activeFilters: new Set(), hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF // entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked) hibpResults: new Map(), quickUnlockEnabled: localStorage.getItem('quickUnlockEnabled') === '1', recoveryConfigured: false, // refreshed by refreshRecoveryStatus on Settings open autofillEnabled: localStorage.getItem('autofillEnabled') !== '0', // default ON autofillClearField: localStorage.getItem('autofillClearField') !== '0', // default ON autofillFailBalloon: localStorage.getItem('autofillFailBalloon') !== '0', // default ON // Hotkey combos. Each combo = { ctrl, shift, alt, win, key }. // key is the uppercase character or VK label ('A'..'Z', '0'..'9', // 'F1'..'F12'). Default: Ctrl+Shift+L / Ctrl+Shift+P. Combos are // local-by-default but ALSO synced via settings_json so they travel // with the user (still Windows-only at runtime — non-Windows clients // ignore the value). autofillHotkeyFull: JSON.parse(localStorage.getItem('autofillHotkeyFull') || '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"L"}'), autofillHotkeyPwd: JSON.parse(localStorage.getItem('autofillHotkeyPwd') || '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'), quickSearchHotkey: JSON.parse(localStorage.getItem('quickSearchHotkey') || '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"Q"}'), sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') || '{"folders":false,"tags":false,"tools":false}'), // Fetch website favicons via the Delphi DuckDuckGo proxy. OFF by // default — opt-in because it sends each entry's domain to a third // party (DuckDuckGo). Synced because it's a portable preference. faviconsEnabled: localStorage.getItem('faviconsEnabled') === '1', // Show the "running in tray" balloon (and any future tray balloon). // Default ON — gates Shell_NotifyIcon NIF_INFO calls in PM.Bridge. trayNotificationsEnabled: localStorage.getItem('trayNotificationsEnabled') !== '0', // Days after which trashed entries are permanently purged. 0 = never. // Synced across devices because it's a user-level preference. trashAutoPurgeDays: parseInt(localStorage.getItem('trashAutoPurgeDays') || '0') || 0, passwordExpiryDays: parseInt(localStorage.getItem('passwordExpiryDays') || '0') || 0, // Table-view hidden columns. Array of keys that the user opted to // hide (e.g. ['folder', 'updated']). Synced across devices. tableColsHidden: (() => { try { return JSON.parse(localStorage.getItem('tableColsHidden') || '[]'); } catch { return []; } })(), editorPosition: localStorage.getItem('editorPosition') || 'right', confirmOnUnsaved: localStorage.getItem('confirmOnUnsaved') !== '0', // 'pw' (master only) | 'pin' (PIN only) | 'both' (master + PIN). 'pw' // is the safe default — any device without a configured PIN behaves // identically to the legacy unlock flow. unlockMode: localStorage.getItem('unlockMode') || 'pw', // Runtime mirror of the DPAPI blob existence — populated on init by // bridgePinStatus(). Editing this directly doesn't touch storage. pinConfigured: false, }; // ============================================================ // CRYPTO — extracted to js/app.crypto.js (§3.1), loaded as a // separate