e88dbf3ec8
Two bugs in the same flow, both on the tray-originated path. 1. Esc did nothing until the search box was clicked: after the tray restore the WebView2 child does not hold the OS keyboard focus, so no keystroke reached the page at all — a DOM .focus() cannot fix that. Both quick-search entry points now call WebBrowser.SetFocus after restoring. 2. Esc closed the modal but left the app on screen. In fill mode closeQuickSearchModal fires cancelAutofill and then minimizeToTray, and two back-to-back window.location.href assignments COALESCE — the minimise was dropped. The copy path was unaffected because it never calls cancelAutofill, which is why this only showed up here. Deferred by a setTimeout(0), the same remedy CLAUDE.md already documents for quickunlock/store + pin/clear. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
514 lines
23 KiB
JavaScript
514 lines
23 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; }
|
|
// Toast deferred to Bridge.onAutofillResult — Delphi reports
|
|
// whether the keystrokes actually landed (elevated target = no).
|
|
autofillPendingToast = entryDisplayName(entry) + ' · username sent';
|
|
if (Bridge.active) Bridge.executeAutofill(u, '', quickSearchHideAfter, 'user');
|
|
} 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.
|
|
autofillPendingToast = entryDisplayName(entry) +
|
|
(u ? ' · username + password sent' : ' · password sent');
|
|
if (Bridge.active) Bridge.executeAutofill(u, pwd, quickSearchHideAfter);
|
|
}
|
|
// 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' + clipClearSuffix());
|
|
} 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', 'T'], desc: 'Autofill the 2FA code (click the 2FA field first)' },
|
|
{ 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)' },
|
|
{ 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' + clipClearSuffix());
|
|
});
|
|
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')
|
|
// Deferred: in fill mode the cancelAutofill above already fired a
|
|
// cmd:// navigation, and two back-to-back window.location.href
|
|
// assignments COALESCE — only one survives, so the minimise was
|
|
// silently dropped and the app stayed on screen. Same pitfall as
|
|
// the quickunlock/store + pin/clear pair in doChangeMasterPassword.
|
|
setTimeout(() => Bridge.minimizeToTray(!!keepClipboard), 0);
|
|
}
|
|
}
|
|
|