Files
Password-Manager/delphi-backend/Source/PM.Bridge.pas
T
r-zakarya aab0b14be4 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>
2026-07-12 05:08:50 +01:00

1311 lines
48 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
unit PM.Bridge;
{
PM.Bridge — JS↔Delphi native capability bridge.
Three features exposed to the embedded WebView2 via cmd:// URLs:
1. TSecureClipboard
Sets text on the Windows clipboard alongside the
ExcludeClipboardContentFromMonitorProcessing format, which prevents
Win+V clipboard history from recording the password. Auto-clears
after a configurable delay via TTimer.
2. Tray icon (TPMBridge.MinimizeToTray / RestoreFromTray)
Shell_NotifyIcon-based. The main window hides; a tray icon appears.
Single-click or double-click on the tray icon restores the window.
OnTrayRestore is called on the main thread so the caller can Show/BringToFront.
3. Windows session-lock detection
WTSRegisterSessionNotification on a dedicated message-only window.
On WTS_SESSION_LOCK the bridge fires OnSystemLock (main thread) so
the Delphi host can inject lockVault() into the WebView2.
Both tray icon messages and WTS notifications are routed through a
single message-only window created with AllocateHWnd, avoiding any
subclassing of the FMX main window.
}
interface
uses
System.SysUtils, System.Classes, System.Math,
FMX.Types, FMX.Forms,
Winapi.Windows, Winapi.ShellAPI, Winapi.Messages,
PM.SingleInstance;
type
// -------------------------------------------------------------------------
// TSecureClipboard
// -------------------------------------------------------------------------
TSecureClipboard = class
private
FClearTimer: TTimer;
procedure ClearTimerTick(Sender: TObject);
public
constructor Create;
destructor Destroy; override;
function ReadText: string;
// Copy AText to the clipboard, excluding it from Win+V history.
// AClearAfterMs = 0 disables auto-clear; default is 30 seconds.
procedure SetText(const AText: string; AClearAfterMs: Integer = 30000);
procedure Clear;
end;
// Distinguishes the "fill everything" hotkey (Ctrl+Shift+L) from the
// "password only" hotkey (Ctrl+Shift+P). The host decides what to type
// based on this kind.
TAutofillKind = (akFull, akPasswordOnly);
// Fired when an autofill hotkey is pressed. Args are the foreground
// window HWND and its title (captured before any focus change), plus
// the kind of fill requested.
// Declared as a method pointer (not TProc<>) because Delphi has no implicit
// conversion from "procedure of object" to "reference to procedure" — the
// host wires this with a regular form method (BridgeAutofillRequest).
TAutofillRequestEvent = procedure(AKind: TAutofillKind;
ATargetHWND: HWND; const ATitle: string) of object;
TNewEntryHotkeyEvent = procedure(const AWindowTitle: string) of object;
// -------------------------------------------------------------------------
// TPMBridge
// -------------------------------------------------------------------------
TPMBridge = class
private
FMainForm: TForm;
FMsgWindow: HWND;
FTrayAdded: Boolean;
FIconOwned: Boolean; // true = we must call DestroyIcon on FIconHandle
FIconHandle: HICON;
FNid: TNotifyIconData;
FPowerNotify: THandle; // registration handle from PowerRegisterSuspendResumeNotification
FSecureClipboard: TSecureClipboard;
FBalloonShown: Boolean;
// User setting: gates Shell_NotifyIcon NIF_INFO balloons (currently
// only the "running in the tray" first-time popup, future tray
// notifications would honour the same flag).
FShowNotifications: Boolean;
// Window placement captured at MinimizeToTray time. Replayed on
// RestoreFromTray so the window comes back in the same state
// (maximised / normal + position + size) as before hiding.
FSavedPlacement: TWindowPlacement;
FHasSavedPlacement: Boolean;
FOnSystemLock: TProc;
// Set TRUE the moment Windows tells us the session is ending
// (WM_QUERYENDSESSION / WM_ENDSESSION). FormCloseQuery checks this
// to bypass the "minimize to tray" intercept so the form closes
// normally and the DB connection is checkpointed instead of being
// force-killed (which leaves -shm / -wal files behind).
FShutdownPending: Boolean;
FOnTrayRestore: TProc;
FOnLockRequest: TProc;
FOnQuit: TProc;
FOnQuickSearchRequest: TProc;
// Autofill: global hotkeys → inject credentials into browser. Combos
// are user-configurable from Settings; defaults are Ctrl+Shift+L /
// Ctrl+Shift+P. We track which IDs are actually live so unregister
// doesn't blindly call UnregisterHotKey on unregistered IDs (which
// would set GetLastError noise during shutdown).
FAutofillRegistered: Boolean;
FAutofillFullActive: Boolean;
FAutofillPwdActive: Boolean;
FOnAutofillRequest: TAutofillRequestEvent;
FDebugHotkeyRegistered: Boolean;
FOnDebugHotkey: TProc;
FNewEntryHotkeyRegistered: Boolean;
FOnNewEntryHotkey: TNewEntryHotkeyEvent;
// Ctrl+Shift+Q — "Quick search + autofill": open the JS quick-search
// modal, the user picks an entry, the password is SendInput'd into the
// window that had focus at hotkey time (captured into FAutofillTargetHWND
// by the host).
FQuickSearchHotkeyRegistered: Boolean;
FOnQuickSearchHotkey: TAutofillRequestEvent;
procedure MsgWindowHandler(var AMsg: TMessage);
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);
destructor Destroy; override;
// Hide main window and show tray icon. AClearClipboard defaults to
// True (a manual minimise wipes any copied password immediately);
// the quick-search "copy then hide so I can paste" flow passes False
// so it doesn't nuke the password it just placed on the clipboard.
procedure MinimizeToTray(AClearClipboard: Boolean = True);
// Restore main window and remove tray icon.
procedure RestoreFromTray;
// Apply Windows dark-mode title bar to the main form. Win10 19044+
// / Win11 only — no-op on older builds. Safe to call repeatedly.
procedure ApplyTitleBarTheme(ADark: Boolean);
// Register the two autofill global hotkeys (full + password-only) with
// the given Win32 modifier flags (MOD_CONTROL/MOD_SHIFT/MOD_ALT/MOD_WIN
// bitmask) and virtual-key codes. Replaces any prior registration —
// safe to call repeatedly to swap combos at runtime.
// Returns True if both hotkeys registered successfully. If one or both
// failed (clash with another app), best-effort: whichever succeeded
// stays active.
function SetAutofillHotkeys(AFullMods, AFullVk,
APwdMods, APwdVk: Word): Boolean;
// Re-register the Ctrl+Shift+Q (default) quick-search hotkey with a
// user-chosen combo. True on success.
function SetQuickSearchHotkey(AMods, AVk: Word): Boolean;
// Convenience wrapper: register the historical defaults (Ctrl+Shift+L
// and Ctrl+Shift+P). Used by the host on first start; runtime changes
// go through SetAutofillHotkeys.
procedure RegisterAutofillHotkey;
// Unregister both autofill hotkeys.
procedure UnregisterAutofillHotkey;
// Simulate username + Tab + password keystrokes into ATargetHWND.
// ATargetHWND = 0 → type into whatever window has focus.
// If AUsername is empty, only the password is typed (no Tab) — matches
// the password-only hotkey path AND avoids spurious Tab on entries
// without a stored username.
// AUsernameOnly = True → type ONLY the username (no Tab, no password);
// used by the quick-search "autofill username" action.
// ARestoreAfter = True → the window was open before the hotkey: keep it
// 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.
// Returns False when nothing was typed: elevated target (UIPI would
// silently drop the keystrokes) or focus never left our own window.
// AClearFirst: send Ctrl+A + Del before each field (user setting).
function ExecuteAutofill(ATargetHWND: HWND;
const AUsername, APassword: string; AUsernameOnly: Boolean = False;
ARestoreAfter: Boolean = False; AClearFirst: Boolean = True): Boolean;
property SecureClipboard: TSecureClipboard read FSecureClipboard;
property TrayAdded: Boolean read FTrayAdded;
property AutofillRegistered: Boolean read FAutofillRegistered;
// Fired on main thread when Windows locks the session (WTS_SESSION_LOCK).
property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock;
// True once WM_QUERYENDSESSION (or WM_ENDSESSION) has been received.
// FormCloseQuery uses this to allow normal close during shutdown.
property ShutdownPending: Boolean read FShutdownPending;
// Fired on main thread when the user clicks the tray icon.
property OnTrayRestore: TProc read FOnTrayRestore write FOnTrayRestore;
// Fired when the user picks "Lock vault" from the tray menu. Handler
// should trigger the JS lockVault() (typically via ExecuteJavaScript).
property OnLockRequest: TProc read FOnLockRequest write FOnLockRequest;
// Fired when the user picks "Quit" from the tray menu. Handler must
// actually terminate the app (Application.Terminate or similar) — the
// bridge does not call it itself, so the host stays in control of
// shutdown order (server stop, save state, etc.).
property OnQuit: TProc read FOnQuit write FOnQuit;
// Fired when the user picks "Quick search…" from the tray menu.
// Handler typically restores the window and pops a JS-side modal
// (Bridge.openQuickSearch) so the user can type to find an entry
// and copy its password without restoring the whole vault UI.
property OnQuickSearchRequest: TProc
read FOnQuickSearchRequest write FOnQuickSearchRequest;
// True (default) = show tray balloon notifications. Set False to keep
// the tray icon mute. Configured from the JS settings panel.
property ShowNotifications: Boolean
read FShowNotifications write FShowNotifications;
// Already-shown flag for the one-time "still running in the tray"
// balloon. Exposed so the host can pre-mark it true on autostart
// launches (the user didn't actively minimise — no need to inform them).
property BalloonShown: Boolean
read FBalloonShown write FBalloonShown;
// Fired on main thread when the autofill hotkey fires.
// Args: (ATargetHWND, AWindowTitle). Handler calls ExecuteJavaScript
// to let JS match the title against vault entries.
property OnAutofillRequest: TAutofillRequestEvent
read FOnAutofillRequest write FOnAutofillRequest;
property OnDebugHotkey: TProc
read FOnDebugHotkey write FOnDebugHotkey;
// Fires on Ctrl+Shift+A. Arg = foreground window title (stripped of
// browser suffix by the JS layer before pre-fill).
property OnNewEntryHotkey: TNewEntryHotkeyEvent
read FOnNewEntryHotkey write FOnNewEntryHotkey;
// Fires on Ctrl+Shift+Q — quick-search-and-fill. Args mirror the
// regular autofill hotkey so the host can save the target HWND and
// pop the JS modal. Kind is always akPasswordOnly (no Tab).
property OnQuickSearchHotkey: TAutofillRequestEvent
read FOnQuickSearchHotkey write FOnQuickSearchHotkey;
end;
implementation
uses
FMX.Platform.Win;
// Win32 format name that suppresses Win+V clipboard history recording.
// Introduced in Windows 10 1809 (build 17763). Silently ignored on older builds.
const
CLIPBOARD_EXCLUDE_FORMAT = 'ExcludeClipboardContentFromMonitorProcessing';
// Tray callback message routed to our message-only window.
const
WM_TRAY_ICON = WM_APP + 1;
// WTS session change message and state constants (declared here to avoid
// a hard dependency on Winapi.WtsApi32 which varies across Delphi versions).
const
WM_WTSSESSION_CHANGE = $02B1;
WTS_SESSION_LOCK = 7;
NOTIFY_FOR_THIS_SESSION = 0;
// Power management broadcast — sent to all top-level windows when the
// system is about to sleep / hibernate or has just resumed. No explicit
// registration needed (unlike WTS).
// PBT_APMSUSPEND ($04) : "system is suspending operation" — fires once,
// right before sleep/hibernate. This is our lock trigger.
// PBT_APMRESUMEAUTOMATIC ($12) : system resumed (we don't need to act).
// PBT_APMRESUMESUSPEND ($07) : system resumed with user interaction.
const
WM_POWERBROADCAST = $0218;
PBT_APMSUSPEND = $0004;
PBT_APMRESUMEAUTOMATIC = $0012;
PBT_APMRESUMESUSPEND = $0007;
// Autofill hotkeys — Ctrl+Shift+L (full) and Ctrl+Shift+P (password only).
// IDs must not clash with other RegisterHotKey calls in this process;
// 42-43 are arbitrary and well outside the range used by FMX internals.
const
AUTOFILL_HOTKEY_ID_FULL = 42; // Ctrl+Shift+L → user + Tab + password
AUTOFILL_HOTKEY_ID_PWDONLY = 43; // Ctrl+Shift+P → password only
DEBUG_HOTKEY_ID = 44; // Ctrl+Shift+D → toggle debug panel
NEW_ENTRY_HOTKEY_ID = 45; // Ctrl+Shift+A → quick-add from window title
QUICK_SEARCH_HOTKEY_ID = 46; // Ctrl+Shift+Q → quick-search-and-fill
AF_MOD_CONTROL = $0002; // same value as MOD_CONTROL
AF_MOD_SHIFT = $0004; // same value as MOD_SHIFT
// Dynamic WTS function pointers — wtsapi32.dll is not guaranteed on all
// Windows SKUs (e.g. minimal Server Core without Session Services), so
// we load it at runtime and tolerate absence gracefully.
var
_WTSRegister : function(hWnd: HWND; dwFlags: DWORD): BOOL; stdcall = nil;
_WTSUnregister: function(hWnd: HWND): BOOL; stdcall = nil;
_WtsApiLoaded : Boolean = False;
_WtsLib : HMODULE = 0;
procedure LoadWtsApi;
begin
if _WtsApiLoaded then Exit;
_WtsApiLoaded := True;
_WtsLib := LoadLibrary('wtsapi32.dll');
if _WtsLib = 0 then Exit;
_WTSRegister := GetProcAddress(_WtsLib, 'WTSRegisterSessionNotification');
_WTSUnregister := GetProcAddress(_WtsLib, 'WTSUnRegisterSessionNotification');
end;
// Power notification registration (Windows 8+). Forces delivery of
// WM_POWERBROADCAST to a specific HWND, including non-top-level / hidden
// utility windows that Windows might otherwise skip. Exported from user32.
const
DEVICE_NOTIFY_WINDOW_HANDLE = 0;
var
_PowerRegister : function(Flags: DWORD; Recipient: THandle;
out RegistrationHandle: THandle): DWORD; stdcall = nil;
_PowerUnregister: function(RegistrationHandle: THandle): DWORD; stdcall = nil;
_PowerApiLoaded : Boolean = False;
procedure LoadPowerApi;
var
LLib: HMODULE;
begin
if _PowerApiLoaded then Exit;
_PowerApiLoaded := True;
// Functions live in user32.dll despite the "Power" prefix.
LLib := GetModuleHandle('user32.dll');
if LLib = 0 then Exit;
_PowerRegister := GetProcAddress(LLib, 'PowerRegisterSuspendResumeNotification');
_PowerUnregister := GetProcAddress(LLib, 'PowerUnregisterSuspendResumeNotification');
end;
// =============================================================================
// TSecureClipboard
// =============================================================================
constructor TSecureClipboard.Create;
begin
inherited;
FClearTimer := TTimer.Create(nil);
FClearTimer.Enabled := False;
FClearTimer.OnTimer := ClearTimerTick;
end;
destructor TSecureClipboard.Destroy;
begin
FClearTimer.Free;
inherited;
end;
procedure TSecureClipboard.ClearTimerTick(Sender: TObject);
begin
FClearTimer.Enabled := False;
Clear;
end;
procedure TSecureClipboard.SetText(const AText: string; AClearAfterMs: Integer);
var
CFExclude: UINT;
LMem: THandle; // HGLOBAL — renamed to avoid Pascal's case-insensitive
// collision with the HGLOBAL type identifier.
LDest: Pointer;
LByteCount: NativeUInt;
begin
FClearTimer.Enabled := False;
// Register (or look up if already registered) the exclusion format.
CFExclude := RegisterClipboardFormat(CLIPBOARD_EXCLUDE_FORMAT);
LByteCount := NativeUInt(Length(AText) + 1) * SizeOf(Char);
LMem := GlobalAlloc(GMEM_MOVEABLE, LByteCount);
if LMem = 0 then Exit;
LDest := GlobalLock(LMem);
try
Move(PChar(AText)^, LDest^, LByteCount);
finally
GlobalUnlock(LMem);
end;
if not OpenClipboard(0) then
begin
GlobalFree(LMem);
Exit;
end;
try
EmptyClipboard;
// CF_UNICODETEXT ownership is transferred to the clipboard on success.
if SetClipboardData(CF_UNICODETEXT, LMem) = 0 then
GlobalFree(LMem);
// Exclusion marker: presence of this format is the signal to Windows;
// the data handle is nil and ignored by the subsystem.
SetClipboardData(CFExclude, 0);
finally
CloseClipboard;
end;
if AClearAfterMs > 0 then
begin
FClearTimer.Interval := AClearAfterMs;
FClearTimer.Enabled := True;
end;
end;
procedure TSecureClipboard.Clear;
begin
if OpenClipboard(0) then
try
EmptyClipboard;
finally
CloseClipboard;
end;
end;
function TSecureClipboard.ReadText: string;
var
H: THandle;
P: PChar;
begin
Result := '';
if not OpenClipboard(0) then Exit;
try
H := GetClipboardData(CF_UNICODETEXT);
if H = 0 then Exit;
P := PChar(GlobalLock(H));
if P <> nil then
try
Result := P;
finally
GlobalUnlock(H);
end;
finally
CloseClipboard;
end;
end;
// =============================================================================
// TPMBridge
// =============================================================================
constructor TPMBridge.Create(AMainForm: TForm);
begin
inherited Create;
FMainForm := AMainForm;
FSecureClipboard := TSecureClipboard.Create;
FTrayAdded := False;
FBalloonShown := False;
FShowNotifications := True; // default on; JS pushes user pref on load
// Dedicated message-only window for tray + WTS notifications.
FMsgWindow := AllocateHWnd(MsgWindowHandler);
PrepareNid;
// Add the tray icon eagerly so it's visible from app startup, regardless
// of whether the window is shown or hidden. Without this, the tray icon
// only appears the first time the user minimizes — meaning fresh-launch
// users can't lock/quit from the tray and discover the feature only by
// accident. NIM_DELETE is now only called at shutdown.
if Shell_NotifyIcon(NIM_ADD, @FNid) then
FTrayAdded := True;
// Session-lock detection (fails silently if wtsapi32.dll is absent).
LoadWtsApi;
if Assigned(_WTSRegister) then
_WTSRegister(FMsgWindow, NOTIFY_FOR_THIS_SESSION);
// Sleep/hibernate detection. Forces WM_POWERBROADCAST delivery to our
// message-only window even if Windows would otherwise skip it. On
// Windows < 8 this fails silently — only modern systems support this
// API, but they're also the ones that have aggressive sleep behavior.
FPowerNotify := 0;
LoadPowerApi;
if Assigned(_PowerRegister) then
_PowerRegister(DEVICE_NOTIFY_WINDOW_HANDLE, FMsgWindow, FPowerNotify);
FDebugHotkeyRegistered := RegisterHotKey(FMsgWindow, DEBUG_HOTKEY_ID,
AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('D'));
FNewEntryHotkeyRegistered := RegisterHotKey(FMsgWindow, NEW_ENTRY_HOTKEY_ID,
AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('A'));
FQuickSearchHotkeyRegistered := RegisterHotKey(FMsgWindow,
QUICK_SEARCH_HOTKEY_ID, AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('Q'));
end;
destructor TPMBridge.Destroy;
begin
if FDebugHotkeyRegistered then
UnregisterHotKey(FMsgWindow, DEBUG_HOTKEY_ID);
if FNewEntryHotkeyRegistered then
UnregisterHotKey(FMsgWindow, NEW_ENTRY_HOTKEY_ID);
if FQuickSearchHotkeyRegistered then
UnregisterHotKey(FMsgWindow, QUICK_SEARCH_HOTKEY_ID);
if (FPowerNotify <> 0) and Assigned(_PowerUnregister) then
_PowerUnregister(FPowerNotify);
if Assigned(_WTSUnregister) then
_WTSUnregister(FMsgWindow);
if FTrayAdded then
begin
Shell_NotifyIcon(NIM_DELETE, @FNid);
FTrayAdded := False;
end;
if FIconOwned and (FIconHandle <> 0) then
DestroyIcon(FIconHandle);
DeallocateHWnd(FMsgWindow);
FSecureClipboard.Free;
inherited;
end;
procedure TPMBridge.PrepareNid;
var
LargeIcon, SmallIcon: HICON;
begin
// Attempt to extract the small (16×16) icon from the exe.
// ExtractIconEx returns the number of icons extracted.
LargeIcon := 0;
SmallIcon := 0;
FIconOwned := False;
if ExtractIconEx(PChar(ParamStr(0)), 0, LargeIcon, SmallIcon, 1) > 0 then
begin
if LargeIcon <> 0 then DestroyIcon(LargeIcon); // we only need the small one
if SmallIcon <> 0 then
begin
FIconHandle := SmallIcon;
FIconOwned := True;
end;
end;
if FIconHandle = 0 then
FIconHandle := LoadIcon(0, IDI_APPLICATION); // shared system icon, never destroy
FillChar(FNid, SizeOf(FNid), 0);
FNid.cbSize := SizeOf(FNid);
FNid.Wnd := FMsgWindow;
FNid.uID := 1;
FNid.uFlags := NIF_ICON or NIF_MESSAGE or NIF_TIP;
FNid.uCallbackMessage := WM_TRAY_ICON;
FNid.hIcon := FIconHandle;
// szTip: array[0..127] of WideChar — copy tooltip text safely.
Move(PChar('Password Manager')^, FNid.szTip[0],
Min(Length('Password Manager'), High(FNid.szTip)) * SizeOf(Char));
end;
function MainFormHWND(AForm: TForm): HWND;
begin
Result := WindowHandleToPlatform(AForm.Handle).Wnd;
end;
function TPMBridge.FindFMXAppWindow: HWND;
var
LWnd: HWND;
LWndPid, LCurrentPid: DWORD;
begin
// FMX on Windows creates a hidden per-process window of class "TFMAppClass"
// that owns the application's taskbar entry — NOT the form's HWND.
// Hiding the form (via ShowWindow / Visible := False / WS_EX_TOOLWINDOW /
// ITaskbarList.DeleteTab) is therefore ineffective at removing the taskbar
// entry: those calls target the wrong window. The correct fix is to find
// the TFMAppClass window owned by our process and hide IT.
// Reference: https://stackoverflow.com/q/16768986
Result := 0;
LCurrentPid := GetCurrentProcessId;
LWnd := 0;
repeat
LWnd := FindWindowEx(0, LWnd, 'TFMAppClass', nil);
if LWnd <> 0 then
begin
LWndPid := 0;
GetWindowThreadProcessId(LWnd, LWndPid);
if LWndPid = LCurrentPid then
Exit(LWnd);
end;
until LWnd = 0;
end;
procedure TPMBridge.MinimizeToTray(AClearClipboard: Boolean = True);
var
LFormHwnd, LAppHwnd: HWND;
begin
// Tray icon is added at construction time and persists for the app's
// lifetime — no NIM_ADD here.
// Extra safety: clear the clipboard immediately when the user minimizes,
// rather than waiting for the 30s auto-clear timer to fire. A password
// the user just copied shouldn't sit in the clipboard while the app is
// out of sight. SKIPPED when AClearClipboard=False — the quick-search
// copy-then-hide flow deliberately keeps the password on the clipboard
// (the 30s auto-clear timer still guards it) so the user can paste it
// into their target app after we minimise.
if AClearClipboard then
FSecureClipboard.Clear;
LFormHwnd := MainFormHWND(FMainForm);
LAppHwnd := FindFMXAppWindow;
// 0. Snapshot the window placement BEFORE hiding so RestoreFromTray can
// replay the exact same state (maximised / normal + size + position).
// Without this, ShowWindow(SW_RESTORE) below always returns to the
// "normal" state — a window that was maximised before hiding comes
// back un-maximised.
FillChar(FSavedPlacement, SizeOf(FSavedPlacement), 0);
FSavedPlacement.length := SizeOf(FSavedPlacement);
FHasSavedPlacement := GetWindowPlacement(LFormHwnd, @FSavedPlacement);
// 1. Hide the visible form via both FMX state and Win32 ShowWindow.
// Keeps the form invisible to the user.
FMainForm.Hide;
ShowWindow(LFormHwnd, SW_HIDE);
// 2. Hide the FMX application proxy window (TFMAppClass). THIS is what
// removes the entry from the taskbar — the form's HWND was never the
// taskbar-visible one in FMX.
if LAppHwnd <> 0 then
ShowWindow(LAppHwnd, SW_HIDE);
// 3. First-time only: pop a balloon notification so the user knows the
// app is still running in the tray (and didn't crash). Skipped when
// the user opted out via Settings.
if (not FBalloonShown) and FShowNotifications then
begin
ShowFirstTimeBalloon;
FBalloonShown := True;
end;
end;
procedure TPMBridge.ShowBalloon(const ATitle, AText: string;
AWarning: Boolean = False);
var
LBalloon: TNotifyIconData;
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.
FillChar(LBalloon, SizeOf(LBalloon), 0);
LBalloon.cbSize := SizeOf(LBalloon);
LBalloon.Wnd := FMsgWindow;
LBalloon.uID := 1;
LBalloon.uFlags := NIF_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;
begin
// Tray icon stays in the tray — we only show the window again.
LFormHwnd := MainFormHWND(FMainForm);
LAppHwnd := FindFMXAppWindow;
// Already visible and not minimised (quick-search / focus hotkey while the
// window is open): just bring it to front. Re-applying FSavedPlacement —
// captured at the LAST MinimizeToTray — would teleport the window to a
// stale position the user has since moved away from.
if FMainForm.Visible and (not IsIconic(LFormHwnd)) then
begin
SetForegroundWindow(LFormHwnd);
Exit;
end;
// Reverse order: show the app proxy first so the taskbar entry comes back,
// then show and foreground the form.
if LAppHwnd <> 0 then
ShowWindow(LAppHwnd, SW_SHOW);
FMainForm.Show;
// Restore to the exact pre-tray state (maximised/normal + size + pos).
// Falls back to SW_SHOW (+ conditional SW_RESTORE) if we never captured
// a placement (e.g. focus-app / new-entry hotkey on an already-visible
// window). Unconditional SW_RESTORE would un-maximise a maximised
// window — surprising for the user who pressed Ctrl+Shift+A / +L /
// clicked the tray.
if FHasSavedPlacement then
begin
// showCmd governs whether the window comes back maximised or normal;
// 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
begin
if (FSavedPlacement.flags and WPF_RESTORETOMAXIMIZED) <> 0 then
FSavedPlacement.showCmd := SW_SHOWMAXIMIZED
else
FSavedPlacement.showCmd := SW_SHOWNORMAL;
end;
SetWindowPlacement(LFormHwnd, @FSavedPlacement);
end
else
begin
ShowWindow(LFormHwnd, SW_SHOW);
// Only un-iconify if actually minimised. Win32 SW_RESTORE on a
// maximised window reverts it to normal — not what we want here.
if IsIconic(LFormHwnd) then
ShowWindow(LFormHwnd, SW_RESTORE);
end;
SetForegroundWindow(LFormHwnd);
end;
procedure TPMBridge.ShowTrayMenu;
const
ID_OPEN = 1;
ID_LOCK = 2;
ID_QUIT = 3;
ID_QUICKSEARCH = 4;
var
LMenu: HMENU;
LPt: TPoint;
LCmd: Cardinal;
begin
LMenu := CreatePopupMenu;
if LMenu = 0 then Exit;
try
AppendMenu(LMenu, MF_STRING, ID_OPEN, 'Open');
AppendMenu(LMenu, MF_STRING, ID_QUICKSEARCH, 'Quick search…');
AppendMenu(LMenu, MF_STRING, ID_LOCK, 'Lock vault');
AppendMenu(LMenu, MF_SEPARATOR, 0, nil);
AppendMenu(LMenu, MF_STRING, ID_QUIT, 'Quit');
GetCursorPos(LPt);
// SetForegroundWindow + WM_NULL post is the canonical Win32 workaround
// that lets TrackPopupMenu auto-dismiss when the user clicks elsewhere.
// Without it, the menu can become "sticky" on a hidden window.
SetForegroundWindow(FMsgWindow);
// Delphi's TrackPopupMenu is declared as returning BOOL, but with
// TPM_RETURNCMD it actually returns the selected menu item ID (or 0).
// Cast through the declared return type to read the real value.
LCmd := Cardinal(TrackPopupMenu(LMenu,
TPM_RETURNCMD or TPM_RIGHTBUTTON or TPM_NONOTIFY,
LPt.X, LPt.Y, 0, FMsgWindow, nil));
PostMessage(FMsgWindow, WM_NULL, 0, 0);
case LCmd of
ID_OPEN: if Assigned(FOnTrayRestore) then FOnTrayRestore();
ID_QUICKSEARCH: if Assigned(FOnQuickSearchRequest) then FOnQuickSearchRequest();
ID_LOCK: if Assigned(FOnLockRequest) then FOnLockRequest();
ID_QUIT: if Assigned(FOnQuit) then FOnQuit();
end;
finally
DestroyMenu(LMenu);
end;
end;
procedure TPMBridge.MsgWindowHandler(var AMsg: TMessage);
var
LMouseEvent: Word;
begin
// AllocateHWnd creates the window on the thread that called it (here: the
// main thread, since TPMBridge.Create runs from FormCreate). Windows
// dispatches messages on the owning thread, so this handler is already
// on the main thread — no need to marshal via TThread.Queue/ForceQueue.
if AMsg.Msg = WM_TRAY_ICON then
begin
// For Shell_NotifyIcon callback messages, the mouse event is in the
// low word of LParam (regardless of NOTIFYICON_VERSION). Extracting
// it via LOWORD is more portable than comparing the full LPARAM.
LMouseEvent := Word(AMsg.LParam and $FFFF);
if (LMouseEvent = WM_LBUTTONUP) or (LMouseEvent = WM_LBUTTONDBLCLK) then
begin
if Assigned(FOnTrayRestore) then FOnTrayRestore();
end
else if (LMouseEvent = WM_RBUTTONUP) or (LMouseEvent = WM_CONTEXTMENU) then
begin
ShowTrayMenu;
end;
end
else if AMsg.Msg = WM_WTSSESSION_CHANGE then
begin
if AMsg.WParam = WTS_SESSION_LOCK then
if Assigned(FOnSystemLock) then FOnSystemLock();
end
else if AMsg.Msg = WM_POWERBROADCAST then
begin
// Sleep/hibernate fires PBT_APMSUSPEND. Treat it identically to a
// session lock: the user is leaving the machine unattended, so the
// vault must be locked. Without this, closing a laptop lid (which
// doesn't always trigger WTS_SESSION_LOCK if the system goes straight
// to sleep) would leave the decrypted state in memory until resume.
// PBT_APMSUSPEND is delivered SYNCHRONOUSLY before the system
// suspends — fast handler required (no UI prompts, no network).
if AMsg.WParam = PBT_APMSUSPEND then
if Assigned(FOnSystemLock) then FOnSystemLock();
end
else if (AMsg.Msg = WM_QUERYENDSESSION) or (AMsg.Msg = WM_ENDSESSION) then
begin
// Windows is logging off / shutting down / restarting. Flip the flag
// so FormCloseQuery lets the form actually close instead of
// minimizing to tray — otherwise Windows force-kills us after the
// shutdown timeout and SQLite's WAL/SHM never get checkpointed.
// Return TRUE (do not block shutdown). DefWindowProc returns TRUE
// by default for WM_QUERYENDSESSION, so we just don't assign Result.
FShutdownPending := True;
AMsg.Result := 1;
end
else if (AMsg.Msg <> 0) and (AMsg.Msg = WM_PMShowMessage) then
begin
// A second instance was launched and PostMessage'd HWND_BROADCAST.
// Bring our window back to the front instead of letting that second
// process spawn its own UI.
if Assigned(FOnTrayRestore) then FOnTrayRestore();
end
else if (AMsg.Msg = WM_HOTKEY) and (AMsg.WParam = DEBUG_HOTKEY_ID) then
begin
if Assigned(FOnDebugHotkey) then FOnDebugHotkey();
end
else if (AMsg.Msg = WM_HOTKEY) and (AMsg.WParam = NEW_ENTRY_HOTKEY_ID) then
begin
if Assigned(FOnNewEntryHotkey) then
begin
var LTarget := GetForegroundWindow;
var LTitle: string;
SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
SetLength(LTitle, LLen);
FOnNewEntryHotkey(LTitle);
end;
end
else if (AMsg.Msg = WM_HOTKEY) and (AMsg.WParam = QUICK_SEARCH_HOTKEY_ID) then
begin
// Quick-search + autofill: capture the foreground HWND BEFORE the JS
// modal steals focus, hand it to the host so it can stash it in
// FAutofillTargetHWND (consumed by cmd://autofill/execute later).
if Assigned(FOnQuickSearchHotkey) then
begin
var LTarget := GetForegroundWindow;
var LTitle: string;
SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
SetLength(LTitle, LLen);
FOnQuickSearchHotkey(akPasswordOnly, LTarget, LTitle);
end;
end
else if (AMsg.Msg = WM_HOTKEY) and Assigned(FOnAutofillRequest) and
((AMsg.WParam = AUTOFILL_HOTKEY_ID_FULL) or
(AMsg.WParam = AUTOFILL_HOTKEY_ID_PWDONLY)) then
begin
// Capture the foreground window BEFORE any focus change, then fire the
// callback so the host can match the title against vault entries.
var LKind: TAutofillKind;
if AMsg.WParam = AUTOFILL_HOTKEY_ID_PWDONLY then
LKind := akPasswordOnly
else
LKind := akFull;
var LTarget := GetForegroundWindow;
var LTitle: string;
SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
SetLength(LTitle, LLen);
FOnAutofillRequest(LKind, LTarget, LTitle);
end;
AMsg.Result := DefWindowProc(FMsgWindow, AMsg.Msg, AMsg.WParam, AMsg.LParam);
end;
// =============================================================================
// TPMBridge — Autofill hotkey + SendInput
// =============================================================================
function TPMBridge.SetAutofillHotkeys(AFullMods, AFullVk,
APwdMods, APwdVk: Word): Boolean;
begin
// Tear down whatever is currently registered before installing the new
// combos. RegisterHotKey would fail if the same ID is already taken.
if FAutofillFullActive then
begin
UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_FULL);
FAutofillFullActive := False;
end;
if FAutofillPwdActive then
begin
UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_PWDONLY);
FAutofillPwdActive := False;
end;
// Best-effort registration. A failure (typically MOD_x clash with another
// app's global hotkey) is silent: the other slot can still be live.
if (AFullVk <> 0) and (AFullMods <> 0) then
FAutofillFullActive := RegisterHotKey(FMsgWindow,
AUTOFILL_HOTKEY_ID_FULL, AFullMods, AFullVk);
if (APwdVk <> 0) and (APwdMods <> 0) then
FAutofillPwdActive := RegisterHotKey(FMsgWindow,
AUTOFILL_HOTKEY_ID_PWDONLY, APwdMods, APwdVk);
FAutofillRegistered := FAutofillFullActive or FAutofillPwdActive;
Result := FAutofillFullActive and FAutofillPwdActive;
end;
function TPMBridge.SetQuickSearchHotkey(AMods, AVk: Word): Boolean;
begin
if FQuickSearchHotkeyRegistered then
begin
UnregisterHotKey(FMsgWindow, QUICK_SEARCH_HOTKEY_ID);
FQuickSearchHotkeyRegistered := False;
end;
if (AVk <> 0) and (AMods <> 0) then
FQuickSearchHotkeyRegistered := RegisterHotKey(FMsgWindow,
QUICK_SEARCH_HOTKEY_ID, AMods, AVk);
Result := FQuickSearchHotkeyRegistered;
end;
procedure TPMBridge.ApplyTitleBarTheme(ADark: Boolean);
const
DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
// uxtheme.dll private API, stable since Win10 1809. File Explorer, Edge
// and Office use this to opt their UI (including popup menus, scrollbars,
// tooltips) into dark mode. Signature changed in 1903 to take an enum:
// 0=Default 1=AllowDark 2=ForceDark 3=ForceLight 4=Max
// We use ForceDark / ForceLight for unambiguous behaviour.
APPMODE_DEFAULT = 0;
APPMODE_FORCE_DARK = 2;
APPMODE_FORCE_LIGHT = 3;
type
TDwmSetWindowAttribute = function(hwnd: HWND; dwAttribute: DWORD;
pvAttribute: Pointer; cbAttribute: DWORD): HRESULT; stdcall;
TSetPreferredAppMode = function(AppMode: Integer): Integer; stdcall;
TFlushMenuThemes = procedure; stdcall;
var
DwmLib, UxLib: HMODULE;
DwmSetWindowAttribute: TDwmSetWindowAttribute;
SetPreferredAppMode: TSetPreferredAppMode;
FlushMenuThemes: TFlushMenuThemes;
DarkFlag: BOOL;
FormHwnd: HWND;
begin
if FMainForm = nil then Exit;
FormHwnd := MainFormHWND(FMainForm);
if FormHwnd = 0 then Exit;
// 1. Title bar (DWM immersive dark mode).
DwmLib := LoadLibrary('dwmapi.dll');
if DwmLib <> 0 then
try
@DwmSetWindowAttribute := GetProcAddress(DwmLib, 'DwmSetWindowAttribute');
if Assigned(DwmSetWindowAttribute) then
begin
DarkFlag := ADark;
DwmSetWindowAttribute(FormHwnd, DWMWA_USE_IMMERSIVE_DARK_MODE,
@DarkFlag, SizeOf(DarkFlag));
end;
finally
FreeLibrary(DwmLib);
end;
// 2. App-wide preferred mode (themes popup menus, scrollbars, tooltips).
// Loaded by ordinal because the functions are not exported by name.
UxLib := LoadLibrary('uxtheme.dll');
if UxLib <> 0 then
try
@SetPreferredAppMode := GetProcAddress(UxLib, MAKEINTRESOURCE(135));
@FlushMenuThemes := GetProcAddress(UxLib, MAKEINTRESOURCE(136));
if Assigned(SetPreferredAppMode) then
begin
if ADark then SetPreferredAppMode(APPMODE_FORCE_DARK)
else SetPreferredAppMode(APPMODE_FORCE_LIGHT);
if Assigned(FlushMenuThemes) then FlushMenuThemes;
end;
finally
FreeLibrary(UxLib);
end;
end;
procedure TPMBridge.RegisterAutofillHotkey;
begin
// Convenience default — Ctrl+Shift+L (full) + Ctrl+Shift+P (password).
// Idempotent: calling twice with the same combos is harmless.
SetAutofillHotkeys(AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('L'),
AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('P'));
end;
procedure TPMBridge.UnregisterAutofillHotkey;
begin
if FAutofillFullActive then
begin
UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_FULL);
FAutofillFullActive := False;
end;
if FAutofillPwdActive then
begin
UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_PWDONLY);
FAutofillPwdActive := False;
end;
FAutofillRegistered := False;
end;
// Block until the user releases Ctrl, Shift, Alt, and Win, or until ATimeoutMs
// elapses. Without this, an autofill triggered by Ctrl+Shift+L injects
// keystrokes WHILE Ctrl+Shift are physically held — turning our Tab into
// Ctrl+Tab (next tab in Chrome), our 's' into Ctrl+S, etc. 1000 ms is a
// generous bound; typical release happens within 50-150 ms.
procedure WaitForModifierRelease(ATimeoutMs: Cardinal);
var
LStart: Cardinal;
begin
LStart := GetTickCount;
while ((GetAsyncKeyState(VK_CONTROL) and $8000) <> 0)
or ((GetAsyncKeyState(VK_SHIFT) and $8000) <> 0)
or ((GetAsyncKeyState(VK_MENU) and $8000) <> 0) // Alt
or ((GetAsyncKeyState(VK_LWIN) and $8000) <> 0)
or ((GetAsyncKeyState(VK_RWIN) and $8000) <> 0) do
begin
Sleep(15);
if GetTickCount - LStart > ATimeoutMs then Break;
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);
var
LInputs: TArray<TInput>;
LCount, I: Integer;
begin
if AText = '' then Exit;
SetLength(LInputs, Length(AText) * 2);
LCount := 0;
for I := 1 to Length(AText) do
begin
FillChar(LInputs[LCount], SizeOf(TInput), 0);
FillChar(LInputs[LCount + 1], SizeOf(TInput), 0);
LInputs[LCount].Itype := INPUT_KEYBOARD;
LInputs[LCount].ki.wScan := Ord(AText[I]);
LInputs[LCount].ki.dwFlags := KEYEVENTF_UNICODE;
LInputs[LCount + 1] := LInputs[LCount];
LInputs[LCount + 1].ki.dwFlags := KEYEVENTF_UNICODE or KEYEVENTF_KEYUP;
Inc(LCount, 2);
end;
SendInput(LCount, @LInputs[0], SizeOf(TInput));
end;
// Send one virtual-key press (down+up).
procedure SendVKey(AVk: Word);
var
LInputs: array[0..1] of TInput;
begin
FillChar(LInputs, SizeOf(LInputs), 0);
LInputs[0].Itype := INPUT_KEYBOARD;
LInputs[0].ki.wVk := AVk;
LInputs[1] := LInputs[0];
LInputs[1].ki.dwFlags := KEYEVENTF_KEYUP;
SendInput(2, @LInputs[0], SizeOf(TInput));
end;
procedure SendSelectAllAndDelete;
var
LInputs: array[0..5] of TInput;
begin
FillChar(LInputs, SizeOf(LInputs), 0);
LInputs[0].Itype := INPUT_KEYBOARD;
LInputs[0].ki.wVk := VK_CONTROL;
LInputs[1].Itype := INPUT_KEYBOARD;
LInputs[1].ki.wVk := Ord('A');
LInputs[2].Itype := INPUT_KEYBOARD;
LInputs[2].ki.wVk := Ord('A');
LInputs[2].ki.dwFlags := KEYEVENTF_KEYUP;
LInputs[3].Itype := INPUT_KEYBOARD;
LInputs[3].ki.wVk := VK_CONTROL;
LInputs[3].ki.dwFlags := KEYEVENTF_KEYUP;
LInputs[4].Itype := INPUT_KEYBOARD;
LInputs[4].ki.wVk := VK_DELETE;
LInputs[5] := LInputs[4];
LInputs[5].ki.dwFlags := KEYEVENTF_KEYUP;
SendInput(6, @LInputs[0], SizeOf(TInput));
end;
// Always-attach foreground switch. The early SetForegroundWindow shortcut
// was unreliable after the picker click — Win10/11 still refused the focus
// hand-off even when our process was foreground. Always doing the attach
// dance is slightly slower but actually works.
function ForceForegroundWindow(ATargetHwnd: HWND): Boolean;
const
ForegroundPollIntervalMs = 20;
ForegroundPollTimeoutMs = 600;
var
CallerThread, TargetThread, TargetPid: DWORD;
ThreadsAttached: Boolean;
WaitStart: Cardinal;
begin
Result := False;
if (ATargetHwnd = 0) or not IsWindow(ATargetHwnd) then Exit;
TargetPid := 0;
TargetThread := GetWindowThreadProcessId(ATargetHwnd, TargetPid);
CallerThread := GetCurrentThreadId;
if TargetThread = 0 then Exit;
ThreadsAttached := (TargetThread <> CallerThread) and
AttachThreadInput(CallerThread, TargetThread, True);
try
if IsIconic(ATargetHwnd) then
ShowWindow(ATargetHwnd, SW_RESTORE);
BringWindowToTop(ATargetHwnd);
SetWindowPos(ATargetHwnd, HWND_TOP, 0, 0, 0, 0,
SWP_NOMOVE or SWP_NOSIZE or SWP_NOACTIVATE);
SetForegroundWindow(ATargetHwnd);
WaitStart := GetTickCount;
while GetForegroundWindow <> ATargetHwnd do
begin
if GetTickCount - WaitStart > ForegroundPollTimeoutMs then Break;
Sleep(ForegroundPollIntervalMs);
SetForegroundWindow(ATargetHwnd);
end;
Result := GetForegroundWindow = ATargetHwnd;
finally
if ThreadsAttached then
AttachThreadInput(CallerThread, TargetThread, False);
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;
const
// Missing from Winapi.Windows — Win32 constant (WinNT.h, Vista+).
PROCESS_QUERY_LIMITED_INFORMATION = $1000;
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;
var
WindowRect: TRect;
CenterAbsX, CenterAbsY, ScreenW, ScreenH: Integer;
SavedCursor: TPoint;
MouseInputs: array[0..2] of TInput;
begin
if (ATargetHwnd = 0) or not IsWindow(ATargetHwnd) then Exit;
if not GetWindowRect(ATargetHwnd, WindowRect) then Exit;
CenterAbsX := (WindowRect.Left + WindowRect.Right) div 2;
CenterAbsY := (WindowRect.Top + WindowRect.Bottom) div 2;
ScreenW := GetSystemMetrics(SM_CXSCREEN);
ScreenH := GetSystemMetrics(SM_CYSCREEN);
if (ScreenW <= 0) or (ScreenH <= 0) then Exit;
GetCursorPos(SavedCursor);
FillChar(MouseInputs, SizeOf(MouseInputs), 0);
MouseInputs[0].Itype := INPUT_MOUSE;
MouseInputs[0].mi.dx := (CenterAbsX * 65535) div ScreenW;
MouseInputs[0].mi.dy := (CenterAbsY * 65535) div ScreenH;
MouseInputs[0].mi.dwFlags:= MOUSEEVENTF_ABSOLUTE or MOUSEEVENTF_MOVE or MOUSEEVENTF_LEFTDOWN;
MouseInputs[1] := MouseInputs[0];
MouseInputs[1].mi.dwFlags:= MOUSEEVENTF_LEFTUP;
MouseInputs[2].Itype := INPUT_MOUSE;
MouseInputs[2].mi.dx := (SavedCursor.X * 65535) div ScreenW;
MouseInputs[2].mi.dy := (SavedCursor.Y * 65535) div ScreenH;
MouseInputs[2].mi.dwFlags:= MOUSEEVENTF_ABSOLUTE or MOUSEEVENTF_MOVE;
SendInput(3, @MouseInputs[0], SizeOf(TInput));
Sleep(PostClickSettleMs);
end;
function TPMBridge.ExecuteAutofill(ATargetHWND: HWND;
const AUsername, APassword: string; AUsernameOnly: Boolean = False;
ARestoreAfter: Boolean = False; AClearFirst: Boolean = True): Boolean;
const
MinimizeSettleMs = 80;
FocusSettleDelayMs = 120;
// Ctrl+A + Del to empty the target field — skipped when the user turned
// "Clear the field before typing" off (Ctrl+A isn't select-all everywhere:
// terminals, some remote desktops).
procedure ClearFieldIfWanted;
begin
if not AClearFirst then Exit;
SendSelectAllAndDelete;
Sleep(60);
end;
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,
// beside the target. (The old minimize-then-restore flickered and sometimes
// lost the restore race.) Without ARestoreAfter (tray-origin flow), the
// restored window is a temporary overlay: shove it out of the way as before
// — the caller trays it after the fill anyway.
if (GetForegroundWindow = OwnFormHwnd) and (not ARestoreAfter) then
begin
ShowWindow(OwnFormHwnd, SW_MINIMIZE);
Sleep(MinimizeSettleMs);
end;
if ATargetHWND <> 0 then
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,
// 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
begin
ClearFieldIfWanted;
SendUnicodeString(AUsername);
Exit;
end;
if AUsername = '' then
begin
ClearFieldIfWanted;
SendUnicodeString(APassword);
Exit;
end;
ClearFieldIfWanted;
SendUnicodeString(AUsername);
Sleep(200);
SendVKey(VK_TAB);
Sleep(200);
ClearFieldIfWanted;
SendUnicodeString(APassword);
end;
end.