// ============================================================ // app.overlays.js — QUICK SEARCH + CHEATSHEET + PASSWORD HISTORY (§3.1) // ============================================================ // // Three small tray/overlay UI features that were interleaved in the monofile // (quick-search functions sit on both sides of the cheatsheet + history // modal), so they're extracted together as one contiguous block rather than // surgically split. Pure declarations + a few module lets/consts, no // top-level side effects → loads BEFORE app.js. All refs to state, api, // Bridge, render, decryptPwd resolve via shared global scope at call time. // // ============================================================ // 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; // When opened by Ctrl+Shift+Q hotkey, Delphi has saved the foreground // HWND and is waiting for cmd://autofill/execute. On pick we SendInput // the password instead of copying to the clipboard. let quickSearchFillMode = 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); // Left click → password only; Ctrl+click → username only // (step-2 forms / unlock screens). row.addEventListener('click', ev => quickSearchPickEntry(e, (ev.ctrlKey || ev.metaKey) ? 'user' : 'pwd')); // Right click → full (user + Tab + password). preventDefault + // stopPropagation so the custom context menu doesn't pop. row.addEventListener('contextmenu', ev => { ev.preventDefault(); ev.stopPropagation(); quickSearchPickEntry(e, 'full'); }); box.appendChild(row); }); } // mode: 'full' (user + Tab + password), 'user' (username only) or 'pwd' // (password only). In fill mode each maps to a SendInput variant; in copy // mode 'full' has no meaning so it falls back to copying the password. async function quickSearchPickEntry(entry, mode) { mode = mode || 'full'; // Fill mode (Ctrl+Shift+Q hotkey): SendInput directly into the HWND // Delphi saved when the hotkey fired. No clipboard touch. if (quickSearchFillMode) { if (mode === 'user') { const u = entry.username || ''; if (!u) { toast('No username on this entry', 'warning'); return; } if (Bridge.active) Bridge.executeAutofill(u, '', quickSearchHideAfter, 'user'); toast(entryDisplayName(entry) + ' · username sent'); } else { const pwd = await decryptPwd(entry.encrypted_password, entry.iv); if (pwd === '[ERROR]') { toast('Decryption error', 'error'); if (Bridge.active) Bridge.cancelAutofill(); return; } // 'full' → user + Tab + password (needs a username to make sense); // 'pwd' (or 'full' on an entry without a username) → password only. const u = (mode === 'full') ? (entry.username || '') : ''; // Single command — Delphi defers the SendInput by 60 ms then, // if hide_after=1, MinimizeToTray's AFTER the keystrokes land. // Hiding before SendInput would tip the Win10/11 anti-focus- // stealing rules into refusing to hand focus to the target. if (Bridge.active) Bridge.executeAutofill(u, pwd, quickSearchHideAfter); toast(entryDisplayName(entry) + (u ? ' · username + password sent' : ' · password sent')); } // Flags consumed — closeQuickSearchModal must not re-trigger. quickSearchFillMode = false; quickSearchHideAfter = false; closeQuickSearchModal(); return; } // Copy mode (tray / palette): no target window, so we can only place a // single value on the clipboard. 'user' copies the username, everything // else copies the password. if (mode === 'user') { 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'); } // keepClipboard=true — we just copied, so minimising back to the tray // must NOT clear the clipboard (the 30s auto-clear still applies). closeQuickSearchModal(true); } // ============================================================ // CHEATSHEET — press '?' anywhere to see all hotkeys // ============================================================ // // Discovery aid. Built dynamically so adding a new hotkey only requires // extending CHEATSHEET_GROUPS — the overlay picks it up automatically. const CHEATSHEET_GROUPS = [ { title: 'Inside the app', items: [ { keys: ['Ctrl', 'K'], desc: 'Command palette / quick search' }, { keys: ['?'], desc: 'Show this cheatsheet' }, { keys: ['Esc'], desc: 'Close modal / panel / cheatsheet' }, { keys: ['Enter'], desc: 'Open / confirm / submit' }, ], }, { title: 'Global (Windows-only, works even when minimised)', items: [ { keys: ['Ctrl', 'Shift', 'L'], desc: 'Autofill username + password into the active window' }, { keys: ['Ctrl', 'Shift', 'P'], desc: 'Autofill password only (step-2 forms, unlock screens)' }, { keys: ['Ctrl', 'Shift', 'Q'], desc: 'Quick search → SendInput password into the active window' }, { keys: ['Ctrl', 'Shift', 'A'], desc: 'Quick-add a new entry pre-filled with the foreground window title' }, ], }, { title: 'Tray', items: [ { keys: ['Right-click tray'], desc: 'Open / Quick search… / Lock vault / Quit' }, { keys: ['Click tray'], desc: 'Restore window' }, ], }, { title: 'On each card', items: [ { keys: [{ icon: 'i-globe' }], desc: 'Open the site in your default browser' }, { keys: [{ icon: 'i-copy' }], desc: 'Copy password to the secure clipboard (auto-clears in 30s)' }, { keys: ['Click card'], desc: 'Open the entry details / edit panel' }, ], }, ]; function renderCheatsheet() { const body = document.getElementById('cheatsheetBody'); body.innerHTML = ''; CHEATSHEET_GROUPS.forEach(group => { const section = el('section', { class: 'cheatsheet-group' }); section.appendChild(el('h4', null, group.title)); const list = el('div', { class: 'cheatsheet-list' }); group.items.forEach(item => { const row = el('div', { class: 'cheatsheet-row' }); const kc = el('div', { class: 'cheatsheet-keys' }); item.keys.forEach((k, i) => { if (i > 0) kc.appendChild(el('span', { class: 'cheatsheet-plus' }, '+')); if (k && typeof k === 'object' && k.icon) { // SVG icon — wrap in kbd-shaped chip for visual consistency // with the text key chips next to it. const chip = el('span', { class: 'cheatsheet-icon-chip' }); chip.appendChild(icon(k.icon)); kc.appendChild(chip); } else { kc.appendChild(el('kbd', null, String(k))); } }); row.appendChild(kc); row.appendChild(el('div', { class: 'cheatsheet-desc' }, item.desc)); list.appendChild(row); }); section.appendChild(list); body.appendChild(section); }); } // ============================================================ // PASSWORD HISTORY — open the modal, decrypt previous versions, // optionally revert one into the current field. // ============================================================ async function openHistoryModal(entryId) { const modal = document.getElementById('historyModal'); const body = document.getElementById('historyBody'); body.innerHTML = ''; body.appendChild(el('div', { class: 'history-loading' }, 'Loading…')); modal.classList.remove('is-hidden'); let rows; try { rows = await fetch(API + '/entries/' + entryId + '/history', { headers: authHeaders(), }).then(r => r.ok ? r.json() : []); } catch (e) { rows = []; } body.innerHTML = ''; if (!rows.length) { body.appendChild(el('p', { class: 'history-empty' }, 'No previous versions yet — they accumulate on each save.')); return; } // Decrypt each row's stored ciphertext with the CURRENT vault key // (master-pw change wipes the history, so the key always works). const list = el('ul', { class: 'history-list' }); for (const row of rows) { const li = el('li', { class: 'history-row' }); const meta = el('div', { class: 'history-meta' }); meta.appendChild(el('span', { class: 'history-date' }, formatDateShort(row.changed_at) + ' · ' + row.changed_at.slice(11, 16))); let plain = ''; try { plain = await decryptPwd(row.encrypted_password, row.iv); } catch (_) { plain = '[ERROR]'; } if (plain === '[ERROR]') plain = ''; const preview = el('div', { class: 'history-preview' }); const isNote = (row.kind === 'note'); const snippet = isNote ? (plain.replace(/\s+/g, ' ').slice(0, 80) + (plain.length > 80 ? '…' : '')) : '•'.repeat(Math.max(plain.length, 8)); const valueSpan = el('span', { class: 'history-value' }, snippet); preview.appendChild(valueSpan); let revealed = false; if (!isNote) { const eye = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show / hide' }); eye.appendChild(icon('i-eye')); eye.addEventListener('click', () => { revealed = !revealed; valueSpan.textContent = revealed ? plain : '•'.repeat(Math.max(plain.length, 8)); }); preview.appendChild(eye); } const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy' }); copy.appendChild(icon('i-copy')); copy.addEventListener('click', () => { if (Bridge.active) Bridge.copySecure(plain, 30000); else { try { navigator.clipboard.writeText(plain); } catch (_) {} } toast('Copied · clears in 30s'); }); preview.appendChild(copy); const revert = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' }); revert.appendChild(icon('i-rotate-ccw')); revert.appendChild(document.createTextNode(' Revert')); revert.addEventListener('click', () => { const target = isNote ? document.getElementById('soNoteBody') : document.getElementById('soPassword'); if (target) { target.value = plain; target.dispatchEvent(new Event('input', { bubbles: true })); soDirtyCheck(); toast('Restored — click Save to commit', 'warning'); } closeHistoryModal(); }); const actions = el('div', { class: 'history-actions' }); actions.appendChild(revert); li.appendChild(meta); li.appendChild(preview); li.appendChild(actions); list.appendChild(li); } body.appendChild(list); } function closeHistoryModal() { document.getElementById('historyModal').classList.add('is-hidden'); } function openCheatsheet() { renderCheatsheet(); document.getElementById('cheatsheetModal').classList.remove('is-hidden'); } function closeCheatsheet() { document.getElementById('cheatsheetModal').classList.add('is-hidden'); } // ============================================================ // Guided tour ("How it works") — spotlights real UI elements with a // bubble, no GIFs. Cheaper than baking videos into assets.res and never // goes stale when the UI changes. Auto-runs once, re-launchable from Settings. // ============================================================ const TOUR_STEPS = [ { sel: '#searchInput', title: 'Search', body: 'Find any entry instantly (Ctrl+K). Anywhere in Windows, press Ctrl+Shift+Q for quick search — copy or autofill without opening the app.' }, { sel: '#newEntryBtn', title: 'Add entries', body: 'Create a login, secure note, card, SSH key and more. The ▾ caret picks the type.' }, { sel: '.view-toggle', title: 'Views', body: 'Switch between cards, list and table. Your choice is remembered.' }, { sel: '.sidebar-section[data-section="tools"]', title: 'Tools', body: 'Authenticator (2FA codes), Vault health score and the password generator live here.' }, { sel: '#settingsBtn', title: 'Settings', body: 'WebDAV sync, encrypted auto-backup, autofill hotkeys (Ctrl+Shift+L) and security options.' }, { sel: '#cheatsheetBtn', title: 'Shortcuts', body: 'Every keyboard shortcut, anytime — or just press ?.' }, ]; let tourIdx = -1; function tourSeen() { return (Bridge.active ? null : localStorage.getItem('tourSeen')) === '1'; } function markTourSeen() { if (Bridge.active) Bridge.setPref('tourSeen', '1'); localStorage.setItem('tourSeen', '1'); // fast path + fallback } // Auto-launch on first unlock. Bridge pref is the source of truth (survives // the port-rotation localStorage wipe); fall back to localStorage when no Bridge. async function maybeStartTour() { let seen = localStorage.getItem('tourSeen') === '1'; if (Bridge.active) { try { seen = (await Bridge.getPref('tourSeen')) === '1'; } catch (_) {} } if (!seen) setTimeout(startTour, 600); // let the app shell settle first } function startTour() { tourIdx = 0; let bd = document.getElementById('tourBackdrop'); if (!bd) { bd = el('div', { id: 'tourBackdrop', class: 'tour-backdrop' }); const spot = el('div', { id: 'tourSpot', class: 'tour-spot' }); const bubble = el('div', { id: 'tourBubble', class: 'tour-bubble' }); document.body.append(bd, spot, bubble); } window.addEventListener('resize', showTourStep); showTourStep(); } function showTourStep() { // Skip any step whose target isn't in the DOM (feature hidden/disabled). while (tourIdx < TOUR_STEPS.length && !document.querySelector(TOUR_STEPS[tourIdx].sel)) tourIdx++; if (tourIdx >= TOUR_STEPS.length) return endTour(); const step = TOUR_STEPS[tourIdx]; const target = document.querySelector(step.sel); target.scrollIntoView({ block: 'center', behavior: 'smooth' }); // Reposition after any scroll settles so the spotlight lands on the rect. setTimeout(() => positionTour(target, step), 120); } function positionTour(target, step) { const spot = document.getElementById('tourSpot'); const bubble = document.getElementById('tourBubble'); if (!spot || !bubble) return; const r = target.getBoundingClientRect(); const pad = 6; spot.style.top = (r.top - pad) + 'px'; spot.style.left = (r.left - pad) + 'px'; spot.style.width = (r.width + pad * 2) + 'px'; spot.style.height = (r.height + pad * 2) + 'px'; const last = tourIdx === TOUR_STEPS.length - 1; bubble.innerHTML = '
' + step.title + '
' + '
' + step.body + '
' + '
' + '' + (tourIdx + 1) + ' / ' + TOUR_STEPS.length + '' + '' + '' + '' + '
'; bubble.querySelector('#tourSkip').onclick = endTour; bubble.querySelector('#tourNext').onclick = () => { tourIdx++; showTourStep(); }; // Place the bubble below the target if there's room, else above. bubble.style.visibility = 'hidden'; bubble.style.top = '0px'; bubble.style.left = '0px'; const bh = bubble.offsetHeight, bw = bubble.offsetWidth; const gap = 12; let top = r.bottom + gap; if (top + bh > window.innerHeight - 8) top = Math.max(8, r.top - gap - bh); let left = r.left; if (left + bw > window.innerWidth - 8) left = window.innerWidth - 8 - bw; bubble.style.top = Math.max(8, top) + 'px'; bubble.style.left = Math.max(8, left) + 'px'; bubble.style.visibility = ''; } function endTour() { tourIdx = -1; window.removeEventListener('resize', showTourStep); ['tourBackdrop', 'tourSpot', 'tourBubble'].forEach(id => { const n = document.getElementById(id); if (n) n.remove(); }); markTourSeen(); } function openQuickSearchModal(hideAfter, forFill) { const modal = document.getElementById('quickSearchModal'); const input = document.getElementById('quickSearchInput'); modal.classList.remove('is-hidden'); input.value = ''; quickSearchSelected = 0; quickSearchHideAfter = !!hideAfter; quickSearchFillMode = !!forFill; // Subtle hint to the user about what Enter will do. const hintEl = modal.querySelector('.quick-search-hint'); if (hintEl) { hintEl.textContent = forFill ? 'Enter / click = password · Shift+Enter / right-click = user+password · Ctrl+Enter / Ctrl+click = username · Esc = cancel' : 'Enter / click = copy password · Ctrl+Enter / Ctrl+click = copy username · Esc = close'; } quickSearchRender(); setTimeout(() => input.focus(), 50); } function closeQuickSearchModal(keepClipboard) { document.getElementById('quickSearchModal').classList.add('is-hidden'); // Fill-mode cancel: tell Delphi to drop the saved HWND so the next // /execute (e.g. an unrelated Ctrl+Shift+L) doesn't accidentally // target the stale window. if (quickSearchFillMode) { if (Bridge.active && typeof Bridge.cancelAutofill === 'function') Bridge.cancelAutofill(); quickSearchFillMode = false; } // 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. keepClipboard // is set by the copy path so minimising doesn't wipe the password we // just placed on the clipboard. if (quickSearchHideAfter) { quickSearchHideAfter = false; if (Bridge.active && typeof Bridge.minimizeToTray === 'function') Bridge.minimizeToTray(!!keepClipboard); } }