diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index 288d0e1..7b3f13e 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -169,9 +169,11 @@ type // visible (no minimize at all — the foreground process is allowed to hand // focus to the target). False (tray-origin) → minimize out of the way, // 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; - ARestoreAfter: Boolean = False); + ARestoreAfter: Boolean = False): Boolean; property SecureClipboard: TSecureClipboard read FSecureClipboard; property TrayAdded: Boolean read FTrayAdded; property AutofillRegistered: Boolean read FAutofillRegistered; @@ -669,9 +671,18 @@ begin if FHasSavedPlacement then begin // 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 - 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); end else @@ -1102,6 +1113,39 @@ begin 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); const PostClickSettleMs = 40; @@ -1138,16 +1182,23 @@ begin Sleep(PostClickSettleMs); end; -procedure TPMBridge.ExecuteAutofill(ATargetHWND: HWND; +function TPMBridge.ExecuteAutofill(ATargetHWND: HWND; const AUsername, APassword: string; AUsernameOnly: Boolean = False; - ARestoreAfter: Boolean = False); + ARestoreAfter: Boolean = False): Boolean; const MinimizeSettleMs = 80; FocusSettleDelayMs = 120; var OwnFormHwnd: HWND; begin + Result := False; 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. // Being the foreground process is precisely what lets us hand the focus to // the target via ForceForegroundWindow — the window just stays where it is, @@ -1167,11 +1218,14 @@ begin WaitForModifierRelease(1000); Sleep(FocusSettleDelayMs); - // Never type into our own window: if the target refused the foreground - // (elevated process / UIPI), the keystrokes would land in the vault UI - // itself — a password typed into a visible search box. Bail instead. + // Never type into our own window: if the target refused the foreground, + // the keystrokes would land in the vault UI itself — a password typed + // into a visible search box. Bail instead. 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, // no password. Used by the quick-search right-click / Shift+Enter path. if AUsernameOnly then diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index a9cee99..d50f548 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -1467,14 +1467,19 @@ begin TTimer(Sender).Free; // 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. - FBridge.ExecuteAutofill(TargetHwnd, PendingUser, PendingPass, UserOnly, - not HideAfter); + var LFillOk := FBridge.ExecuteAutofill(TargetHwnd, PendingUser, PendingPass, + UserOnly, not HideAfter); 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), - 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 // already has focus — now we can safely tray ourselves without diff --git a/js/app.autofill.js b/js/app.autofill.js index 7d62844..0944a2a 100644 --- a/js/app.autofill.js +++ b/js/app.autofill.js @@ -167,6 +167,23 @@ function autofillScore(entry, titleLower) { 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. // kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P. async function autofillHandleRequest(windowTitle, kind) { diff --git a/js/app.js b/js/app.js index d3c6366..d3d4b4b 100644 --- a/js/app.js +++ b/js/app.js @@ -113,6 +113,12 @@ const Bridge = (() => { 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 // the foreground window title pre-filled (browser suffix stripped). onNewEntryFromTitle(windowTitle) { diff --git a/js/app.overlays.js b/js/app.overlays.js index f95bfe7..32c313d 100644 --- a/js/app.overlays.js +++ b/js/app.overlays.js @@ -115,8 +115,10 @@ async function quickSearchPickEntry(entry, mode) { if (mode === 'user') { const u = entry.username || ''; 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'); - toast(entryDisplayName(entry) + ' · username sent'); } else { const pwd = await decryptPwd(entry.encrypted_password, entry.iv); if (pwd === '[ERROR]') { @@ -131,9 +133,9 @@ async function quickSearchPickEntry(entry, mode) { // 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. + autofillPendingToast = entryDisplayName(entry) + + (u ? ' · username + password sent' : ' · password sent'); if (Bridge.active) Bridge.executeAutofill(u, pwd, quickSearchHideAfter); - toast(entryDisplayName(entry) + - (u ? ' · username + password sent' : ' · password sent')); } // Flags consumed — closeQuickSearchModal must not re-trigger. quickSearchFillMode = false;