feat: tray quick-search + privacy hardening + race fixes
Quick search from tray - New "Quick search…" entry in the tray context menu (between Open and Lock vault). - Compact modal with live-filtered top-8 entries, arrow keys / Enter to copy the password (Shift+Enter copies the username instead), Esc to dismiss. Each row shows the favicon when cached. - Locked vault → focus the master password input instead of opening the modal (same pattern as the locked-autofill-hotkey path). - Window-state restore: Delphi remembers whether the window was hidden before the menu was opened and tells JS via the Bridge.openQuickSearch(wasHidden) arg. After the copy (or cancel) we hide back to the tray so the previously-foreground app comes back and Ctrl+V drops the password in. Tray notifications toggle - New Settings → Security "Show tray notifications" toggle. Gates Shell_NotifyIcon NIF_INFO balloons (currently only the "still running in the tray" first-time popup). Default ON, synced via settings_json so it follows the user across devices. - PM.Bridge.ShowNotifications exposed as a public property; JS pushes the value on every settings sync. Privacy: WebView2 phone-home killed - WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS set in the unit initialization section (before the TMS WebBrowser instantiates its CoreWebView2Environment). Disables: background networking, sync, component updates, breakpad/crashpad, domain reliability, client-side phishing detection, experiments, UMA upload, MediaRouter, OptimizationHints, SafeBrowsing enhanced, autofill server, privacy sandbox APIs. Verified via Resource Monitor: only 127.0.0.1 connections remain (plus DDG when favicons are on). Fixes - Blank-window-on-launch race: the 1.5 s navigation timer assumes WebView2 finishes init in time, but on slow machines Edge Chromium needs 2-3 s and the Navigate() call is silently dropped. WebBrowserInitialized now also navigates if a URL is still pending — first to run wins. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user