a42e4b205d
Batched session work sharing app.js / index.html / Entries.pas, so it can't
split cleanly without interactive hunk staging.
- feat: encrypt `template` metadata at rest (template_enc/iv, added to
ENCRYPTED_META_FIELDS). withEncryptedMeta skips an absent template key so
partial re-ships (add-tag, move-to-folder) don't wipe it via LHasTemplate.
Cleartext column kept as migration fallback. +3 unit tests.
- feat: first-run guided tour ("How it works") — spotlight + bubble, no GIFs,
re-launchable from Settings, seen-flag in DPAPI prefs.
- fix(import): preserve original created_at on restore (was stamped to import
time); restore entry icons on overwrite (PUT ignores icon_b64).
- fix(settings): correct clipboard-privacy copy (already excluded from Win+V);
PIN text 4-6 -> 4-12; reorder Set-PIN above unlock-method; move tray/startup
toggles to General; dedicated backup-password button + warning status; tab icons.
- chore: remove dead legacy monolith (app-legacy.js, index-legacy.html,
style-legacy.css) + unused passkeyBtn stub.
- docs: full-source review (CODE_AUDIT 6b), template + favorite/pinned notes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
506 lines
22 KiB
JavaScript
506 lines
22 KiB
JavaScript
// ============================================================
|
|
// 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 =
|
|
'<div class="tour-bubble-title">' + step.title + '</div>' +
|
|
'<div class="tour-bubble-body">' + step.body + '</div>' +
|
|
'<div class="tour-bubble-foot">' +
|
|
'<span class="tour-bubble-count">' + (tourIdx + 1) + ' / ' + TOUR_STEPS.length + '</span>' +
|
|
'<span class="tour-bubble-btns">' +
|
|
'<button class="btn btn-ghost btn-sm" id="tourSkip">Skip</button>' +
|
|
'<button class="btn btn-primary btn-sm" id="tourNext">' +
|
|
(last ? 'Done' : 'Next') + '</button>' +
|
|
'</span></div>';
|
|
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);
|
|
}
|
|
}
|
|
|