fix(autofill): honest result reporting + restore maximized from tray

Bug 1: a maximized window trayed via the quick-search fill flow came back
"normal" on the next restore. ExecuteAutofill minimizes the window BEFORE
MinimizeToTray snapshots the placement, so the snapshot said SHOWMINIMIZED
and the never-restore-minimized guard forced SHOWNORMAL. Now honours
WPF_RESTORETOMAXIMIZED (Windows keeps the pre-minimize state in flags).

Bug 2: filling into an elevated app (admin Notepad) showed "password sent"
while UIPI silently discarded the keystrokes (SendInput even reports
success). ExecuteAutofill is now a function: it checks the target process
elevation up front (can't-open counts as elevated) and returns False without
typing. UMainForm feeds the result to JS via Bridge.onAutofillResult; the
quick-search success toast is deferred until Delphi confirms, and a failure
shows "Autofill blocked - the target window runs as administrator".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-11 20:19:34 +01:00
parent 7103fbf703
commit 4a47caad55
5 changed files with 101 additions and 17 deletions
+63 -9
View File
@@ -169,9 +169,11 @@ type
// visible (no minimize at all — the foreground process is allowed to hand // visible (no minimize at all — the foreground process is allowed to hand
// focus to the target). False (tray-origin) → minimize out of the way, // focus to the target). False (tray-origin) → minimize out of the way,
// the caller trays it after the fill. // the caller trays it after the fill.
procedure ExecuteAutofill(ATargetHWND: HWND; // Returns False when nothing was typed: elevated target (UIPI would
// silently drop the keystrokes) or focus never left our own window.
function ExecuteAutofill(ATargetHWND: HWND;
const AUsername, APassword: string; AUsernameOnly: Boolean = False; const AUsername, APassword: string; AUsernameOnly: Boolean = False;
ARestoreAfter: Boolean = False); ARestoreAfter: Boolean = False): Boolean;
property SecureClipboard: TSecureClipboard read FSecureClipboard; property SecureClipboard: TSecureClipboard read FSecureClipboard;
property TrayAdded: Boolean read FTrayAdded; property TrayAdded: Boolean read FTrayAdded;
property AutofillRegistered: Boolean read FAutofillRegistered; property AutofillRegistered: Boolean read FAutofillRegistered;
@@ -669,9 +671,18 @@ begin
if FHasSavedPlacement then if FHasSavedPlacement then
begin begin
// showCmd governs whether the window comes back maximised or normal; // showCmd governs whether the window comes back maximised or normal;
// it's what SW_RESTORE clobbers. We force it ourselves. // it's what SW_RESTORE clobbers. We force it ourselves. Captured while
// MINIMISED (tray-origin fill: ExecuteAutofill minimises us before
// MinimizeToTray snapshots): never restore as minimised, but honour
// WPF_RESTORETOMAXIMIZED — a maximised window minimised then trayed
// must come back maximised, not "normal".
if FSavedPlacement.showCmd = SW_SHOWMINIMIZED then if FSavedPlacement.showCmd = SW_SHOWMINIMIZED then
FSavedPlacement.showCmd := SW_SHOWNORMAL; // never restore as minimised begin
if (FSavedPlacement.flags and WPF_RESTORETOMAXIMIZED) <> 0 then
FSavedPlacement.showCmd := SW_SHOWMAXIMIZED
else
FSavedPlacement.showCmd := SW_SHOWNORMAL;
end;
SetWindowPlacement(LFormHwnd, @FSavedPlacement); SetWindowPlacement(LFormHwnd, @FSavedPlacement);
end end
else else
@@ -1102,6 +1113,39 @@ begin
end; end;
end; end;
// True when the process owning AHwnd runs elevated (admin). UIPI silently
// DISCARDS SendInput from a non-elevated process into an elevated one —
// SendInput even reports success — so detecting elevation up front is the
// only way to tell the user the fill can't work instead of lying "sent".
// Can't-tell (OpenProcess denied, which protected/elevated processes do)
// counts as elevated: better an honest "blocked" than a silent no-op.
function IsWindowProcessElevated(AHwnd: HWND): Boolean;
var
LPid: DWORD;
LProc, LToken: THandle;
LElev: TOKEN_ELEVATION;
LLen: DWORD;
begin
Result := False;
LPid := 0;
GetWindowThreadProcessId(AHwnd, LPid);
if LPid = 0 then Exit;
LProc := OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, LPid);
if LProc = 0 then Exit(True);
try
if not OpenProcessToken(LProc, TOKEN_QUERY, LToken) then Exit(True);
try
LLen := 0;
if GetTokenInformation(LToken, TokenElevation, @LElev, SizeOf(LElev), LLen) then
Result := LElev.TokenIsElevated <> 0;
finally
CloseHandle(LToken);
end;
finally
CloseHandle(LProc);
end;
end;
procedure ClickTargetCenterToGrabFocus(ATargetHwnd: HWND); procedure ClickTargetCenterToGrabFocus(ATargetHwnd: HWND);
const const
PostClickSettleMs = 40; PostClickSettleMs = 40;
@@ -1138,16 +1182,23 @@ begin
Sleep(PostClickSettleMs); Sleep(PostClickSettleMs);
end; end;
procedure TPMBridge.ExecuteAutofill(ATargetHWND: HWND; function TPMBridge.ExecuteAutofill(ATargetHWND: HWND;
const AUsername, APassword: string; AUsernameOnly: Boolean = False; const AUsername, APassword: string; AUsernameOnly: Boolean = False;
ARestoreAfter: Boolean = False); ARestoreAfter: Boolean = False): Boolean;
const const
MinimizeSettleMs = 80; MinimizeSettleMs = 80;
FocusSettleDelayMs = 120; FocusSettleDelayMs = 120;
var var
OwnFormHwnd: HWND; OwnFormHwnd: HWND;
begin begin
Result := False;
OwnFormHwnd := MainFormHWND(FMainForm); OwnFormHwnd := MainFormHWND(FMainForm);
// UIPI: keystrokes into an elevated target are silently dropped by Windows
// (SendInput even claims success). Detect it up front and report failure so
// the UI can say "blocked" instead of a false "password sent".
if (ATargetHWND <> 0) and IsWindowProcessElevated(ATargetHWND) then Exit;
// ARestoreAfter (window was open before the hotkey): DON'T minimize at all. // ARestoreAfter (window was open before the hotkey): DON'T minimize at all.
// Being the foreground process is precisely what lets us hand the focus to // Being the foreground process is precisely what lets us hand the focus to
// the target via ForceForegroundWindow — the window just stays where it is, // the target via ForceForegroundWindow — the window just stays where it is,
@@ -1167,11 +1218,14 @@ begin
WaitForModifierRelease(1000); WaitForModifierRelease(1000);
Sleep(FocusSettleDelayMs); Sleep(FocusSettleDelayMs);
// Never type into our own window: if the target refused the foreground // Never type into our own window: if the target refused the foreground,
// (elevated process / UIPI), the keystrokes would land in the vault UI // the keystrokes would land in the vault UI itself — a password typed
// itself — a password typed into a visible search box. Bail instead. // into a visible search box. Bail instead.
if GetForegroundWindow = OwnFormHwnd then Exit; if GetForegroundWindow = OwnFormHwnd then Exit;
// Past every bail-out — the keystrokes below are the fill itself.
Result := True;
// Username-only: type just the username into the focused field, no Tab, // Username-only: type just the username into the focused field, no Tab,
// no password. Used by the quick-search right-click / Shift+Enter path. // no password. Used by the quick-search right-click / Shift+Enter path.
if AUsernameOnly then if AUsernameOnly then
+10 -5
View File
@@ -1467,14 +1467,19 @@ begin
TTimer(Sender).Free; TTimer(Sender).Free;
// ARestoreAfter = not HideAfter: if the app was open before the hotkey // ARestoreAfter = not HideAfter: if the app was open before the hotkey
// (quick-search beside the target), it comes back after the fill; if it // (quick-search beside the target), it stays visible during the fill; if it
// started hidden, the MinimizeToTray below re-hides it anyway. // started hidden, the MinimizeToTray below re-hides it anyway.
FBridge.ExecuteAutofill(TargetHwnd, PendingUser, PendingPass, UserOnly, var LFillOk := FBridge.ExecuteAutofill(TargetHwnd, PendingUser, PendingPass,
not HideAfter); UserOnly, not HideAfter);
ForegroundAfter := GetForegroundWindow; ForegroundAfter := GetForegroundWindow;
LogLine(Format('Autofill executed — target=%s, foreground_after=%s, match=%s', LogLine(Format('Autofill executed — target=%s, foreground_after=%s, ok=%s',
[IntToHex(TargetHwnd, 8), IntToHex(ForegroundAfter, 8), [IntToHex(TargetHwnd, 8), IntToHex(ForegroundAfter, 8),
BoolToStr(ForegroundAfter = TargetHwnd, True)])); BoolToStr(LFillOk, True)]));
// Tell JS whether the keystrokes were actually sent — the success toast
// must not lie when the target runs elevated (UIPI drops our input).
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.onAutofillResult==="function")' +
'Bridge.onAutofillResult(' + BoolToStr(LFillOk, True).ToLower + ')');
// Hide-after (Ctrl+Shift+Q from tray): SendInput is done, the target // Hide-after (Ctrl+Shift+Q from tray): SendInput is done, the target
// already has focus — now we can safely tray ourselves without // already has focus — now we can safely tray ourselves without
+17
View File
@@ -167,6 +167,23 @@ function autofillScore(entry, titleLower) {
return 0; return 0;
} }
// Success toast deferred until Delphi confirms the keystrokes were sent.
// Quick-search sets this label before calling executeAutofill; Delphi fires
// Bridge.onAutofillResult(ok) → we toast the label (ok) or an honest
// failure (elevated target — UIPI silently drops our SendInput).
let autofillPendingToast = '';
function autofillReportResult(ok) {
const label = autofillPendingToast;
autofillPendingToast = '';
if (ok) {
if (label) toast(label);
} else {
toast('Autofill blocked — the target window runs as administrator. ' +
'Copy the password instead.', 'error');
}
}
// Called by Bridge.onAutofillRequest when a hotkey fires. // Called by Bridge.onAutofillRequest when a hotkey fires.
// kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P. // kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P.
async function autofillHandleRequest(windowTitle, kind) { async function autofillHandleRequest(windowTitle, kind) {
+6
View File
@@ -113,6 +113,12 @@ const Bridge = (() => {
autofillHandleRequest(windowTitle, kind || 'full'); autofillHandleRequest(windowTitle, kind || 'full');
}, },
// Called by Delphi after cmd://autofill/execute with whether the
// keystrokes were actually sent (false = elevated target, UIPI).
onAutofillResult(ok) {
autofillReportResult(!!ok);
},
// Called by Delphi on Ctrl+Shift+A. Opens the new-entry modal with // Called by Delphi on Ctrl+Shift+A. Opens the new-entry modal with
// the foreground window title pre-filled (browser suffix stripped). // the foreground window title pre-filled (browser suffix stripped).
onNewEntryFromTitle(windowTitle) { onNewEntryFromTitle(windowTitle) {
+5 -3
View File
@@ -115,8 +115,10 @@ async function quickSearchPickEntry(entry, mode) {
if (mode === 'user') { if (mode === 'user') {
const u = entry.username || ''; const u = entry.username || '';
if (!u) { toast('No username on this entry', 'warning'); return; } if (!u) { toast('No username on this entry', 'warning'); return; }
// Toast deferred to Bridge.onAutofillResult — Delphi reports
// whether the keystrokes actually landed (elevated target = no).
autofillPendingToast = entryDisplayName(entry) + ' · username sent';
if (Bridge.active) Bridge.executeAutofill(u, '', quickSearchHideAfter, 'user'); if (Bridge.active) Bridge.executeAutofill(u, '', quickSearchHideAfter, 'user');
toast(entryDisplayName(entry) + ' · username sent');
} else { } else {
const pwd = await decryptPwd(entry.encrypted_password, entry.iv); const pwd = await decryptPwd(entry.encrypted_password, entry.iv);
if (pwd === '[ERROR]') { if (pwd === '[ERROR]') {
@@ -131,9 +133,9 @@ async function quickSearchPickEntry(entry, mode) {
// if hide_after=1, MinimizeToTray's AFTER the keystrokes land. // if hide_after=1, MinimizeToTray's AFTER the keystrokes land.
// Hiding before SendInput would tip the Win10/11 anti-focus- // Hiding before SendInput would tip the Win10/11 anti-focus-
// stealing rules into refusing to hand focus to the target. // stealing rules into refusing to hand focus to the target.
autofillPendingToast = entryDisplayName(entry) +
(u ? ' · username + password sent' : ' · password sent');
if (Bridge.active) Bridge.executeAutofill(u, pwd, quickSearchHideAfter); if (Bridge.active) Bridge.executeAutofill(u, pwd, quickSearchHideAfter);
toast(entryDisplayName(entry) +
(u ? ' · username + password sent' : ' · password sent'));
} }
// Flags consumed — closeQuickSearchModal must not re-trigger. // Flags consumed — closeQuickSearchModal must not re-trigger.
quickSearchFillMode = false; quickSearchFillMode = false;