feat: quick-search fill modes + editable custom-field combobox + JS build gate

Quick search (Ctrl+Shift+Q fill mode)
- Enter / left-click → full autofill (username + Tab + password), like
  Ctrl+Shift+L.
- Shift+Enter / right-click → username only (new Delphi username-only
  SendInput path via field=user; ExecuteAutofill AUsernameOnly param).
- Ctrl+Enter / Ctrl+click → password only.
- Copy mode (tray / palette) unchanged: Enter/left = password,
  Shift+Enter/right = username.
- Clipboard fix: copy-then-minimise no longer wipes the just-copied
  password — MinimizeToTray takes an AClearClipboard flag (False on the
  quick-search copy path, driven by app/minimize?keepclip=1). The 30s
  auto-clear still guards it.
- Right-click on a result row suppresses the native/custom context menu
  (preventDefault + stopPropagation).

Editable custom-field combobox
- Option-backed custom fields (card brand, expiry year/month, etc.) now
  render a custom editable combobox instead of a locked <select>: an
  arrow drops a menu of ALL options (a native <datalist> filtered to the
  typed text, which confused users), while the input stays freely
  typeable for values not in the list. Storage shape unchanged.
- Outside-click closes the menu via the existing slideover mousedown
  handler; item mousedown + preventDefault so blur doesn't race the pick.

Build safety
- BuildAssets.ps1 runs `node --check` on every embedded .js before
  generating assets.res. A syntax error now aborts the asset build
  (exit 1, file + line logged) instead of shipping a dead bundle that
  only surfaces after a full Delphi rebuild. Node is optional: absent →
  warn and continue.

Docs
- CODE_AUDIT.md: full static-analysis report (security, latent bugs,
  maintainability, future features, prioritized action plan).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-03 08:13:25 +01:00
parent 7440d07793
commit 3076fec710
7 changed files with 507 additions and 73 deletions
+133 -63
View File
@@ -99,11 +99,13 @@ const Bridge = (() => {
// back to the tray AFTER SendInput completes — necessary for the
// Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or
// Win10/11 anti-focus-stealing rules block the target).
executeAutofill(username, password, hideAfter) {
executeAutofill(username, password, hideAfter, field) {
if (!active) return;
// field='user' → type only the username (no Tab / password).
cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) +
'&password=' + encodeURIComponent(password) +
(hideAfter ? '&hide_after=1' : ''));
(hideAfter ? '&hide_after=1' : '') +
(field === 'user' ? '&field=user' : ''));
},
// Ask Delphi to bring the main window to front (used when the
@@ -403,9 +405,11 @@ const Bridge = (() => {
// Hide the window back to the tray icon. Used by Quick search to
// restore "was in tray" state after a password copy.
minimizeToTray() {
minimizeToTray(keepClipboard) {
if (!active) return;
cmd('cmd://app/minimize');
// keepClipboard=true → the just-copied password survives the
// minimise (quick-search copy-then-hide). Default clears it.
cmd('cmd://app/minimize' + (keepClipboard ? '?keepclip=1' : ''));
},
// Push the "show tray notifications" preference to Delphi so the
@@ -1017,54 +1021,79 @@ function quickSearchRender() {
main.appendChild(el('div', { class: 'quick-search-sub' }, e.username));
row.appendChild(avatar);
row.appendChild(main);
row.addEventListener('click', () => quickSearchPickEntry(e, false));
// Left click → full (user + Tab + password); Ctrl+click → password
// only (step-2 forms / unlock screens).
row.addEventListener('click', ev =>
quickSearchPickEntry(e, (ev.ctrlKey || ev.metaKey) ? 'pwd' : 'full'));
// Right click → username only. preventDefault + stopPropagation so
// the custom context menu (installCustomContextMenu) doesn't pop.
row.addEventListener('contextmenu', ev => {
ev.preventDefault();
ev.stopPropagation();
quickSearchPickEntry(e, 'user');
});
box.appendChild(row);
});
}
async function quickSearchPickEntry(entry, copyUsername) {
// Fill mode (Ctrl+Shift+Q hotkey): SendInput the password directly into
// the HWND Delphi saved when the hotkey fired. No clipboard touch.
if (quickSearchFillMode && !copyUsername) {
const pwd = await decryptPwd(entry.encrypted_password, entry.iv);
if (pwd === '[ERROR]') {
toast('Decryption error', 'error');
if (Bridge.active) Bridge.cancelAutofill();
return;
// 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'));
}
// 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 window.
if (Bridge.active) Bridge.executeAutofill('', pwd, quickSearchHideAfter);
toast(entryDisplayName(entry) + ' · password sent');
// Both flags consumed — closeQuickSearchModal must not re-trigger.
// Flags consumed — closeQuickSearchModal must not re-trigger.
quickSearchFillMode = false;
quickSearchHideAfter = false;
closeQuickSearchModal();
return;
}
if (copyUsername) {
// 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 (!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 (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();
// keepClipboard=true — we just copied, so minimising back to the tray
// must NOT clear the clipboard (the 30s auto-clear still applies).
closeQuickSearchModal(true);
}
// ============================================================
@@ -1264,14 +1293,14 @@ function openQuickSearchModal(hideAfter, forFill) {
const hintEl = modal.querySelector('.quick-search-hint');
if (hintEl) {
hintEl.textContent = forFill
? 'Enter = type password into the active window · Esc = cancel'
: 'Enter = copy password · Shift+Enter = copy username · Esc = close';
? 'Enter = fill user+password · Shift+Enter = username · Ctrl+Enter = password · Esc = cancel'
: 'Enter / click = copy password · Shift+Enter / right-click = copy username · Esc = close';
}
quickSearchRender();
setTimeout(() => input.focus(), 50);
}
function closeQuickSearchModal() {
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
@@ -1284,11 +1313,13 @@ function closeQuickSearchModal() {
// 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.
// 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();
Bridge.minimizeToTray(!!keepClipboard);
}
}
@@ -4765,36 +4796,63 @@ function buildCustomFieldRow(field, idx, rerender) {
soDirtyCheck();
});
// Render a <select> when the field declares an `options` array (set
// by entry templates for things like card brand or expiration month).
// Falls back to a plain <input> otherwise. Storage shape unchanged —
// `field.value` still holds the chosen string.
// Value field. When the field declares an `options` array (entry
// templates: card brand, expiry year/month, etc.) we wrap the input in
// a CUSTOM editable combobox: an arrow button that drops a menu of ALL
// options (unlike a native <datalist>, which filters to what's typed),
// while the input stays freely typeable for a value not in the list.
// Storage shape unchanged — `field.value` holds the string either way.
let valueInput;
if (Array.isArray(field.options) && field.options.length > 0) {
valueInput = el('select', { class: 'so-input so-custom-value' });
valueInput.appendChild(el('option', { value: '' }, '-- Select --'));
let valueSlot; // what actually goes into the row (input or combo wrap)
const hasOptions = Array.isArray(field.options) && field.options.length > 0;
valueInput = el('input', {
type: field.is_secret ? 'password' : 'text',
class: 'so-input so-custom-value',
placeholder: hasOptions ? 'Pick or type…' : 'Value',
autocomplete: field.is_secret ? 'new-password' : 'off',
spellcheck: 'false',
});
valueInput.value = field.value || '';
valueInput.addEventListener('input', () => {
field.value = valueInput.value;
soDirtyCheck();
});
if (hasOptions && !field.is_secret) {
const combo = el('div', { class: 'so-combo' });
valueInput.classList.add('so-combo-input');
const arrow = el('button', {
class: 'so-combo-arrow', type: 'button', tabindex: '-1',
title: 'Show options',
});
arrow.appendChild(icon('i-chevron-down'));
const menu = el('div', { class: 'so-combo-menu is-hidden' });
field.options.forEach(opt => {
const o = el('option', { value: opt }, opt);
if (opt === (field.value || '')) o.selected = true;
valueInput.appendChild(o);
const item = el('div', { class: 'so-combo-item' }, opt);
// mousedown (not click) + preventDefault so the input doesn't
// blur-close the menu before we read the choice.
item.addEventListener('mousedown', ev => {
ev.preventDefault();
ev.stopPropagation();
valueInput.value = opt;
field.value = opt;
soDirtyCheck();
menu.classList.add('is-hidden');
});
menu.appendChild(item);
});
valueInput.addEventListener('change', () => {
field.value = valueInput.value;
soDirtyCheck();
arrow.addEventListener('click', ev => {
ev.stopPropagation();
// Close any other open combo first, then toggle this one.
document.querySelectorAll('.so-combo-menu:not(.is-hidden)')
.forEach(m => { if (m !== menu) m.classList.add('is-hidden'); });
menu.classList.toggle('is-hidden');
});
combo.appendChild(valueInput);
combo.appendChild(arrow);
combo.appendChild(menu);
valueSlot = combo;
} else {
valueInput = el('input', {
type: field.is_secret ? 'password' : 'text',
class: 'so-input so-custom-value',
placeholder: 'Value',
autocomplete: field.is_secret ? 'new-password' : 'off',
spellcheck: 'false',
});
valueInput.value = field.value || '';
valueInput.addEventListener('input', () => {
field.value = valueInput.value;
soDirtyCheck();
});
valueSlot = valueInput;
}
// Reveal eye — only meaningful for secret fields.
@@ -4850,7 +4908,7 @@ function buildCustomFieldRow(field, idx, rerender) {
});
row.appendChild(labelInput);
row.appendChild(valueInput);
row.appendChild(valueSlot);
row.appendChild(eye);
row.appendChild(secretBtn);
row.appendChild(copyBtn);
@@ -10804,6 +10862,13 @@ async function init() {
let _slideoverMouseDownInside = false;
document.addEventListener('mousedown', e => {
_slideoverMouseDownInside = !!(e.target.closest && e.target.closest('.slideover'));
// Close any open custom combobox menu when the click lands outside
// a combo (the arrow toggle + item mousedown both stopPropagation,
// so this only fires for genuine outside clicks).
if (!(e.target.closest && e.target.closest('.so-combo'))) {
document.querySelectorAll('.so-combo-menu:not(.is-hidden)')
.forEach(m => m.classList.add('is-hidden'));
}
}, true);
document.addEventListener('click', e => {
if (!$('#slideover').classList.contains('is-open')) return;
@@ -11496,7 +11561,12 @@ async function init() {
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);
// Shift+Enter → username, Ctrl+Enter → password only,
// plain Enter → full (user + Tab + password).
const mode = e.shiftKey ? 'user'
: (e.ctrlKey || e.metaKey) ? 'pwd'
: 'full';
if (entry) quickSearchPickEntry(entry, mode);
}
});
}