fix(autofill): kill residual hotkey chord; dedicated fail-balloon setting

Residual new-entry trigger (1 in 6): password chars go out as
KEYEVENTF_UNICODE (VK_PACKET, can't match a hotkey) — the real chord risk is
the Ctrl+A clear-field, which sends a real VK_A. If the user re-presses
Ctrl+Shift mid-sequence, that VK_A becomes physical Ctrl+Shift+A = our own
new-entry hotkey. ForceReleaseModifiers now runs inside
SendSelectAllAndDelete, at the risky instant, not just once up front.

Balloon: was gated by "Show tray notifications" (OFF for this user) — now
gated by its own synced setting "Tray alert when autofill is blocked"
(autofillFailBalloon, Settings > Autofill, default ON), carried as notify=0
on cmd://autofill/execute. ShowBalloon no longer gates internally; each
caller applies its own setting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-12 05:23:02 +01:00
parent 9a72fc0424
commit 72dcc3dd82
4 changed files with 48 additions and 10 deletions
+10 -4
View File
@@ -127,8 +127,8 @@ type
procedure ShowFirstTimeBalloon; procedure ShowFirstTimeBalloon;
function FindFMXAppWindow: HWND; function FindFMXAppWindow: HWND;
public public
// Tray balloon (gated by the "Show tray notifications" setting). Used // Tray balloon (caller applies its own gating setting). Used for
// for messages the user must see while the window is hidden — e.g. an // messages the user must see while the window is hidden — e.g. an
// autofill blocked by an elevated target. // autofill blocked by an elevated target.
procedure ShowBalloon(const ATitle, AText: string; AWarning: Boolean = False); procedure ShowBalloon(const ATitle, AText: string; AWarning: Boolean = False);
constructor Create(AMainForm: TForm); constructor Create(AMainForm: TForm);
@@ -623,8 +623,8 @@ procedure TPMBridge.ShowBalloon(const ATitle, AText: string;
var var
LBalloon: TNotifyIconData; LBalloon: TNotifyIconData;
begin begin
// Gated by the "Show tray notifications" user setting. // No gating here — each caller applies its own setting (first-time tray
if not FShowNotifications then Exit; // balloon → FShowNotifications; autofill-blocked → autofillFailBalloon).
// Build a separate TNotifyIconData with NIF_INFO set, NIM_MODIFY on the // Build a separate TNotifyIconData with NIF_INFO set, NIM_MODIFY on the
// same uID. szInfo/szInfoTitle carry the balloon content. // same uID. szInfo/szInfoTitle carry the balloon content.
FillChar(LBalloon, SizeOf(LBalloon), 0); FillChar(LBalloon, SizeOf(LBalloon), 0);
@@ -1091,6 +1091,12 @@ procedure SendSelectAllAndDelete;
var var
LInputs: array[0..5] of TInput; LInputs: array[0..5] of TInput;
begin begin
// This is the ONLY real-VK chord we inject (password chars go out as
// KEYEVENTF_UNICODE / VK_PACKET, which can't match a RegisterHotKey).
// If the user re-presses Ctrl+Shift mid-sequence, our VK_A below becomes
// a physical Ctrl+Shift+A = our own new-entry hotkey firing mid-fill.
// Force-release right here, at the risky instant — not just once up front.
ForceReleaseModifiers;
FillChar(LInputs, SizeOf(LInputs), 0); FillChar(LInputs, SizeOf(LInputs), 0);
LInputs[0].Itype := INPUT_KEYBOARD; LInputs[0].Itype := INPUT_KEYBOARD;
LInputs[0].ki.wVk := VK_CONTROL; LInputs[0].ki.wVk := VK_CONTROL;
+10 -4
View File
@@ -108,6 +108,9 @@ type
// Send Ctrl+A + Del before typing each field (user setting, default ON; // Send Ctrl+A + Del before typing each field (user setting, default ON;
// OFF for targets where Ctrl+A isn't select-all — terminals, RDP). // OFF for targets where Ctrl+A isn't select-all — terminals, RDP).
FAutofillPendingClear: Boolean; FAutofillPendingClear: Boolean;
// Tray balloon when the fill fails while the window is hidden (user
// setting "Tray alert when autofill is blocked", default ON).
FAutofillPendingNotify: Boolean;
// Created dynamically in FormCreate so the directive can pick either // Created dynamically in FormCreate so the directive can pick either
// TTMSFNCWebBrowser or TTMSFNCEdgeWebBrowser at compile time without // TTMSFNCWebBrowser or TTMSFNCEdgeWebBrowser at compile time without
// needing two .fmx variants. Aligned to Client to fill the remaining // needing two .fmx variants. Aligned to Client to fill the remaining
@@ -901,6 +904,7 @@ begin
FAutofillPendingHide := GetParam('hide_after') = '1'; FAutofillPendingHide := GetParam('hide_after') = '1';
FAutofillPendingUserOnly := GetParam('field') = 'user'; FAutofillPendingUserOnly := GetParam('field') = 'user';
FAutofillPendingClear := GetParam('clear') <> '0'; // absent = ON FAutofillPendingClear := GetParam('clear') <> '0'; // absent = ON
FAutofillPendingNotify := GetParam('notify') <> '0'; // absent = ON
FAutofillTargetHWND := 0; FAutofillTargetHWND := 0;
// Small timer so SetForegroundWindow has time to take effect before // Small timer so SetForegroundWindow has time to take effect before
@@ -1453,7 +1457,7 @@ procedure TMainForm.AutofillTimerTick(Sender: TObject);
var var
TargetHwnd: HWND; TargetHwnd: HWND;
PendingUser, PendingPass: string; PendingUser, PendingPass: string;
HideAfter, UserOnly, ClearFirst: Boolean; HideAfter, UserOnly, ClearFirst, NotifyFail: Boolean;
ForegroundAfter: HWND; ForegroundAfter: HWND;
begin begin
TargetHwnd := FAutofillPendingHWND; TargetHwnd := FAutofillPendingHWND;
@@ -1462,12 +1466,14 @@ begin
HideAfter := FAutofillPendingHide; HideAfter := FAutofillPendingHide;
UserOnly := FAutofillPendingUserOnly; UserOnly := FAutofillPendingUserOnly;
ClearFirst := FAutofillPendingClear; ClearFirst := FAutofillPendingClear;
NotifyFail := FAutofillPendingNotify;
FAutofillPendingHWND := 0; FAutofillPendingHWND := 0;
FAutofillPendingUser := ''; FAutofillPendingUser := '';
FAutofillPendingPass := ''; FAutofillPendingPass := '';
FAutofillPendingHide := False; FAutofillPendingHide := False;
FAutofillPendingUserOnly := False; FAutofillPendingUserOnly := False;
FAutofillPendingClear := False; FAutofillPendingClear := False;
FAutofillPendingNotify := False;
TTimer(Sender).Enabled := False; TTimer(Sender).Enabled := False;
TTimer(Sender).Free; TTimer(Sender).Free;
@@ -1487,9 +1493,9 @@ begin
'if(window.Bridge&&typeof Bridge.onAutofillResult==="function")' + 'if(window.Bridge&&typeof Bridge.onAutofillResult==="function")' +
'Bridge.onAutofillResult(' + BoolToStr(LFillOk, True).ToLower + ')'); 'Bridge.onAutofillResult(' + BoolToStr(LFillOk, True).ToLower + ')');
// Window hidden (tray) → the in-app failure toast is invisible; surface // Window hidden (tray) → the in-app failure toast is invisible; surface
// the block via a tray balloon instead (gated by the tray-notifications // the block via a tray balloon instead (gated by the dedicated
// setting inside ShowBalloon). // "Tray alert when autofill is blocked" setting, default ON).
if (not LFillOk) and if (not LFillOk) and NotifyFail and
((not Self.Visible) or IsIconic(WindowHandleToPlatform(Self.Handle).Wnd)) then ((not Self.Visible) or IsIconic(WindowHandleToPlatform(Self.Handle).Wnd)) then
FBridge.ShowBalloon('Autofill blocked', FBridge.ShowBalloon('Autofill blocked',
'The target window runs as administrator — Windows silently blocks ' + 'The target window runs as administrator — Windows silently blocks ' +
+13
View File
@@ -642,6 +642,19 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</div> </div>
<div class="setting-row">
<span>
Tray alert when autofill is blocked
<small class="setting-hint">
Shows a Windows balloon if the fill fails while the
app is hidden (e.g. the target runs as administrator).
</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingAutofillFailBalloon">
<span class="toggle-slider"></span>
</label>
</div>
<div id="settingAutofillHotkeysRow" style="margin-top:8px"> <div id="settingAutofillHotkeysRow" style="margin-top:8px">
<div class="setting-row"> <div class="setting-row">
<span style="font-size:12px;color:var(--text-dim)"> <span style="font-size:12px;color:var(--text-dim)">
+14 -1
View File
@@ -142,7 +142,8 @@ const Bridge = (() => {
'&password=' + encodeURIComponent(password) + '&password=' + encodeURIComponent(password) +
(hideAfter ? '&hide_after=1' : '') + (hideAfter ? '&hide_after=1' : '') +
(field === 'user' ? '&field=user' : '') + (field === 'user' ? '&field=user' : '') +
(state.autofillClearField ? '' : '&clear=0')); (state.autofillClearField ? '' : '&clear=0') +
(state.autofillFailBalloon ? '' : '&notify=0'));
}, },
// Ask Delphi to bring the main window to front (used when the // Ask Delphi to bring the main window to front (used when the
@@ -645,6 +646,7 @@ const state = {
recoveryConfigured: false, // refreshed by refreshRecoveryStatus on Settings open recoveryConfigured: false, // refreshed by refreshRecoveryStatus on Settings open
autofillEnabled: localStorage.getItem('autofillEnabled') !== '0', // default ON autofillEnabled: localStorage.getItem('autofillEnabled') !== '0', // default ON
autofillClearField: localStorage.getItem('autofillClearField') !== '0', // default ON autofillClearField: localStorage.getItem('autofillClearField') !== '0', // default ON
autofillFailBalloon: localStorage.getItem('autofillFailBalloon') !== '0', // default ON
// Hotkey combos. Each combo = { ctrl, shift, alt, win, key }. // Hotkey combos. Each combo = { ctrl, shift, alt, win, key }.
// key is the uppercase character or VK label ('A'..'Z', '0'..'9', // key is the uppercase character or VK label ('A'..'Z', '0'..'9',
// 'F1'..'F12'). Default: Ctrl+Shift+L / Ctrl+Shift+P. Combos are // 'F1'..'F12'). Default: Ctrl+Shift+L / Ctrl+Shift+P. Combos are
@@ -6344,6 +6346,7 @@ function openSettings() {
$('#settingFaviconActionsRow').style.display = Bridge.active ? 'flex' : 'none'; $('#settingFaviconActionsRow').style.display = Bridge.active ? 'flex' : 'none';
$('#settingAutofill').checked = state.autofillEnabled; $('#settingAutofill').checked = state.autofillEnabled;
$('#settingAutofillClear').checked = state.autofillClearField; $('#settingAutofillClear').checked = state.autofillClearField;
$('#settingAutofillFailBalloon').checked = state.autofillFailBalloon;
$('#settingAutofillRow').style.display = Bridge.active ? '' : 'none'; $('#settingAutofillRow').style.display = Bridge.active ? '' : 'none';
// Hotkey capture buttons — labels reflect current combos. // Hotkey capture buttons — labels reflect current combos.
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull); $('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
@@ -6756,6 +6759,8 @@ const SYNCED_SETTING_KEYS = [
// Send Ctrl+A + Del before typing each autofill field (default ON). // Send Ctrl+A + Del before typing each autofill field (default ON).
// OFF for targets where Ctrl+A isn't select-all (terminals, RDP). // OFF for targets where Ctrl+A isn't select-all (terminals, RDP).
'autofillClearField', 'autofillClearField',
// Tray balloon when a fill fails while the window is hidden (default ON).
'autofillFailBalloon',
]; ];
// Sets `data-editor-position` on <body> so CSS can swap the slideover // Sets `data-editor-position` on <body> so CSS can swap the slideover
@@ -6839,6 +6844,9 @@ async function loadServerSettings() {
case 'autofillClearField': case 'autofillClearField':
localStorage.setItem('autofillClearField', v ? '1' : '0'); localStorage.setItem('autofillClearField', v ? '1' : '0');
break; break;
case 'autofillFailBalloon':
localStorage.setItem('autofillFailBalloon', v ? '1' : '0');
break;
} }
}); });
// Apply visual settings immediately. // Apply visual settings immediately.
@@ -7674,6 +7682,11 @@ async function init() {
localStorage.setItem('autofillClearField', e.target.checked ? '1' : '0'); localStorage.setItem('autofillClearField', e.target.checked ? '1' : '0');
saveServerSettings(); saveServerSettings();
}); });
$('#settingAutofillFailBalloon').addEventListener('change', e => {
state.autofillFailBalloon = e.target.checked;
localStorage.setItem('autofillFailBalloon', e.target.checked ? '1' : '0');
saveServerSettings();
});
// ---- Hotkey capture buttons ---- // ---- Hotkey capture buttons ----
// Click → button label becomes "Press combo…" → next keydown captures. // Click → button label becomes "Press combo…" → next keydown captures.