fix(autofill): force-release stuck modifiers; balloon when blocked from tray
Root cause of "Ctrl+Shift+P opened the new-entry modal": if the user still holds Ctrl+Shift when WaitForModifierRelease times out (1s), every password letter is typed as a Ctrl+Shift+<letter> chord — garbage in the field AND it fires our own global hotkeys (a password containing 'a' triggers Ctrl+Shift+A = new entry). ForceReleaseModifiers now injects KEYUP for any still-held modifier before typing. Also: when the fill is blocked (elevated target) while the window is hidden in the tray, the in-app toast is invisible — show a tray balloon instead. ShowFirstTimeBalloon generalized into ShowBalloon(title, text, warning), gated by the existing "Show tray notifications" setting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -125,6 +125,10 @@ type
|
||||
procedure PrepareNid;
|
||||
procedure ShowTrayMenu;
|
||||
procedure ShowFirstTimeBalloon;
|
||||
// Tray balloon (gated by the "Show tray notifications" setting). Used
|
||||
// for messages the user must see while the window is hidden — e.g. an
|
||||
// autofill blocked by an elevated target.
|
||||
procedure ShowBalloon(const ATitle, AText: string; AWarning: Boolean = False);
|
||||
function FindFMXAppWindow: HWND;
|
||||
public
|
||||
constructor Create(AMainForm: TForm);
|
||||
@@ -614,30 +618,36 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TPMBridge.ShowFirstTimeBalloon;
|
||||
procedure TPMBridge.ShowBalloon(const ATitle, AText: string;
|
||||
AWarning: Boolean = False);
|
||||
var
|
||||
LBalloon: TNotifyIconData;
|
||||
const
|
||||
BALLOON_TITLE = 'Password Manager';
|
||||
BALLOON_TEXT = 'Still running in the tray — click the icon to restore, ' +
|
||||
'right-click for menu.';
|
||||
begin
|
||||
// Gated by the "Show tray notifications" user setting.
|
||||
if not FShowNotifications then Exit;
|
||||
// Build a separate TNotifyIconData with NIF_INFO set, NIM_MODIFY on the
|
||||
// same uID. szInfo/szInfoTitle carry the balloon content. NIIF_INFO
|
||||
// gives the system info icon — no scary warning glyph.
|
||||
// same uID. szInfo/szInfoTitle carry the balloon content.
|
||||
FillChar(LBalloon, SizeOf(LBalloon), 0);
|
||||
LBalloon.cbSize := SizeOf(LBalloon);
|
||||
LBalloon.Wnd := FMsgWindow;
|
||||
LBalloon.uID := 1;
|
||||
LBalloon.uFlags := NIF_INFO;
|
||||
Move(PChar(BALLOON_TITLE)^, LBalloon.szInfoTitle[0],
|
||||
Min(Length(BALLOON_TITLE), High(LBalloon.szInfoTitle)) * SizeOf(Char));
|
||||
Move(PChar(BALLOON_TEXT)^, LBalloon.szInfo[0],
|
||||
Min(Length(BALLOON_TEXT), High(LBalloon.szInfo)) * SizeOf(Char));
|
||||
LBalloon.dwInfoFlags := NIIF_INFO;
|
||||
Move(PChar(ATitle)^, LBalloon.szInfoTitle[0],
|
||||
Min(Length(ATitle), High(LBalloon.szInfoTitle)) * SizeOf(Char));
|
||||
Move(PChar(AText)^, LBalloon.szInfo[0],
|
||||
Min(Length(AText), High(LBalloon.szInfo)) * SizeOf(Char));
|
||||
if AWarning then LBalloon.dwInfoFlags := NIIF_WARNING
|
||||
else LBalloon.dwInfoFlags := NIIF_INFO;
|
||||
Shell_NotifyIcon(NIM_MODIFY, @LBalloon);
|
||||
end;
|
||||
|
||||
procedure TPMBridge.ShowFirstTimeBalloon;
|
||||
begin
|
||||
ShowBalloon('Password Manager',
|
||||
'Still running in the tray — click the icon to restore, ' +
|
||||
'right-click for menu.');
|
||||
end;
|
||||
|
||||
procedure TPMBridge.RestoreFromTray;
|
||||
var
|
||||
LFormHwnd, LAppHwnd: HWND;
|
||||
@@ -1010,6 +1020,36 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
// Inject KEYUP for any modifier still physically held after the wait timed
|
||||
// out. Typing with Ctrl+Shift down turns every password letter into a
|
||||
// Ctrl+Shift+<letter> chord — which not only types garbage but FIRES OUR OWN
|
||||
// global hotkeys (a password containing 'a' triggered Ctrl+Shift+A = the
|
||||
// new-entry modal mid-fill). The user's keys stay physically down but the OS
|
||||
// modifier state clears until they release and press again.
|
||||
procedure ForceReleaseModifiers;
|
||||
const
|
||||
MODS: array[0..4] of Word = (VK_CONTROL, VK_SHIFT, VK_MENU, VK_LWIN, VK_RWIN);
|
||||
var
|
||||
LInputs: array[0..4] of TInput;
|
||||
I, N: Integer;
|
||||
begin
|
||||
N := 0;
|
||||
for I := 0 to High(MODS) do
|
||||
if (GetAsyncKeyState(MODS[I]) and $8000) <> 0 then
|
||||
begin
|
||||
FillChar(LInputs[N], SizeOf(TInput), 0);
|
||||
LInputs[N].Itype := INPUT_KEYBOARD;
|
||||
LInputs[N].ki.wVk := MODS[I];
|
||||
LInputs[N].ki.dwFlags := KEYEVENTF_KEYUP;
|
||||
Inc(N);
|
||||
end;
|
||||
if N > 0 then
|
||||
begin
|
||||
SendInput(N, @LInputs[0], SizeOf(TInput));
|
||||
Sleep(30);
|
||||
end;
|
||||
end;
|
||||
|
||||
// Build (and immediately send) a key-down+up pair for each char in AText
|
||||
// using KEYEVENTF_UNICODE. Returns nothing — best-effort.
|
||||
procedure SendUnicodeString(const AText: string);
|
||||
@@ -1231,6 +1271,7 @@ begin
|
||||
ForceForegroundWindow(ATargetHWND);
|
||||
|
||||
WaitForModifierRelease(1000);
|
||||
ForceReleaseModifiers; // timeout hit with keys still down → clean state
|
||||
Sleep(FocusSettleDelayMs);
|
||||
|
||||
// Never type into our own window: if the target refused the foreground,
|
||||
|
||||
@@ -1486,6 +1486,14 @@ begin
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&typeof Bridge.onAutofillResult==="function")' +
|
||||
'Bridge.onAutofillResult(' + BoolToStr(LFillOk, True).ToLower + ')');
|
||||
// Window hidden (tray) → the in-app failure toast is invisible; surface
|
||||
// the block via a tray balloon instead (gated by the tray-notifications
|
||||
// setting inside ShowBalloon).
|
||||
if (not LFillOk) and
|
||||
((not Self.Visible) or IsIconic(WindowHandleToPlatform(Self.Handle).Wnd)) then
|
||||
FBridge.ShowBalloon('Autofill blocked',
|
||||
'The target window runs as administrator — Windows silently blocks ' +
|
||||
'simulated keystrokes. Copy the password instead.', True);
|
||||
|
||||
// Hide-after (Ctrl+Shift+Q from tray): SendInput is done, the target
|
||||
// already has focus — now we can safely tray ourselves without
|
||||
|
||||
Reference in New Issue
Block a user