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:
2026-06-10 19:35:37 +01:00
parent 2ef636ce30
commit f047fba9a3
7 changed files with 464 additions and 9 deletions
+91
View File
@@ -1258,6 +1258,97 @@ input[type="range"]::-webkit-slider-thumb {
display: flex; flex-direction: column; gap: 6px; 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 /* Slideover title — tint the "+ New entry" prefix in accent colour so
the mode is unambiguous without changing the header layout. */ the mode is unambiguous without changing the header layout. */
#slideoverTitle.is-new-mode { color: var(--accent); } #slideoverTitle.is-new-mode { color: var(--accent); }
+22 -2
View File
@@ -82,6 +82,10 @@ type
FPowerNotify: THandle; // registration handle from PowerRegisterSuspendResumeNotification FPowerNotify: THandle; // registration handle from PowerRegisterSuspendResumeNotification
FSecureClipboard: TSecureClipboard; FSecureClipboard: TSecureClipboard;
FBalloonShown: Boolean; 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 // Window placement captured at MinimizeToTray time. Replayed on
// RestoreFromTray so the window comes back in the same state // RestoreFromTray so the window comes back in the same state
// (maximised / normal + position + size) as before hiding. // (maximised / normal + position + size) as before hiding.
@@ -91,6 +95,7 @@ type
FOnTrayRestore: TProc; FOnTrayRestore: TProc;
FOnLockRequest: TProc; FOnLockRequest: TProc;
FOnQuit: TProc; FOnQuit: TProc;
FOnQuickSearchRequest: TProc;
// Autofill: global hotkeys → inject credentials into browser. Combos // Autofill: global hotkeys → inject credentials into browser. Combos
// are user-configurable from Settings; defaults are Ctrl+Shift+L / // are user-configurable from Settings; defaults are Ctrl+Shift+L /
// Ctrl+Shift+P. We track which IDs are actually live so unregister // 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 // bridge does not call it itself, so the host stays in control of
// shutdown order (server stop, save state, etc.). // shutdown order (server stop, save state, etc.).
property OnQuit: TProc read FOnQuit write FOnQuit; 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. // Fired on main thread when the autofill hotkey fires.
// Args: (ATargetHWND, AWindowTitle). Handler calls ExecuteJavaScript // Args: (ATargetHWND, AWindowTitle). Handler calls ExecuteJavaScript
// to let JS match the title against vault entries. // to let JS match the title against vault entries.
@@ -374,6 +389,7 @@ begin
FSecureClipboard := TSecureClipboard.Create; FSecureClipboard := TSecureClipboard.Create;
FTrayAdded := False; FTrayAdded := False;
FBalloonShown := False; FBalloonShown := False;
FShowNotifications := True; // default on; JS pushes user pref on load
// Dedicated message-only window for tray + WTS notifications. // Dedicated message-only window for tray + WTS notifications.
FMsgWindow := AllocateHWnd(MsgWindowHandler); FMsgWindow := AllocateHWnd(MsgWindowHandler);
@@ -538,8 +554,9 @@ begin
ShowWindow(LAppHwnd, SW_HIDE); ShowWindow(LAppHwnd, SW_HIDE);
// 3. First-time only: pop a balloon notification so the user knows the // 3. First-time only: pop a balloon notification so the user knows the
// app is still running in the tray (and didn't crash). // app is still running in the tray (and didn't crash). Skipped when
if not FBalloonShown then // the user opted out via Settings.
if (not FBalloonShown) and FShowNotifications then
begin begin
ShowFirstTimeBalloon; ShowFirstTimeBalloon;
FBalloonShown := True; FBalloonShown := True;
@@ -615,6 +632,7 @@ const
ID_OPEN = 1; ID_OPEN = 1;
ID_LOCK = 2; ID_LOCK = 2;
ID_QUIT = 3; ID_QUIT = 3;
ID_QUICKSEARCH = 4;
var var
LMenu: HMENU; LMenu: HMENU;
LPt: TPoint; LPt: TPoint;
@@ -624,6 +642,7 @@ begin
if LMenu = 0 then Exit; if LMenu = 0 then Exit;
try try
AppendMenu(LMenu, MF_STRING, ID_OPEN, 'Open'); 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_STRING, ID_LOCK, 'Lock vault');
AppendMenu(LMenu, MF_SEPARATOR, 0, nil); AppendMenu(LMenu, MF_SEPARATOR, 0, nil);
AppendMenu(LMenu, MF_STRING, ID_QUIT, 'Quit'); AppendMenu(LMenu, MF_STRING, ID_QUIT, 'Quit');
@@ -643,6 +662,7 @@ begin
case LCmd of case LCmd of
ID_OPEN: if Assigned(FOnTrayRestore) then FOnTrayRestore(); ID_OPEN: if Assigned(FOnTrayRestore) then FOnTrayRestore();
ID_QUICKSEARCH: if Assigned(FOnQuickSearchRequest) then FOnQuickSearchRequest();
ID_LOCK: if Assigned(FOnLockRequest) then FOnLockRequest(); ID_LOCK: if Assigned(FOnLockRequest) then FOnLockRequest();
ID_QUIT: if Assigned(FOnQuit) then FOnQuit(); ID_QUIT: if Assigned(FOnQuit) then FOnQuit();
end; end;
+1
View File
@@ -4,6 +4,7 @@ object MainForm: TMainForm
Caption = 'Password Manager' Caption = 'Password Manager'
ClientHeight = 720 ClientHeight = 720
ClientWidth = 1100 ClientWidth = 1100
WindowState = wsMaximized
FormFactor.Width = 320 FormFactor.Width = 320
FormFactor.Height = 480 FormFactor.Height = 480
FormFactor.Devices = [Desktop] FormFactor.Devices = [Desktop]
+90 -1
View File
@@ -12,7 +12,8 @@ uses
FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes, FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes,
FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser, FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser,
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart, PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart,
PM.Favicon; PM.Favicon,
FMX.Platform.Win; // WindowHandleToPlatform → HWND for visibility check
type type
TMainForm = class(TForm) TMainForm = class(TForm)
@@ -67,6 +68,7 @@ type
ATargetHWND: HWND; const ATitle: string); ATargetHWND: HWND; const ATitle: string);
procedure BridgeDebugHotkey; procedure BridgeDebugHotkey;
procedure BridgeNewEntryHotkey(const AWindowTitle: string); procedure BridgeNewEntryHotkey(const AWindowTitle: string);
procedure BridgeQuickSearchRequest;
procedure WebBrowserInitialized(Sender: TObject); procedure WebBrowserInitialized(Sender: TObject);
end; end;
@@ -77,6 +79,10 @@ implementation
{$R *.fmx} {$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); procedure TMainForm.FormCreate(Sender: TObject);
begin begin
FServer := TPMHTTPServer.Create; FServer := TPMHTTPServer.Create;
@@ -90,6 +96,7 @@ begin
FBridge.OnAutofillRequest := BridgeAutofillRequest; FBridge.OnAutofillRequest := BridgeAutofillRequest;
FBridge.OnDebugHotkey := BridgeDebugHotkey; FBridge.OnDebugHotkey := BridgeDebugHotkey;
FBridge.OnNewEntryHotkey := BridgeNewEntryHotkey; FBridge.OnNewEntryHotkey := BridgeNewEntryHotkey;
FBridge.OnQuickSearchRequest := BridgeQuickSearchRequest;
FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup
FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later
FAutofillTargetHWND := 0; FAutofillTargetHWND := 0;
@@ -158,6 +165,44 @@ procedure TMainForm.WebBrowserInitialized(Sender: TObject);
begin begin
WebBrowser.EnableContextMenu := False; WebBrowser.EnableContextMenu := False;
WebBrowser.EnableShowDebugConsole := 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; end;
procedure TMainForm.BridgeNewEntryHotkey(const AWindowTitle: string); procedure TMainForm.BridgeNewEntryHotkey(const AWindowTitle: string);
@@ -529,6 +574,22 @@ begin
LogLine('App brought to front (autofill picker)'); LogLine('App brought to front (autofill picker)');
end 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 else if ACmd = 'app/ready' then
begin begin
WebBrowser.SetFocus; WebBrowser.SetFocus;
@@ -735,4 +796,32 @@ begin
LogLine('Autofill hotkey (' + LKind + ') — foreground: "' + ATitle + '"'); LogLine('Autofill hotkey (' + LKind + ') — foreground: "' + ATitle + '"');
end; 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. end.
Binary file not shown.
+35
View File
@@ -426,6 +426,20 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</div> </div>
<div class="setting-row" id="settingTrayNotifRow">
<span>
Show tray notifications
<small class="setting-hint">
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.
</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingTrayNotif">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row" id="settingAutoStartRow"> <div class="setting-row" id="settingAutoStartRow">
<span> <span>
Start with Windows Start with Windows
@@ -709,6 +723,27 @@
</div> </div>
</div> </div>
<!-- ============================================================ -->
<!-- MODAL: Quick search (tray menu → fast password copy) -->
<!-- ============================================================ -->
<div id="quickSearchModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm quick-search-panel">
<div class="quick-search-input">
<svg><use href="#i-search"/></svg>
<input id="quickSearchInput" type="text"
placeholder="Search and press Enter to copy password…"
autocomplete="off">
<kbd>Esc</kbd>
</div>
<div class="quick-search-results" id="quickSearchResults"></div>
<div class="quick-search-hint">
Enter = copy password &middot; Shift+Enter = copy username &middot;
click = copy password &middot; Esc = close
</div>
</div>
</div>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- MODAL: Standalone TOTP generator (paste secret → live code) --> <!-- MODAL: Standalone TOTP generator (paste secret → live code) -->
<!-- ============================================================ --> <!-- ============================================================ -->
+219
View File
@@ -240,6 +240,40 @@ const Bridge = (() => {
r(dataUri || ''); 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 // default — opt-in because it sends each entry's domain to a third
// party (DuckDuckGo). Synced because it's a portable preference. // party (DuckDuckGo). Synced because it's a portable preference.
faviconsEnabled: localStorage.getItem('faviconsEnabled') === '1', 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'); 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 = // Generate a cryptographically random RFC 4648 base32 secret. 20 bytes =
// 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size. // 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size.
function randomBase32Secret(numBytes) { function randomBase32Secret(numBytes) {
@@ -5449,6 +5613,8 @@ function openSettings() {
// Bridge.onAutoStartStatus. // Bridge.onAutoStartStatus.
Bridge.getAutoStart(); Bridge.getAutoStart();
} }
$('#settingTrayNotif').checked = state.trayNotificationsEnabled !== false;
$('#settingTrayNotifRow').style.display = Bridge.active ? '' : 'none';
$('#settingUser').textContent = state.username; $('#settingUser').textContent = state.username;
// Async: query server for recovery key state and update the label // Async: query server for recovery key state and update the label
refreshRecoveryStatus(); refreshRecoveryStatus();
@@ -5575,6 +5741,10 @@ async function enterApp() {
// Push the user-configured hotkeys (combos + enabled state) to Delphi. // Push the user-configured hotkeys (combos + enabled state) to Delphi.
// Replaces the historical "always Ctrl+Shift+L on startup" path. // Replaces the historical "always Ctrl+Shift+L on startup" path.
autofillPushHotkeys(); 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. // booleans. Synced so the user gets the same fold state across devices.
'sidebarCollapsed', 'sidebarCollapsed',
'faviconsEnabled', 'faviconsEnabled',
'trayNotificationsEnabled',
]; ];
function applySidebarCollapsed() { function applySidebarCollapsed() {
@@ -5637,6 +5808,14 @@ async function loadServerSettings() {
case 'faviconsEnabled': case 'faviconsEnabled':
localStorage.setItem('faviconsEnabled', v ? '1' : '0'); localStorage.setItem('faviconsEnabled', v ? '1' : '0');
break; 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. // Apply visual settings immediately.
@@ -6059,6 +6238,15 @@ async function init() {
? 'Will start with Windows (in tray)' ? 'Will start with Windows (in tray)'
: 'Wont start with Windows'); : 'Wont 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 => { $('#settingFavicons').addEventListener('change', e => {
state.faviconsEnabled = e.target.checked; state.faviconsEnabled = e.target.checked;
localStorage.setItem('faviconsEnabled', state.faviconsEnabled ? '1' : '0'); localStorage.setItem('faviconsEnabled', state.faviconsEnabled ? '1' : '0');
@@ -6259,6 +6447,37 @@ async function init() {
$('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value)); $('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value));
$$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette)); $$('#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 // Re-sync quickUnlockEnabled from the DPAPI source of truth. localStorage
// is wiped at each launch (random port → new origin), so the cached value // is wiped at each launch (random port → new origin), so the cached value
// can lie about the actual server-side state. // can lie about the actual server-side state.