diff --git a/css/style.css b/css/style.css index 488add5..c332c44 100644 --- a/css/style.css +++ b/css/style.css @@ -1258,6 +1258,97 @@ input[type="range"]::-webkit-slider-thumb { display: flex; flex-direction: column; gap: 6px; } +/* ---- Quick search modal (tray menu) ------------------ */ +.quick-search-panel { + padding: 0; + overflow: hidden; + max-width: 520px; + width: 100%; +} +.quick-search-input { + display: flex; align-items: center; gap: 10px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); +} +.quick-search-input svg { + width: 18px; height: 18px; + color: var(--text-dim); + flex-shrink: 0; +} +.quick-search-input input { + flex: 1; + background: transparent; + border: none; + color: var(--text); + font-size: 15px; + outline: none; +} +.quick-search-input kbd { + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + background: var(--bg); + color: var(--text-dim); + border: 1px solid var(--border); +} +.quick-search-results { + max-height: 360px; + overflow-y: auto; + padding: 6px 0; +} +.quick-search-row { + display: flex; align-items: center; gap: 10px; + padding: 8px 18px; + cursor: pointer; +} +.quick-search-row.is-selected { + background: var(--accent-soft); +} +.quick-search-row:hover { background: var(--accent-soft); } +.quick-search-avatar { + width: 32px; height: 32px; + border-radius: var(--radius-sm); + background: var(--bg-elev-2); + display: grid; place-items: center; + overflow: hidden; + font-weight: 600; + font-size: 13px; + flex-shrink: 0; +} +.quick-search-avatar img { + width: 100%; height: 100%; + object-fit: contain; + padding: 4px; + box-sizing: border-box; +} +.quick-search-main { + flex: 1; min-width: 0; +} +.quick-search-name { + font-size: 14px; font-weight: 600; + color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.quick-search-sub { + font-size: 12px; + color: var(--text-dim); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.quick-search-empty { + padding: 24px 18px; + text-align: center; + color: var(--text-dim); + font-size: 13px; + font-style: italic; +} +.quick-search-hint { + border-top: 1px solid var(--border); + padding: 8px 18px; + font-size: 11px; + color: var(--text-faint); + line-height: 1.5; +} + /* Slideover title — tint the "+ New entry" prefix in accent colour so the mode is unambiguous without changing the header layout. */ #slideoverTitle.is-new-mode { color: var(--accent); } diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index 659e45b..8668693 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -82,6 +82,10 @@ type FPowerNotify: THandle; // registration handle from PowerRegisterSuspendResumeNotification FSecureClipboard: TSecureClipboard; FBalloonShown: Boolean; + // User setting: gates Shell_NotifyIcon NIF_INFO balloons (currently + // only the "running in the tray" first-time popup, future tray + // notifications would honour the same flag). + FShowNotifications: Boolean; // Window placement captured at MinimizeToTray time. Replayed on // RestoreFromTray so the window comes back in the same state // (maximised / normal + position + size) as before hiding. @@ -91,6 +95,7 @@ type FOnTrayRestore: TProc; FOnLockRequest: TProc; FOnQuit: TProc; + FOnQuickSearchRequest: TProc; // Autofill: global hotkeys → inject credentials into browser. Combos // are user-configurable from Settings; defaults are Ctrl+Shift+L / // Ctrl+Shift+P. We track which IDs are actually live so unregister @@ -157,6 +162,16 @@ type // bridge does not call it itself, so the host stays in control of // shutdown order (server stop, save state, etc.). property OnQuit: TProc read FOnQuit write FOnQuit; + // Fired when the user picks "Quick search…" from the tray menu. + // Handler typically restores the window and pops a JS-side modal + // (Bridge.openQuickSearch) so the user can type to find an entry + // and copy its password without restoring the whole vault UI. + property OnQuickSearchRequest: TProc + read FOnQuickSearchRequest write FOnQuickSearchRequest; + // True (default) = show tray balloon notifications. Set False to keep + // the tray icon mute. Configured from the JS settings panel. + property ShowNotifications: Boolean + read FShowNotifications write FShowNotifications; // Fired on main thread when the autofill hotkey fires. // Args: (ATargetHWND, AWindowTitle). Handler calls ExecuteJavaScript // to let JS match the title against vault entries. @@ -374,6 +389,7 @@ begin FSecureClipboard := TSecureClipboard.Create; FTrayAdded := False; FBalloonShown := False; + FShowNotifications := True; // default on; JS pushes user pref on load // Dedicated message-only window for tray + WTS notifications. FMsgWindow := AllocateHWnd(MsgWindowHandler); @@ -538,8 +554,9 @@ begin ShowWindow(LAppHwnd, SW_HIDE); // 3. First-time only: pop a balloon notification so the user knows the - // app is still running in the tray (and didn't crash). - if not FBalloonShown then + // app is still running in the tray (and didn't crash). Skipped when + // the user opted out via Settings. + if (not FBalloonShown) and FShowNotifications then begin ShowFirstTimeBalloon; FBalloonShown := True; @@ -612,9 +629,10 @@ end; procedure TPMBridge.ShowTrayMenu; const - ID_OPEN = 1; - ID_LOCK = 2; - ID_QUIT = 3; + ID_OPEN = 1; + ID_LOCK = 2; + ID_QUIT = 3; + ID_QUICKSEARCH = 4; var LMenu: HMENU; LPt: TPoint; @@ -624,6 +642,7 @@ begin if LMenu = 0 then Exit; try AppendMenu(LMenu, MF_STRING, ID_OPEN, 'Open'); + AppendMenu(LMenu, MF_STRING, ID_QUICKSEARCH, 'Quick search…'); AppendMenu(LMenu, MF_STRING, ID_LOCK, 'Lock vault'); AppendMenu(LMenu, MF_SEPARATOR, 0, nil); AppendMenu(LMenu, MF_STRING, ID_QUIT, 'Quit'); @@ -642,9 +661,10 @@ begin PostMessage(FMsgWindow, WM_NULL, 0, 0); case LCmd of - ID_OPEN: if Assigned(FOnTrayRestore) then FOnTrayRestore(); - ID_LOCK: if Assigned(FOnLockRequest) then FOnLockRequest(); - ID_QUIT: if Assigned(FOnQuit) then FOnQuit(); + ID_OPEN: if Assigned(FOnTrayRestore) then FOnTrayRestore(); + ID_QUICKSEARCH: if Assigned(FOnQuickSearchRequest) then FOnQuickSearchRequest(); + ID_LOCK: if Assigned(FOnLockRequest) then FOnLockRequest(); + ID_QUIT: if Assigned(FOnQuit) then FOnQuit(); end; finally DestroyMenu(LMenu); diff --git a/delphi-backend/UMainForm.fmx b/delphi-backend/UMainForm.fmx index 24903f3..ab6677c 100644 --- a/delphi-backend/UMainForm.fmx +++ b/delphi-backend/UMainForm.fmx @@ -4,6 +4,7 @@ object MainForm: TMainForm Caption = 'Password Manager' ClientHeight = 720 ClientWidth = 1100 + WindowState = wsMaximized FormFactor.Width = 320 FormFactor.Height = 480 FormFactor.Devices = [Desktop] diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index d49b143..2d9e83d 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -12,7 +12,8 @@ uses FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes, FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser, PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart, - PM.Favicon; + PM.Favicon, + FMX.Platform.Win; // WindowHandleToPlatform → HWND for visibility check type TMainForm = class(TForm) @@ -67,6 +68,7 @@ type ATargetHWND: HWND; const ATitle: string); procedure BridgeDebugHotkey; procedure BridgeNewEntryHotkey(const AWindowTitle: string); + procedure BridgeQuickSearchRequest; procedure WebBrowserInitialized(Sender: TObject); end; @@ -77,6 +79,10 @@ implementation {$R *.fmx} +// Forward — used by WebBrowserInitialized (which sits above the actual +// definition lower in the unit). +function MaskAccessToken(const AUrl: string): string; forward; + procedure TMainForm.FormCreate(Sender: TObject); begin FServer := TPMHTTPServer.Create; @@ -90,6 +96,7 @@ begin FBridge.OnAutofillRequest := BridgeAutofillRequest; FBridge.OnDebugHotkey := BridgeDebugHotkey; FBridge.OnNewEntryHotkey := BridgeNewEntryHotkey; + FBridge.OnQuickSearchRequest := BridgeQuickSearchRequest; FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later FAutofillTargetHWND := 0; @@ -158,6 +165,44 @@ procedure TMainForm.WebBrowserInitialized(Sender: TObject); begin WebBrowser.EnableContextMenu := False; WebBrowser.EnableShowDebugConsole := False; + + // Race-safe navigation fallback: the 1.5 s timer in NavigateToVault + // assumes WebView2 finishes its async init within that window. On slow + // boots / cold-start machines Edge Chromium can take 2-3 s, and the + // timer's Navigate() call lands while the browser is still uninitialised + // → silently dropped → blank window forever. OnInitialized fires once + // the engine is ready, so if a navigation is still pending here, do it + // now. The timer either already ran (FPendingURL == '') or runs later + // and no-ops on the empty string. + FNavTimer.Enabled := False; + if FPendingURL <> '' then + begin + LogLine('OnInitialized fallback nav to: ' + MaskAccessToken(FPendingURL)); + WebBrowser.Navigate(FPendingURL); + FPendingURL := ''; + end; +end; + +procedure TMainForm.BridgeQuickSearchRequest; +var + LWasHidden: Boolean; + LWasHiddenJs: string; +begin + // Tray menu "Quick search…" — bring the window back so the user can + // see the modal, then ask JS to open it. JS handles the locked-vault + // case (shows the auth screen with master-pw focused instead). + if not FServer.Active then Exit; + // Remember whether the window was hidden BEFORE we restore — after the + // user picks an entry the JS layer asks us to minimize back so they can + // paste into the target app without an extra alt-tab. + LWasHidden := (not Self.Visible) or + IsIconic(WindowHandleToPlatform(Self.Handle).Wnd); + FBridge.RestoreFromTray; + LWasHiddenJs := BoolToStr(LWasHidden, True).ToLower; + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&typeof Bridge.openQuickSearch==="function")' + + 'Bridge.openQuickSearch(' + LWasHiddenJs + ')'); + LogLine('Quick search requested from tray menu (wasHidden=' + LWasHiddenJs + ')'); end; procedure TMainForm.BridgeNewEntryHotkey(const AWindowTitle: string); @@ -529,6 +574,22 @@ begin LogLine('App brought to front (autofill picker)'); end + // Used by the quick-search modal: after the user picks an entry the + // password is on the clipboard — if the app was hidden when invoked + // from the tray menu, hide it again so the user can paste straight + // into the target app without alt-tabbing. + else if ACmd = 'app/minimize' then + FBridge.MinimizeToTray + + // Tray balloon notifications on/off. JS pushes the user setting at + // startup (settings_json sync) and whenever they flip the toggle. + else if ACmd = 'tray/notifications' then + begin + FBridge.ShowNotifications := GetParam('enabled') = '1'; + LogLine('Tray notifications ' + + IfThen(FBridge.ShowNotifications, 'enabled', 'disabled')); + end + else if ACmd = 'app/ready' then begin WebBrowser.SetFocus; @@ -735,4 +796,32 @@ begin LogLine('Autofill hotkey (' + LKind + ') — foreground: "' + ATitle + '"'); end; +initialization + // WebView2 ships with a long list of "phone home" behaviours enabled by + // default (SmartScreen lookups, component updates, sync, UMA telemetry, + // domain reliability beacons, optimisation hints, etc.). For a vault + // that's meant to be 100% offline we disable them by passing flags + // through WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS — the standard way to + // configure the embedded Chromium without touching system policy. + // + // Must be set BEFORE the TMS FNC WebBrowser instantiates its + // CoreWebView2Environment, hence the unit initialization block. + SetEnvironmentVariable('WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS', + '--disable-background-networking ' + + '--disable-sync ' + + '--disable-component-update ' + + '--no-default-browser-check ' + + '--no-pings ' + + '--disable-client-side-phishing-detection ' + + '--disable-domain-reliability ' + + '--disable-breakpad ' + + '--disable-crash-reporter ' + + '--no-experiments ' + + '--metrics-recording-only ' + + '--disable-features=MediaRouter,OptimizationHints,InterestFeedContentSuggestions,' + + 'CalculateNativeWinOcclusion,HardwareMediaKeyHandling,Translate,' + + 'NetworkServiceInProcess,BackgroundFetch,SafeBrowsingEnhancedProtection,' + + 'AutofillServerCommunication,PrivacySandboxAdsAPIsOverride' + ); + end. diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 3fb97ff..644564f 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ diff --git a/index.html b/index.html index b390e85..cefef98 100644 --- a/index.html +++ b/index.html @@ -426,6 +426,20 @@ +
+ + Show tray notifications + + The first time the app minimises to the tray + it shows a small balloon explaining how to + restore it. Turn off if you've seen it. + + + +
Start with Windows @@ -709,6 +723,27 @@
+ + + + + diff --git a/js/app.js b/js/app.js index a2a1dcf..2b961fd 100644 --- a/js/app.js +++ b/js/app.js @@ -240,6 +240,40 @@ const Bridge = (() => { 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) { + 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'); + return; + } + if (typeof openQuickSearchModal === 'function') + openQuickSearchModal(!!wasHidden); + }, + + // Hide the window back to the tray icon. Used by Quick search to + // restore "was in tray" state after a password copy. + minimizeToTray() { + if (!active) return; + cmd('cmd://app/minimize'); + }, + + // 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')); + }, }; })(); @@ -306,6 +340,9 @@ const state = { // 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', }; // ============================================================ @@ -637,6 +674,133 @@ async function clearAllFavicons() { toast('Cached icons cleared'); } +// ============================================================ +// QUICK SEARCH MODAL (tray menu → fast password copy) +// ============================================================ +// +// Trades on the autofill workflow when SendInput can't reach the target +// (UIPI-elevated app, native non-text-input UI, etc.): right-click tray → +// Quick search → type → Enter → password is on the clipboard, ready to +// paste with Ctrl+V. App returns to whatever state it was in afterwards. + +let quickSearchSelected = 0; +// When opened from the tray menu, we hide back to tray after the user +// picks an entry — so the previously-foreground app comes back and +// Ctrl+V drops the password in. +let quickSearchHideAfter = false; + +function quickSearchScoreEntry(e, q) { + if (!q) return 1; // empty query → all entries pass, ordering preserved + const ql = q.toLowerCase(); + const fields = [ + (e.title || ''), + (e.site || ''), + (e.username || ''), + ].map(x => x.toLowerCase()); + let score = 0; + fields.forEach((f, i) => { + if (!f) return; + if (f.startsWith(ql)) score += 100 - i; // strong prefix match + else if (f.includes(ql)) score += 50 - i; // substring fallback + }); + return score; +} + +function quickSearchRender() { + const q = document.getElementById('quickSearchInput').value.trim(); + const list = state.entries + .map(e => ({ e, s: quickSearchScoreEntry(e, q) })) + .filter(x => x.s > 0) + .sort((a, b) => b.s - a.s) + .slice(0, 8) + .map(x => x.e); + + const box = document.getElementById('quickSearchResults'); + box.innerHTML = ''; + if (list.length === 0) { + box.appendChild(el('div', { class: 'quick-search-empty' }, + q ? 'No match for "' + q + '"' : 'No entries')); + quickSearchSelected = 0; + return; + } + if (quickSearchSelected >= list.length) quickSearchSelected = 0; + if (quickSearchSelected < 0) quickSearchSelected = list.length - 1; + + list.forEach((e, i) => { + const row = el('div', { + class: 'quick-search-row' + (i === quickSearchSelected ? ' is-selected' : ''), + 'data-id': String(e.id), + }); + // Avatar — favicon if cached, else initials. + const avatar = el('div', { class: 'quick-search-avatar' }); + if (e.icon_b64) { + const img = el('img', { src: e.icon_b64, alt: '' }); + img.addEventListener('error', () => { + avatar.innerHTML = ''; + avatar.textContent = initials(entryDisplayName(e)); + }); + avatar.appendChild(img); + } else { + avatar.textContent = initials(entryDisplayName(e)); + } + const main = el('div', { class: 'quick-search-main' }); + main.appendChild(el('div', { class: 'quick-search-name' }, entryDisplayName(e))); + if (e.username) + main.appendChild(el('div', { class: 'quick-search-sub' }, e.username)); + row.appendChild(avatar); + row.appendChild(main); + row.addEventListener('click', () => quickSearchPickEntry(e, false)); + box.appendChild(row); + }); +} + +async function quickSearchPickEntry(entry, copyUsername) { + if (copyUsername) { + const u = entry.username || ''; + if (!u) { + toast('No username on this entry', 'warning'); + return; + } + if (Bridge.active) Bridge.copySecure(u, 30000); + else { try { await navigator.clipboard.writeText(u); } catch (_) {} } + toast('Username copied · clears in 30s'); + } else { + const pwd = await decryptPwd(entry.encrypted_password, entry.iv); + if (pwd === '[ERROR]') { + toast('Decryption error', 'error'); + return; + } + if (Bridge.active) Bridge.copySecure(pwd, 30000); + else { try { await navigator.clipboard.writeText(pwd); } catch (_) {} } + toast(entryDisplayName(entry) + ' · password copied'); + } + closeQuickSearchModal(); +} + +function openQuickSearchModal(hideAfter) { + const modal = document.getElementById('quickSearchModal'); + const input = document.getElementById('quickSearchInput'); + modal.classList.remove('is-hidden'); + input.value = ''; + quickSearchSelected = 0; + quickSearchHideAfter = !!hideAfter; + quickSearchRender(); + setTimeout(() => input.focus(), 50); +} + +function closeQuickSearchModal() { + document.getElementById('quickSearchModal').classList.add('is-hidden'); + // If the modal was opened from the tray (window was hidden), restore + // the previous "in tray" state so the user can paste straight into + // the target app. Cancel (Esc / close X) also triggers this — they + // came from the tray, they should go back to the tray. + if (quickSearchHideAfter) { + quickSearchHideAfter = false; + if (Bridge.active && typeof Bridge.minimizeToTray === 'function') + Bridge.minimizeToTray(); + } +} + // Generate a cryptographically random RFC 4648 base32 secret. 20 bytes = // 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size. function randomBase32Secret(numBytes) { @@ -5449,6 +5613,8 @@ function openSettings() { // Bridge.onAutoStartStatus. Bridge.getAutoStart(); } + $('#settingTrayNotif').checked = state.trayNotificationsEnabled !== false; + $('#settingTrayNotifRow').style.display = Bridge.active ? '' : 'none'; $('#settingUser').textContent = state.username; // Async: query server for recovery key state and update the label refreshRecoveryStatus(); @@ -5575,6 +5741,10 @@ async function enterApp() { // Push the user-configured hotkeys (combos + enabled state) to Delphi. // Replaces the historical "always Ctrl+Shift+L on startup" path. autofillPushHotkeys(); + // Sync the tray notifications preference to Delphi (default ON; + // settings_json may have flipped it). + if (Bridge.active && typeof Bridge.setTrayNotifications === 'function') + Bridge.setTrayNotifications(state.trayNotificationsEnabled !== false); } // ============================================================ @@ -5595,6 +5765,7 @@ const SYNCED_SETTING_KEYS = [ // booleans. Synced so the user gets the same fold state across devices. 'sidebarCollapsed', 'faviconsEnabled', + 'trayNotificationsEnabled', ]; function applySidebarCollapsed() { @@ -5637,6 +5808,14 @@ async function loadServerSettings() { case 'faviconsEnabled': localStorage.setItem('faviconsEnabled', v ? '1' : '0'); break; + case 'trayNotificationsEnabled': + localStorage.setItem('trayNotificationsEnabled', v ? '1' : '0'); + // Push the synced value to Delphi so the bridge honours + // it from this point on (the user may have flipped it + // on another device). + if (Bridge.active && typeof Bridge.setTrayNotifications === 'function') + Bridge.setTrayNotifications(v); + break; } }); // Apply visual settings immediately. @@ -6059,6 +6238,15 @@ async function init() { ? 'Will start with Windows (in tray)' : 'Won’t start with Windows'); }); + $('#settingTrayNotif').addEventListener('change', e => { + state.trayNotificationsEnabled = e.target.checked; + localStorage.setItem('trayNotificationsEnabled', e.target.checked ? '1' : '0'); + if (Bridge.active) Bridge.setTrayNotifications(e.target.checked); + saveServerSettings(); + toast(e.target.checked + ? 'Tray notifications enabled' + : 'Tray notifications disabled'); + }); $('#settingFavicons').addEventListener('change', e => { state.faviconsEnabled = e.target.checked; localStorage.setItem('faviconsEnabled', state.faviconsEnabled ? '1' : '0'); @@ -6259,6 +6447,37 @@ async function init() { $('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value)); $$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette)); + // Quick-search modal (tray menu) — keyboard nav + close + const qsInput = document.getElementById('quickSearchInput'); + if (qsInput) { + qsInput.addEventListener('input', () => { + quickSearchSelected = 0; + quickSearchRender(); + }); + qsInput.addEventListener('keydown', e => { + const rows = document.querySelectorAll('#quickSearchResults .quick-search-row'); + if (e.key === 'Escape') { + e.preventDefault(); + closeQuickSearchModal(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (rows.length) { quickSearchSelected++; quickSearchRender(); } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (rows.length) { quickSearchSelected--; quickSearchRender(); } + } else if (e.key === 'Enter') { + e.preventDefault(); + const sel = rows[quickSearchSelected]; + if (!sel) return; + const id = parseInt(sel.dataset.id, 10); + const entry = state.entries.find(x => x.id === id); + if (entry) quickSearchPickEntry(entry, e.shiftKey); + } + }); + } + $$('#quickSearchModal [data-close]').forEach(b => + b.addEventListener('click', closeQuickSearchModal)); + // Re-sync quickUnlockEnabled from the DPAPI source of truth. localStorage // is wiped at each launch (random port → new origin), so the cached value // can lie about the actual server-side state.