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:
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user