dcb1ac63ce
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2312 lines
92 KiB
ObjectPascal
2312 lines
92 KiB
ObjectPascal
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, System.Types,
|
||
System.Diagnostics,
|
||
FMX.Types, FMX.Forms,
|
||
Winapi.Windows, Winapi.ShellAPI, Winapi.Messages, Winapi.MultiMon,
|
||
PM.SingleInstance, PM.UserPrefs;
|
||
|
||
type
|
||
// -------------------------------------------------------------------------
|
||
// TSecureClipboard
|
||
// -------------------------------------------------------------------------
|
||
TSecureClipboard = class
|
||
private
|
||
FClearTimer: TTimer;
|
||
// Clipboard sequence number right after OUR SetText. If it moved, the
|
||
// user copied something else since — that content is theirs, not ours.
|
||
FSetSequence: DWORD;
|
||
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;
|
||
// Clear only if the clipboard still holds what WE put there (sequence
|
||
// unchanged). Auto-clear timer + minimize-to-tray use this so they never
|
||
// wipe something the user copied from another app in the meantime.
|
||
procedure ClearIfOurs;
|
||
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.
|
||
// akTotp reuses the password-only typing path: JS sends the generated
|
||
// 6-digit code in the password slot with an empty username, so Delphi
|
||
// types just the code into the focused 2FA field.
|
||
TAutofillKind = (akFull, akPasswordOnly, akTotp);
|
||
|
||
// 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;
|
||
// Window that owned the foreground just before we came out of the tray, so
|
||
// hiding again can give it back. Windows does that for a minimise, but a
|
||
// hide leaves the previously active application dark.
|
||
FPrevForeground: HWND;
|
||
// WinEvent hook that watches every foreground change in the session.
|
||
FForegroundHook: THandle;
|
||
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;
|
||
FOnFillBarSettings: TProc;
|
||
FOnFillBarChanged: 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;
|
||
FAutofillTotpActive: Boolean;
|
||
FOnAutofillRequest: TAutofillRequestEvent;
|
||
FDebugHotkeyRegistered: Boolean;
|
||
FOnDebugHotkey: TProc;
|
||
FNewEntryHotkeyRegistered: Boolean;
|
||
// Current new-entry combo (user-configurable, default Ctrl+Shift+A). Kept
|
||
// so ExecuteAutofill can re-register it after unregistering during a fill.
|
||
FNewEntryHotkeyMods: Word;
|
||
FNewEntryHotkeyVk: Word;
|
||
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;
|
||
// "Lazy mode": a two-button strip floating above every other window, doing
|
||
// with the mouse what the autofill hotkeys do with the keyboard. 0 when off.
|
||
//
|
||
// WS_EX_NOACTIVATE IS THE WHOLE TRICK. A normal window would take the
|
||
// foreground on click, and the field the user just clicked in would lose
|
||
// its caret before we could type into it - GetForegroundWindow would then
|
||
// name US as the target. NOACTIVATE means the click never moves the
|
||
// foreground: the browser stays active, its field stays focused, and the
|
||
// capture below sees exactly what the hotkey path sees.
|
||
FLazyWnd: HWND;
|
||
// Dernier theme annonce par la page. La barrette est peinte a la main, donc
|
||
// elle a besoin de savoir - un bouton Win32 standard reste gris clair meme
|
||
// sous SetPreferredAppMode(ForceDark), qui ne themise que ce que le systeme
|
||
// dessine lui-meme (menus, ascenseurs, infobulles).
|
||
FDarkTheme: Boolean;
|
||
// Opacite AU REPOS, en pourcentage - la barrette redevient pleine des que
|
||
// la souris entre. 100 = jamais transparente. Vit en pref DPAPI et non dans
|
||
// settings_json : elle peut etre allumee coffre ferme, quand aucune session
|
||
// n'existe pour lire les reglages du serveur.
|
||
FLazyAlphaPct: Integer;
|
||
// Voir les proprietes LastFillWaits et LastRequestFromBar.
|
||
FLastFillWaits: string;
|
||
FLastRequestFromBar: Boolean;
|
||
// Dernier etat connu du survol, pour ne reposer l'opacite que sur un
|
||
// changement - SetLayeredWindowAttributes force un repaint a chaque appel.
|
||
FLazyHover: Boolean;
|
||
// Barrette en icones (police Segoe MDL2 Assets) plutot qu'en mots, et sa
|
||
// largeur reelle - elle depend des boutons choisis dans les reglages.
|
||
FLazyIcons: Boolean;
|
||
// Fond de la barrette retire (cle de couleur) : seuls les boutons restent.
|
||
FLazyClear: Boolean;
|
||
FLazyW: Integer;
|
||
procedure LazyWindowHandler(var AMsg: TMessage);
|
||
// Corps extraits des raccourcis Ctrl+Shift+A et Ctrl+Shift+Q : la barrette
|
||
// les declenche aussi, meme capture de la fenetre cible.
|
||
procedure FireNewEntryRequest;
|
||
procedure FireQuickSearchRequest;
|
||
/// Pose l'opacite qui convient a l'etat de survol donne.
|
||
procedure ApplyLazyAlpha(AHover: Boolean);
|
||
/// Menu du clic droit sur la barrette : le choix de l'opacite au repos.
|
||
procedure ShowLazyMenu;
|
||
/// Peint un bouton de la barrette (BS_OWNERDRAW). Le systeme garde le suivi
|
||
/// du clic et l'etat enfonce ; nous ne reprenons que les couleurs.
|
||
procedure DrawLazyButton(ADraw: PDrawItemStruct);
|
||
// Capture the foreground window and hand it to the host. Shared by the
|
||
// autofill hotkeys and the lazy strip - same target, same title matching,
|
||
// same picker. Only the gesture differs.
|
||
procedure FireAutofillRequest(AKind: TAutofillKind;
|
||
AFromBar: Boolean = False);
|
||
procedure MsgWindowHandler(var AMsg: TMessage);
|
||
procedure PrepareNid;
|
||
procedure ShowTrayMenu;
|
||
procedure ShowFirstTimeBalloon;
|
||
public
|
||
// The FMX taskbar proxy (TFMAppClass) — a SEPARATE top-level window from
|
||
// the form, and the one that owns the taskbar entry. Public so the host
|
||
// can hide it during a --tray launch, before MinimizeToTray gets to run.
|
||
function FindFMXAppWindow: HWND;
|
||
// Tray balloon (caller applies its own gating 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);
|
||
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.
|
||
// AReturnForeground: give the foreground back to the application that had
|
||
// it before we came out of the tray. False for callers that already decided
|
||
// who should be focused (post-autofill hands it to the filled window).
|
||
procedure MinimizeToTray(AClearClipboard: Boolean = True;
|
||
AReturnForeground: Boolean = True; ANotify: Boolean = True);
|
||
// Record the application currently in the foreground as the one to restore
|
||
// focus to when we go back to the tray. Ignores our own and shell windows.
|
||
procedure CaptureForegroundOwner;
|
||
// 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.
|
||
// ATotpVk = 0 means "no 2FA hotkey configured" — it is then skipped and
|
||
// does NOT count against the returned all-registered flag.
|
||
function SetAutofillHotkeys(AFullMods, AFullVk,
|
||
APwdMods, APwdVk, ATotpMods, ATotpVk: 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;
|
||
// Re-register the new-entry hotkey with a user-chosen combo (default
|
||
// Ctrl+Shift+A). Stored so ExecuteAutofill can restore it after the fill.
|
||
function SetNewEntryHotkey(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;
|
||
/// Le detail des deux attentes du dernier remplissage, pour le journal de
|
||
/// l'hote. Une chaine plutot qu'un evenement : ExecuteAutofill s'execute
|
||
/// sur le thread principal, juste avant que l'hote journalise.
|
||
property LastFillWaits: string read FLastFillWaits;
|
||
/// Le dernier remplissage venait-il de la barrette plutot que d'un
|
||
/// raccourci ? Les deux chemins se rejoignent des FireAutofillRequest.
|
||
property LastRequestFromBar: Boolean read FLastRequestFromBar;
|
||
property SecureClipboard: TSecureClipboard read FSecureClipboard;
|
||
property TrayAdded: Boolean read FTrayAdded;
|
||
// Show or hide the lazy strip. Idempotent, and remembered across launches.
|
||
procedure SetLazyBar(AOn: Boolean);
|
||
// Re-allume la barrette si elle l'etait au dernier arret. A appeler une
|
||
// fois l'hote pret : le bouton Open a besoin de OnTrayRestore, et la
|
||
// peinture a besoin du theme.
|
||
procedure RestoreLazyBar;
|
||
function LazyBarVisible: Boolean;
|
||
/// Reconstruit la barrette apres un changement de ses reglages (boutons,
|
||
/// icones). POSTE : l'appel peut venir de la barrette elle-meme, qu'on ne
|
||
/// detruit pas depuis son propre gestionnaire.
|
||
procedure ReloadLazyBar;
|
||
property AutofillRegistered: Boolean read FAutofillRegistered;
|
||
// Per-slot outcome of the last SetAutofillHotkeys — lets the host name
|
||
// exactly which combo lost the race with another app's global hotkey
|
||
// instead of a vague "one of them failed".
|
||
property AutofillFullActive: Boolean read FAutofillFullActive;
|
||
property AutofillPwdActive: Boolean read FAutofillPwdActive;
|
||
property AutofillTotpActive: Boolean read FAutofillTotpActive;
|
||
// 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;
|
||
// Clic droit sur la barrette -> "Fill bar settings..." : la fenetre revient
|
||
// (OnTrayRestore) puis l'hote ouvre l'onglet Fill bar des reglages.
|
||
property OnFillBarSettings: TProc read FOnFillBarSettings write FOnFillBarSettings;
|
||
// La barrette a change hors des reglages (clic droit, menu du tray) : l'hote
|
||
// rafraichit l'onglet Fill bar s'il est affiche.
|
||
property OnFillBarChanged: TProc read FOnFillBarChanged write FOnFillBarChanged;
|
||
// 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, Winapi.CommCtrl;
|
||
|
||
// Foreground window at process start — captured in this unit's initialization,
|
||
// which runs before any of our windows exist. Every later opportunity is
|
||
// circular: once we own the foreground (and a tray-hidden window keeps owning
|
||
// it), asking who is in front only ever answers "us", so we would never learn
|
||
// which application to give focus back to.
|
||
var
|
||
GStartupForeground: HWND = 0;
|
||
// Last window, in any other process, that genuinely held the foreground.
|
||
// Maintained by the hook below rather than sampled when we need it: by then
|
||
// the user's application is two foreground changes behind (their app → the
|
||
// taskbar they clicked → us), so it can no longer be observed.
|
||
GLastForeground: HWND = 0;
|
||
|
||
// ---- WinEvent hook plumbing (foreground tracking) ------------------------
|
||
// Not in Winapi.Windows; UMainForm declares its own copy for the minimise hook.
|
||
const
|
||
EVENT_SYSTEM_FOREGROUND = $0003;
|
||
WINEVENT_OUTOFCONTEXT = $0000;
|
||
WINEVENT_SKIPOWNPROCESS = $0002;
|
||
OBJID_WINDOW = 0;
|
||
|
||
function SetWinEventHook(eventMin, eventMax: DWORD; hmodWinEventProc: HMODULE;
|
||
lpfnWinEventProc: Pointer; idProcess, idThread, dwFlags: DWORD): THandle;
|
||
stdcall; external 'user32.dll' name 'SetWinEventHook';
|
||
function UnhookWinEvent(hWinEventHook: THandle): BOOL;
|
||
stdcall; external 'user32.dll' name 'UnhookWinEvent';
|
||
|
||
// Defined further down but needed by the constructor / MinimizeToTray.
|
||
function IsOwnProcessWindow(AHwnd: HWND): Boolean; forward;
|
||
function IsShellWindow(AHwnd: HWND): Boolean; forward;
|
||
function ForceForegroundWindow(ATargetHwnd: HWND): Boolean; forward;
|
||
procedure ForegroundEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
|
||
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall; forward;
|
||
|
||
// 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;
|
||
// Fermeture de la barrette, POSTEE a la fenetre message-only plutot que
|
||
// traitee sur place : detruire une fenetre depuis son propre gestionnaire
|
||
// libererait, en plein envoi, l'instance qui sert de window proc.
|
||
WM_LAZY_CLOSE = WM_APP + 2;
|
||
WM_LAZY_RELOAD = WM_APP + 3;
|
||
|
||
// 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
|
||
AUTOFILL_HOTKEY_ID_TOTP = 47; // Ctrl+Shift+T → type the 2FA code
|
||
AF_MOD_CONTROL = $0002; // same value as MOD_CONTROL
|
||
AF_MOD_SHIFT = $0004; // same value as MOD_SHIFT
|
||
|
||
// Lazy strip - child button IDs and geometry. Deliberately small: it sits on
|
||
// top of whatever the user is reading, so it has to cost as little screen as
|
||
// two words allow.
|
||
const
|
||
LAZY_ID_FULL = 101;
|
||
LAZY_ID_PWD = 102;
|
||
LAZY_ID_TOTP = 103;
|
||
LAZY_ID_CLOSE = 104;
|
||
LAZY_ID_OPEN = 105;
|
||
LAZY_ID_ADD = 106; // nouvelle entree depuis la fenetre en avant (Ctrl+Shift+A)
|
||
LAZY_ID_SEARCH = 107; // recherche rapide (Ctrl+Shift+Q)
|
||
LAZY_ID_SETTINGS = 108; // reglages, onglet Fill bar
|
||
LAZY_MENU_ICONS = 1; // hors des valeurs d'opacite (25..100) du meme menu
|
||
LAZY_MENU_SETTINGS = 2;
|
||
LAZY_MENU_CLEAR = 3;
|
||
// LE SONDAGE PLUTOT QUE TrackMouseEvent. La barrette a quatre fenetres
|
||
// enfants : passer d'elle a un bouton envoie deja un WM_MOUSELEAVE au parent,
|
||
// et il faudrait donc suivre la souris sur les cinq fenetres pour recoller
|
||
// les morceaux. Comparer le curseur au rectangle cinq fois par seconde ne
|
||
// connait pas ce piege, et ne coute rien de mesurable.
|
||
LAZY_TIMER_ID = 1;
|
||
LAZY_POLL_MS = 200;
|
||
// Cle de la pref, et opacite par defaut. 60 % laisse lire ce qu'il y a
|
||
// derriere sans faire disparaitre la barrette du regard.
|
||
LAZY_ALPHA_KEY = 'lazyAlphaPct';
|
||
LAZY_ALPHA_DEFAULT = 60;
|
||
// Coin haut-gauche retenu, 'x,y'. Meme raison que l'opacite d'etre une pref
|
||
// DPAPI : la barrette s'allume parfois coffre ferme.
|
||
LAZY_POS_KEY = 'lazyPos';
|
||
// Barrette allumee ou non au dernier arret. Pref DPAPI comme les deux autres :
|
||
// elle doit revenir AVANT toute ouverture de coffre, et localStorage est vide
|
||
// a chaque lancement (le port ephemere change d'origine).
|
||
LAZY_ON_KEY = 'lazyOn';
|
||
// Boutons affiches, dans l'ordre, et le choix icones/texte. Le X est toujours
|
||
// la : c'est la seule facon de fermer la barrette la ou elle est.
|
||
LAZY_BTNS_KEY = 'lazyButtons';
|
||
LAZY_BTNS_DEFAULT = 'full,pwd,totp,add,search,open,settings';
|
||
LAZY_ICONS_KEY = 'lazyIcons';
|
||
// FOND TRANSPARENT : peint dans une couleur qu'aucun theme n'emploie, puis
|
||
// declaree cle (LWA_COLORKEY) - ces pixels ne sont plus dessines du tout, et
|
||
// les clics y TRAVERSENT. On traine donc la barrette par ses boutons
|
||
// (DragDetect), le fond n'etant plus la pour etre saisi.
|
||
LAZY_CLEAR_KEY = 'lazyClear';
|
||
LAZY_KEY_COLOR = $00FF00FF;
|
||
LAZY_H = 40;
|
||
|
||
// Palette de la barrette, en COLORREF ($00BBGGRR - les octets sont INVERSES
|
||
// par rapport a l'hexadecimal CSS). Reprise des variables de css/style.css pour
|
||
// que la barrette et la fenetre principale ne soient pas deux gris differents.
|
||
const
|
||
LAZY_DARK_BG = $1C1515; // --bg #15151c
|
||
LAZY_DARK_FACE = $322929; // --bg-elev-2 #292932
|
||
LAZY_DARK_DOWN = $3F3434; // --bg-elev-3 #34343f
|
||
LAZY_DARK_EDGE = $413535; // --border #353541
|
||
LAZY_DARK_INK = $F5F1F1; // --text #f1f1f5
|
||
LAZY_LITE_BG = $F5F7F7; // --bg #f7f7f5
|
||
LAZY_LITE_FACE = $FFFFFF; // --bg-elev #ffffff
|
||
LAZY_LITE_DOWN = $DDE3E3; // --bg-elev-3 #e3e3dd
|
||
LAZY_LITE_EDGE = $D7DCDC; // --border #dcdcd7
|
||
LAZY_LITE_INK = $1F1A1A; // --text #1a1a1f
|
||
// Survol : l'accent de l'app, comme les boutons de la fenetre principale.
|
||
LAZY_DARK_ACCENT = $D4B606; // --accent #06b6d4
|
||
LAZY_LITE_ACCENT = $B29108; // --accent #0891b2
|
||
|
||
// 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;
|
||
ClearIfOurs;
|
||
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;
|
||
|
||
// Snapshot the sequence AFTER our write — any later copy (ours or another
|
||
// app's) bumps it, which is exactly the "is it still ours?" signal.
|
||
FSetSequence := GetClipboardSequenceNumber;
|
||
|
||
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;
|
||
|
||
procedure TSecureClipboard.ClearIfOurs;
|
||
begin
|
||
if GetClipboardSequenceNumber = FSetSequence then
|
||
Clear;
|
||
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
|
||
// Seed from the pre-window snapshot, then let the hook maintain it: the first
|
||
// close can happen before any other application is activated.
|
||
if (GStartupForeground <> 0) and IsWindow(GStartupForeground) and
|
||
(not IsOwnProcessWindow(GStartupForeground)) and
|
||
(not IsShellWindow(GStartupForeground)) then
|
||
GLastForeground := GStartupForeground;
|
||
CaptureForegroundOwner;
|
||
FForegroundHook := SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND,
|
||
0, @ForegroundEventProc, 0, 0, WINEVENT_OUTOFCONTEXT or WINEVENT_SKIPOWNPROCESS);
|
||
|
||
// 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'));
|
||
// Default combo; JS overrides it via cmd://autofill/hotkeys (SetNewEntryHotkey)
|
||
// at unlock. Store it so ExecuteAutofill's re-register uses the live combo.
|
||
FNewEntryHotkeyMods := AF_MOD_CONTROL or AF_MOD_SHIFT;
|
||
FNewEntryHotkeyVk := Ord('A');
|
||
FNewEntryHotkeyRegistered := RegisterHotKey(FMsgWindow, NEW_ENTRY_HOTKEY_ID,
|
||
FNewEntryHotkeyMods, FNewEntryHotkeyVk);
|
||
FQuickSearchHotkeyRegistered := RegisterHotKey(FMsgWindow,
|
||
QUICK_SEARCH_HOTKEY_ID, AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('Q'));
|
||
end;
|
||
|
||
destructor TPMBridge.Destroy;
|
||
begin
|
||
if FForegroundHook <> 0 then
|
||
UnhookWinEvent(FForegroundHook);
|
||
|
||
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);
|
||
|
||
if FLazyWnd <> 0 then DeallocateHWnd(FLazyWnd);
|
||
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;
|
||
|
||
// Every foreground change in the session lands here (out-of-context, so nothing
|
||
// is injected into other processes). We keep the last one that is neither ours
|
||
// nor a shell window — that is the application to hand focus back to.
|
||
procedure ForegroundEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
|
||
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall;
|
||
begin
|
||
if (idObject <> OBJID_WINDOW) or (AWnd = 0) then Exit;
|
||
if IsOwnProcessWindow(AWnd) or IsShellWindow(AWnd) then Exit;
|
||
GLastForeground := AWnd;
|
||
end;
|
||
|
||
procedure TPMBridge.CaptureForegroundOwner;
|
||
begin
|
||
if (GLastForeground <> 0) and IsWindow(GLastForeground) then
|
||
FPrevForeground := GLastForeground;
|
||
end;
|
||
|
||
procedure TPMBridge.MinimizeToTray(AClearClipboard: Boolean = True;
|
||
AReturnForeground: Boolean = True; ANotify: 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. ClearIfOurs: never wipe
|
||
// content the user copied from another app since.
|
||
if AClearClipboard then
|
||
FSecureClipboard.ClearIfOurs;
|
||
|
||
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);
|
||
|
||
// 0b. Hand the foreground back to the application that had it before we came
|
||
// up, BEFORE hiding: Windows only lets the current foreground owner give
|
||
// focus away, and once our windows are hidden the call is silently
|
||
// refused — which left us foreground while invisible, so the next hotkey
|
||
// captured our own window as its target. Callers that already placed the
|
||
// foreground themselves (post-autofill) pass False.
|
||
CaptureForegroundOwner;
|
||
// Not when it is minimised: ForceForegroundWindow un-minimises its target, and
|
||
// popping another application's window back open is not ours to decide. The
|
||
// user put it away — leave the foreground to Windows.
|
||
if AReturnForeground and (FPrevForeground <> 0) and IsWindow(FPrevForeground) and
|
||
(not IsIconic(FPrevForeground)) then
|
||
ForceForegroundWindow(FPrevForeground);
|
||
|
||
// 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.
|
||
// ANotify=False : absence de quelques centaines de ms (scan du QR), la
|
||
// fenetre revient seule - l'annonce "toujours dans le tray" serait fausse.
|
||
if ANotify and (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
|
||
// No gating here — each caller applies its own setting (first-time tray
|
||
// balloon → FShowNotifications; autofill-blocked → autofillFailBalloon).
|
||
// 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;
|
||
LWasTrayHidden, LRestoreMax: Boolean;
|
||
LPlace: TWindowPlacement;
|
||
begin
|
||
// Tray icon stays in the tray — we only show the window again.
|
||
LFormHwnd := MainFormHWND(FMainForm);
|
||
LAppHwnd := FindFMXAppWindow;
|
||
|
||
CaptureForegroundOwner;
|
||
|
||
// 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;
|
||
|
||
// Captured BEFORE Show flips Visible: tray-hidden (Hide'd by
|
||
// MinimizeToTray) vs merely taskbar-minimised (Visible but iconic).
|
||
LWasTrayHidden := not FMainForm.Visible;
|
||
|
||
// Windows remembers "this minimised window was maximised" in the placement
|
||
// flags, but FMainForm.Show below re-applies FMX's own WindowState (normal)
|
||
// and loses it — hence a maximised window coming back normal after Win+D /
|
||
// Show desktop. Read the flag BEFORE showing so we can re-apply it.
|
||
LRestoreMax := False;
|
||
if not LWasTrayHidden then
|
||
begin
|
||
FillChar(LPlace, SizeOf(LPlace), 0);
|
||
LPlace.length := SizeOf(LPlace);
|
||
LRestoreMax := GetWindowPlacement(LFormHwnd, @LPlace) and
|
||
((LPlace.flags and WPF_RESTORETOMAXIMIZED) <> 0);
|
||
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.
|
||
// Replay the snapshot ONLY for a genuine tray return (window was hidden —
|
||
// the exact state MinimizeToTray created). A merely taskbar-minimised
|
||
// window restores via Windows' own placement memory below; replaying our
|
||
// older snapshot would teleport it to wherever it sat at the LAST tray-hide.
|
||
if FHasSavedPlacement and LWasTrayHidden 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 if LRestoreMax then
|
||
// Was maximised before Windows minimised it (Win+D, taskbar, Show
|
||
// desktop): come back maximised. SW_RESTORE alone would land on the
|
||
// pre-maximise "normal" geometry.
|
||
ShowWindow(LFormHwnd, SW_SHOWMAXIMIZED)
|
||
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;
|
||
ID_LAZY = 5;
|
||
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_STRING or IfThen(LazyBarVisible, MF_CHECKED, MF_UNCHECKED),
|
||
// "Fill bar" et non "Lazy mode" : le premier nomme la chose, le second
|
||
// decrivait l'utilisateur. Le signe qui a tranche - l'ancien libelle avait
|
||
// besoin d'une parenthese pour se faire comprendre, et c'etait elle qui
|
||
// portait le sens. Les identifiants internes gardent "lazy" : renommer
|
||
// LAZY_ON_KEY et consorts perdrait les reglages deja poses sur la machine.
|
||
ID_LAZY, 'Fill bar');
|
||
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_LAZY: SetLazyBar(not LazyBarVisible);
|
||
ID_LOCK: if Assigned(FOnLockRequest) then FOnLockRequest();
|
||
ID_QUIT: if Assigned(FOnQuit) then FOnQuit();
|
||
end;
|
||
finally
|
||
DestroyMenu(LMenu);
|
||
end;
|
||
end;
|
||
|
||
// Hiding to the tray leaves no other window activated, so our own — invisible —
|
||
// window can still be the foreground one. Every hotkey that captures a target
|
||
// from the foreground has to reject that case, or it acts on itself.
|
||
function IsOwnProcessWindow(AHwnd: HWND): Boolean;
|
||
var
|
||
LPid: DWORD;
|
||
begin
|
||
if AHwnd = 0 then Exit(True);
|
||
LPid := 0;
|
||
GetWindowThreadProcessId(AHwnd, LPid);
|
||
Result := LPid = GetCurrentProcessId;
|
||
end;
|
||
|
||
// Desktop and notification-area windows are foreground constantly — clicking
|
||
// the tray icon makes the notification overflow flyout foreground — but they are
|
||
// never "the application the user was working in", so they must not be recorded
|
||
// as the one to give focus back to.
|
||
function IsShellWindow(AHwnd: HWND): Boolean;
|
||
const
|
||
SHELL_CLASSES: array[0..5] of string = (
|
||
'Shell_TrayWnd', 'NotifyIconOverflowWindow', 'TopLevelWindowForOverflowXamlIsland',
|
||
'Progman', 'WorkerW', 'Shell_SecondaryTrayWnd');
|
||
var
|
||
LClass: array[0..255] of Char;
|
||
LName: string;
|
||
I: Integer;
|
||
begin
|
||
if GetClassName(AHwnd, LClass, Length(LClass)) = 0 then Exit(False);
|
||
LName := LClass;
|
||
for I := Low(SHELL_CLASSES) to High(SHELL_CLASSES) do
|
||
if SameText(LName, SHELL_CLASSES[I]) then Exit(True);
|
||
Result := False;
|
||
end;
|
||
|
||
// The target of a fill is whatever window owns the foreground at the moment
|
||
// the user asks for it - a shortcut, or a click on the lazy strip, which takes
|
||
// no foreground precisely so this stays true.
|
||
procedure TPMBridge.FireAutofillRequest(AKind: TAutofillKind;
|
||
AFromBar: Boolean = False);
|
||
var
|
||
LTarget: HWND;
|
||
LTitle: string;
|
||
LLen: Integer;
|
||
begin
|
||
if not Assigned(FOnAutofillRequest) then Exit;
|
||
// QUI A DEMANDE. Les deux gestes partagent tout ce qui suit, donc le journal
|
||
// les confondait sous "hotkey" - et un diagnostic de lenteur qui ne sait pas
|
||
// par ou l'on est entre cherche au mauvais endroit.
|
||
FLastRequestFromBar := AFromBar;
|
||
LTarget := GetForegroundWindow;
|
||
CaptureForegroundOwner;
|
||
// Hiding to the tray does not hand the foreground to anyone else, so ours
|
||
// stays foreground while invisible: from there we would capture our own
|
||
// window and type the password into it. There is no target to fill.
|
||
if IsOwnProcessWindow(LTarget) then
|
||
begin
|
||
ShowBalloon('Nothing to fill',
|
||
'Click the field you want filled first, then ask for the fill.', True);
|
||
Exit;
|
||
end;
|
||
SetLength(LTitle, 512);
|
||
LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
|
||
SetLength(LTitle, LLen);
|
||
FOnAutofillRequest(AKind, LTarget, LTitle);
|
||
end;
|
||
|
||
// Les quatre boutons sont de la meme classe, donc une seule procedure d'origine
|
||
// a retenir.
|
||
var
|
||
GLazyOldBtnProc: Pointer = nil;
|
||
// Bouton de la barrette sous la souris (0 = aucun) : il se peint a l'accent.
|
||
GLazyHotBtn: HWND = 0;
|
||
|
||
/// Un appui sur un bouton de la barrette : clic, ou debut de glissement ?
|
||
///
|
||
/// C'EST DragDetect QUI TRANCHE, ET PERSONNE D'AUTRE. Il bloque jusqu'a ce que
|
||
/// la souris depasse le seuil de glissement du systeme - celui que
|
||
/// l'utilisateur a regle, et qui n'est pas le meme sur un ecran tactile - ou
|
||
/// jusqu'au relachement. Un seuil ecrit a la main serait un troisieme reglage
|
||
/// que personne n'a demande.
|
||
function LazyButtonProc(AWnd: HWND; AMsg: UINT; AWp: WPARAM;
|
||
ALp: LPARAM): LRESULT; stdcall;
|
||
var
|
||
LPt: TPoint;
|
||
begin
|
||
if AMsg = WM_LBUTTONDOWN then
|
||
begin
|
||
GetCursorPos(LPt);
|
||
if DragDetect(AWnd, LPt) then
|
||
begin
|
||
ReleaseCapture; // DragDetect a pris la souris, la boucle de deplacement la veut
|
||
// Exactement le geste "trainer par la barre de titre" : la barrette EST
|
||
// sa propre barre de titre (WM_NCHITTEST -> HTCAPTION).
|
||
SendMessage(GetParent(AWnd), WM_SYSCOMMAND, SC_MOVE or HTCAPTION, 0);
|
||
end
|
||
else
|
||
// Relache sans bouger. DragDetect a AVALE le WM_LBUTTONUP, que le bouton
|
||
// ne verra donc jamais : lui rendre la main le laisserait enfonce pour
|
||
// toujours. On annonce le clic a sa place - c'est le message qu'il aurait
|
||
// envoye lui-meme.
|
||
SendMessage(GetParent(AWnd), WM_COMMAND,
|
||
MAKEWPARAM(GetDlgCtrlID(AWnd), BN_CLICKED), LPARAM(AWnd));
|
||
Exit(0);
|
||
end;
|
||
// LE SURVOL SE VOIT. Un bouton peint a la main n'a aucun etat "survole" :
|
||
// on le suit ici (entree par WM_MOUSEMOVE, sortie par WM_MOUSELEAVE demande a
|
||
// TrackMouseEvent) et on redessine les deux boutons concernes.
|
||
if (AMsg = WM_MOUSEMOVE) and (GLazyHotBtn <> AWnd) then
|
||
begin
|
||
if GLazyHotBtn <> 0 then InvalidateRect(GLazyHotBtn, nil, False);
|
||
GLazyHotBtn := AWnd;
|
||
InvalidateRect(AWnd, nil, False);
|
||
var LTrack: TTrackMouseEvent;
|
||
LTrack.cbSize := SizeOf(LTrack);
|
||
LTrack.dwFlags := TME_LEAVE;
|
||
LTrack.hwndTrack := AWnd;
|
||
LTrack.dwHoverTime := 0;
|
||
TrackMouseEvent(LTrack);
|
||
end
|
||
else if (AMsg = WM_MOUSELEAVE) and (GLazyHotBtn = AWnd) then
|
||
begin
|
||
GLazyHotBtn := 0;
|
||
InvalidateRect(AWnd, nil, False);
|
||
end;
|
||
Result := CallWindowProc(GLazyOldBtnProc, AWnd, AMsg, AWp, ALp);
|
||
end;
|
||
|
||
procedure TPMBridge.DrawLazyButton(ADraw: PDrawItemStruct);
|
||
var
|
||
LRect: TRect;
|
||
LBrush: HBRUSH;
|
||
LFace, LEdge, LInk: COLORREF;
|
||
LText: array[0..31] of Char;
|
||
LGlyph: Char;
|
||
LFont: HFONT;
|
||
LOldFont: HGDIOBJ;
|
||
begin
|
||
LRect := ADraw.rcItem;
|
||
if FDarkTheme then
|
||
begin
|
||
LFace := LAZY_DARK_FACE; LEdge := LAZY_DARK_EDGE; LInk := LAZY_DARK_INK;
|
||
if (ADraw.itemState and ODS_SELECTED) <> 0 then LFace := LAZY_DARK_DOWN;
|
||
end
|
||
else
|
||
begin
|
||
LFace := LAZY_LITE_FACE; LEdge := LAZY_LITE_EDGE; LInk := LAZY_LITE_INK;
|
||
if (ADraw.itemState and ODS_SELECTED) <> 0 then LFace := LAZY_LITE_DOWN;
|
||
end;
|
||
// Survole : fond releve et encre a l'accent, cadre compris.
|
||
if ADraw.hwndItem = GLazyHotBtn then
|
||
begin
|
||
if FDarkTheme then
|
||
begin
|
||
LFace := LAZY_DARK_DOWN; LInk := LAZY_DARK_ACCENT; LEdge := LAZY_DARK_ACCENT;
|
||
end
|
||
else
|
||
begin
|
||
LFace := LAZY_LITE_DOWN; LInk := LAZY_LITE_ACCENT; LEdge := LAZY_LITE_ACCENT;
|
||
end;
|
||
end;
|
||
|
||
LBrush := CreateSolidBrush(LFace);
|
||
FillRect(ADraw.hDC, LRect, LBrush);
|
||
DeleteObject(LBrush);
|
||
LBrush := CreateSolidBrush(LEdge);
|
||
FrameRect(ADraw.hDC, LRect, LBrush);
|
||
DeleteObject(LBrush);
|
||
|
||
SetBkMode(ADraw.hDC, TRANSPARENT);
|
||
SetTextColor(ADraw.hDC, LInk);
|
||
if FLazyIcons then
|
||
begin
|
||
// Segoe MDL2 Assets, livree avec Windows 10 et 11 : aucune image a
|
||
// embarquer, et le glyphe suit la couleur du texte comme un mot.
|
||
case ADraw.CtlID of
|
||
LAZY_ID_FULL: LGlyph := #$E77B; // Contact
|
||
LAZY_ID_PWD: LGlyph := #$E8D7; // Permissions (cle)
|
||
LAZY_ID_TOTP: LGlyph := #$E823; // Recent (horloge du code)
|
||
LAZY_ID_ADD: LGlyph := #$E710; // Add
|
||
LAZY_ID_SEARCH: LGlyph := #$E721; // Search
|
||
LAZY_ID_OPEN: LGlyph := #$E8A7; // OpenInNewWindow
|
||
LAZY_ID_SETTINGS: LGlyph := #$E713; // Settings (roue)
|
||
else
|
||
LGlyph := #$E711; // Cancel (X)
|
||
end;
|
||
LFont := CreateFont(-16, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET,
|
||
OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
|
||
DEFAULT_PITCH, 'Segoe MDL2 Assets');
|
||
LOldFont := SelectObject(ADraw.hDC, LFont);
|
||
DrawText(ADraw.hDC, PChar(LGlyph), 1, LRect,
|
||
DT_CENTER or DT_VCENTER or DT_SINGLELINE);
|
||
SelectObject(ADraw.hDC, LOldFont);
|
||
DeleteObject(LFont);
|
||
Exit;
|
||
end;
|
||
// Le libelle est celui de la fenetre-bouton : une seule verite, posee a la
|
||
// creation, plutot qu'une table de correspondance a tenir a jour.
|
||
GetWindowText(ADraw.hwndItem, LText, Length(LText));
|
||
DrawText(ADraw.hDC, LText, -1, LRect,
|
||
DT_CENTER or DT_VCENTER or DT_SINGLELINE);
|
||
end;
|
||
|
||
procedure TPMBridge.ApplyLazyAlpha(AHover: Boolean);
|
||
var
|
||
LAlpha: Byte;
|
||
begin
|
||
if FLazyWnd = 0 then Exit;
|
||
// TRANSPARENTE N'EST PAS TRAVERSANTE : la barrette continue d'avaler les
|
||
// clics sur toute sa surface. C'est un allegement du regard, pas un passage.
|
||
// Le pourcentage s'applique toujours aux boutons, fond retire ou non.
|
||
if AHover or (FLazyAlphaPct >= 100) then
|
||
LAlpha := 255
|
||
else
|
||
LAlpha := MulDiv(255, FLazyAlphaPct, 100);
|
||
if FLazyClear then
|
||
SetLayeredWindowAttributes(FLazyWnd, LAZY_KEY_COLOR, LAlpha,
|
||
LWA_ALPHA or LWA_COLORKEY)
|
||
else
|
||
SetLayeredWindowAttributes(FLazyWnd, 0, LAlpha, LWA_ALPHA);
|
||
end;
|
||
|
||
procedure TPMBridge.ShowLazyMenu;
|
||
const
|
||
CHOICES: array[0..3] of Integer = (100, 75, 50, 25);
|
||
var
|
||
LMenu: HMENU;
|
||
LPt: TPoint;
|
||
LCmd: Cardinal;
|
||
I: Integer;
|
||
LIconsLabel: string;
|
||
begin
|
||
LMenu := CreatePopupMenu;
|
||
if LMenu = 0 then Exit;
|
||
try
|
||
for I := Low(CHOICES) to High(CHOICES) do
|
||
AppendMenu(LMenu,
|
||
MF_STRING or IfThen(FLazyAlphaPct = CHOICES[I], MF_CHECKED, MF_UNCHECKED),
|
||
CHOICES[I], PChar(Format('%d%% when the mouse leaves', [CHOICES[I]])));
|
||
if FLazyIcons then LIconsLabel := 'Show text' else LIconsLabel := 'Show icons';
|
||
AppendMenu(LMenu, MF_SEPARATOR, 0, nil);
|
||
AppendMenu(LMenu, MF_STRING, LAZY_MENU_ICONS, PChar(LIconsLabel));
|
||
AppendMenu(LMenu, MF_STRING or IfThen(FLazyClear, MF_CHECKED, MF_UNCHECKED),
|
||
LAZY_MENU_CLEAR, 'Transparent background');
|
||
AppendMenu(LMenu, MF_STRING, LAZY_MENU_SETTINGS, 'Fill bar settings...');
|
||
|
||
// LE BATTEMENT DOIT S'ARRETER PENDANT LE MENU. Il repose la barrette en
|
||
// tete de la bande TOPMOST cinq fois par seconde - or le menu deroulant est
|
||
// lui aussi dans cette bande, et se retrouvait donc recouvert par la
|
||
// barrette qui venait de l'ouvrir. TrackPopupMenu est modal : le minuteur
|
||
// continuerait de battre pendant tout ce temps.
|
||
KillTimer(FLazyWnd, LAZY_TIMER_ID);
|
||
GetCursorPos(LPt);
|
||
// Le proprietaire du menu est la fenetre message-only, JAMAIS la barrette :
|
||
// TrackPopupMenu exige un SetForegroundWindow pour se refermer tout seul, et
|
||
// le faire sur la barrette detruirait le NOACTIVATE qui la fait fonctionner.
|
||
SetForegroundWindow(FMsgWindow);
|
||
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);
|
||
|
||
if LCmd = LAZY_MENU_SETTINGS then
|
||
begin
|
||
if Assigned(FOnTrayRestore) then FOnTrayRestore();
|
||
if Assigned(FOnFillBarSettings) then FOnFillBarSettings();
|
||
end
|
||
else if LCmd = LAZY_MENU_CLEAR then
|
||
begin
|
||
if FLazyClear then SetPref(LAZY_CLEAR_KEY, '0') else SetPref(LAZY_CLEAR_KEY, '1');
|
||
ReloadLazyBar;
|
||
end
|
||
else if LCmd = LAZY_MENU_ICONS then
|
||
begin
|
||
if FLazyIcons then SetPref(LAZY_ICONS_KEY, '0') else SetPref(LAZY_ICONS_KEY, '1');
|
||
ReloadLazyBar;
|
||
end
|
||
else if LCmd > 0 then
|
||
begin
|
||
FLazyAlphaPct := LCmd;
|
||
SetPref(LAZY_ALPHA_KEY, IntToStr(FLazyAlphaPct));
|
||
ApplyLazyAlpha(FLazyHover);
|
||
if Assigned(FOnFillBarChanged) then FOnFillBarChanged();
|
||
end;
|
||
finally
|
||
DestroyMenu(LMenu);
|
||
if FLazyWnd <> 0 then
|
||
SetTimer(FLazyWnd, LAZY_TIMER_ID, LAZY_POLL_MS, nil);
|
||
end;
|
||
end;
|
||
|
||
function TPMBridge.LazyBarVisible: Boolean;
|
||
begin
|
||
Result := FLazyWnd <> 0;
|
||
end;
|
||
|
||
procedure TPMBridge.LazyWindowHandler(var AMsg: TMessage);
|
||
var
|
||
LRect: TRect;
|
||
LBrush: HBRUSH;
|
||
LPt: TPoint;
|
||
LIn: Boolean;
|
||
begin
|
||
case AMsg.Msg of
|
||
WM_TIMER:
|
||
begin
|
||
GetCursorPos(LPt);
|
||
GetWindowRect(FLazyWnd, LRect);
|
||
LIn := PtInRect(LRect, LPt);
|
||
if LIn <> FLazyHover then
|
||
begin
|
||
FLazyHover := LIn;
|
||
ApplyLazyAlpha(LIn);
|
||
end;
|
||
// TOPMOST N'EST PAS UN RANG, C'EST UNE BANDE. La barre des taches y est
|
||
// elle aussi, et cliquer son fond vide la remonte en tete de cette
|
||
// bande : une barrette posee dessus passe DERRIERE et semble avoir
|
||
// disparu, alors qu'elle est seulement recouverte. La reposer a chaque
|
||
// battement est ce que font tous les outils flottants ; sans mouvement
|
||
// ni taille a changer, l'appel ne repeint rien.
|
||
SetWindowPos(FLazyWnd, HWND_TOPMOST, 0, 0, 0, 0,
|
||
SWP_NOMOVE or SWP_NOSIZE or SWP_NOACTIVATE);
|
||
Exit;
|
||
end;
|
||
// Fin du glissement (la boucle modale ouverte par HTCAPTION), donc une
|
||
// ecriture par deplacement et non une par pixel parcouru.
|
||
WM_EXITSIZEMOVE:
|
||
begin
|
||
GetWindowRect(FLazyWnd, LRect);
|
||
SetPref(LAZY_POS_KEY, Format('%d,%d', [LRect.Left, LRect.Top]));
|
||
Exit;
|
||
end;
|
||
// Deux portes pour un seul menu : le fond de la barrette est declare
|
||
// non-client (HTCAPTION, pour pouvoir la trainer) et rend donc un clic droit
|
||
// NC, tandis qu'un clic droit sur un bouton remonte en WM_CONTEXTMENU.
|
||
WM_NCRBUTTONUP, WM_CONTEXTMENU:
|
||
begin
|
||
ShowLazyMenu;
|
||
AMsg.Result := 0;
|
||
Exit;
|
||
end;
|
||
// THE STRIP IS ITS OWN TITLE BAR. It has none to grab, and it will end up
|
||
// sitting over the very field it fills - so every pixel that is not a
|
||
// button drags it out of the way.
|
||
WM_NCHITTEST:
|
||
begin
|
||
AMsg.Result := HTCAPTION;
|
||
Exit;
|
||
end;
|
||
// AllocateHWnd's window class registers no background brush, so nothing
|
||
// erases the gaps around the buttons and whatever was behind shows through.
|
||
WM_ERASEBKGND:
|
||
begin
|
||
GetClientRect(FLazyWnd, LRect);
|
||
if FLazyClear then
|
||
begin
|
||
LBrush := CreateSolidBrush(LAZY_KEY_COLOR);
|
||
FillRect(HDC(AMsg.WParam), LRect, LBrush);
|
||
DeleteObject(LBrush);
|
||
AMsg.Result := 1;
|
||
Exit;
|
||
end;
|
||
LBrush := CreateSolidBrush(IfThen(FDarkTheme, LAZY_DARK_BG, LAZY_LITE_BG));
|
||
FillRect(HDC(AMsg.WParam), LRect, LBrush);
|
||
DeleteObject(LBrush);
|
||
// Le cadre est peint ici plutot que par WS_BORDER : celui-ci se sert
|
||
// dans les couleurs systeme, qui ignorent notre theme.
|
||
LBrush := CreateSolidBrush(IfThen(FDarkTheme, LAZY_DARK_EDGE, LAZY_LITE_EDGE));
|
||
FrameRect(HDC(AMsg.WParam), LRect, LBrush);
|
||
DeleteObject(LBrush);
|
||
AMsg.Result := 1;
|
||
Exit;
|
||
end;
|
||
WM_DRAWITEM:
|
||
begin
|
||
DrawLazyButton(PDrawItemStruct(AMsg.LParam));
|
||
AMsg.Result := 1;
|
||
Exit;
|
||
end;
|
||
WM_COMMAND:
|
||
if HiWord(AMsg.WParam) = BN_CLICKED then
|
||
begin
|
||
case LoWord(AMsg.WParam) of
|
||
LAZY_ID_FULL: FireAutofillRequest(akFull, True);
|
||
LAZY_ID_PWD: FireAutofillRequest(akPasswordOnly, True);
|
||
LAZY_ID_TOTP: FireAutofillRequest(akTotp, True);
|
||
LAZY_ID_OPEN: if Assigned(FOnTrayRestore) then FOnTrayRestore();
|
||
LAZY_ID_ADD: FireNewEntryRequest;
|
||
LAZY_ID_SEARCH: FireQuickSearchRequest;
|
||
LAZY_ID_SETTINGS:
|
||
begin
|
||
if Assigned(FOnTrayRestore) then FOnTrayRestore();
|
||
if Assigned(FOnFillBarSettings) then FOnFillBarSettings();
|
||
end;
|
||
LAZY_ID_CLOSE: PostMessage(FMsgWindow, WM_LAZY_CLOSE, 0, 0);
|
||
end;
|
||
AMsg.Result := 0;
|
||
Exit;
|
||
end;
|
||
end;
|
||
AMsg.Result := DefWindowProc(FLazyWnd, AMsg.Msg, AMsg.WParam, AMsg.LParam);
|
||
end;
|
||
|
||
procedure TPMBridge.FireNewEntryRequest;
|
||
var
|
||
LTarget: HWND;
|
||
LTitle: string;
|
||
LLen: Integer;
|
||
begin
|
||
if not Assigned(FOnNewEntryHotkey) then Exit;
|
||
LTarget := GetForegroundWindow;
|
||
LTitle := '';
|
||
// The window we are about to cover is exactly the one to hand the
|
||
// foreground back to when we return to the tray.
|
||
CaptureForegroundOwner;
|
||
// From the tray the foreground can resolve to OUR own window — don't
|
||
// pre-fill the new entry with "Password Manager". Only capture a real
|
||
// external window's title; otherwise pass '' (blank title).
|
||
if not IsOwnProcessWindow(LTarget) then
|
||
begin
|
||
SetLength(LTitle, 512);
|
||
LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
|
||
SetLength(LTitle, LLen);
|
||
end;
|
||
FOnNewEntryHotkey(LTitle);
|
||
end;
|
||
|
||
procedure TPMBridge.FireQuickSearchRequest;
|
||
var
|
||
LTarget: HWND;
|
||
LTitle: string;
|
||
LLen: Integer;
|
||
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 not Assigned(FOnQuickSearchHotkey) then Exit;
|
||
LTarget := GetForegroundWindow;
|
||
CaptureForegroundOwner;
|
||
// Same trap as the autofill hotkeys: our own hidden window can be the
|
||
// foreground one. Report no target — the host then opens quick search in
|
||
// copy mode instead of offering to type into ourselves.
|
||
if IsOwnProcessWindow(LTarget) then LTarget := 0;
|
||
LTitle := '';
|
||
if LTarget <> 0 then
|
||
begin
|
||
SetLength(LTitle, 512);
|
||
LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
|
||
SetLength(LTitle, LLen);
|
||
end;
|
||
FOnQuickSearchHotkey(akPasswordOnly, LTarget, LTitle);
|
||
end;
|
||
|
||
procedure TPMBridge.ReloadLazyBar;
|
||
begin
|
||
PostMessage(FMsgWindow, WM_LAZY_RELOAD, 0, 0);
|
||
end;
|
||
|
||
procedure TPMBridge.RestoreLazyBar;
|
||
begin
|
||
if GetPref(LAZY_ON_KEY) = '1' then SetLazyBar(True);
|
||
end;
|
||
|
||
procedure TPMBridge.SetLazyBar(AOn: Boolean);
|
||
// Declarees AVANT les routines imbriquees, qui s'en servent : une routine
|
||
// imbriquee ne voit que les variables declarees au-dessus d'elle.
|
||
var
|
||
LWork, LRect: TRect;
|
||
LSaved: TArray<string>;
|
||
LX, LY, LPosX, LPosY: Integer;
|
||
LList: string;
|
||
LTip: HWND;
|
||
|
||
// BS_OWNERDRAW : le systeme garde le suivi de la souris et l'etat enfonce,
|
||
// nous ne reprenons que les couleurs. Un bouton standard resterait gris clair
|
||
// sur fond sombre, comme sur la capture qui a motive ce changement.
|
||
procedure MakeButton(AId: Integer; const ACaption, ATip: string;
|
||
AX, AWidth: Integer);
|
||
var
|
||
LBtn: HWND;
|
||
LTool: TToolInfo;
|
||
begin
|
||
LBtn := CreateWindow('BUTTON', PChar(ACaption),
|
||
WS_CHILD or WS_VISIBLE or BS_OWNERDRAW, AX, 5, AWidth, 28,
|
||
FLazyWnd, HMENU(AId), HInstance, nil);
|
||
if LBtn = 0 then Exit;
|
||
// Without this the button wears the 1995 system font.
|
||
SendMessage(LBtn, WM_SETFONT, WPARAM(GetStockObject(DEFAULT_GUI_FONT)), 1);
|
||
GLazyOldBtnProc := Pointer(SetWindowLongPtr(LBtn, GWLP_WNDPROC,
|
||
LONG_PTR(@LazyButtonProc)));
|
||
// UNE ICONE SANS MOT SE NOMME AU SURVOL. Info-bulle posee dans les deux
|
||
// modes : elle dit aussi ce que fait "Add" ou "Search" en texte.
|
||
if LTip <> 0 then
|
||
begin
|
||
FillChar(LTool, SizeOf(LTool), 0);
|
||
LTool.cbSize := SizeOf(LTool);
|
||
LTool.uFlags := TTF_IDISHWND or TTF_SUBCLASS;
|
||
LTool.hwnd := FLazyWnd;
|
||
LTool.uId := UINT_PTR(LBtn);
|
||
LTool.lpszText := PChar(ATip);
|
||
SendMessage(LTip, TTM_ADDTOOL, 0, LPARAM(@LTool));
|
||
end;
|
||
end;
|
||
|
||
// Un bouton du jeu choisi dans les reglages, pose a la suite des precedents.
|
||
procedure AddButton(const AKey: string; AId: Integer;
|
||
const ACaption, ATip: string; ATextWidth: Integer);
|
||
var
|
||
LW: Integer;
|
||
begin
|
||
if not (',' + LList + ',').Contains(',' + AKey + ',') then Exit;
|
||
LW := IfThen(FLazyIcons, 30, ATextWidth);
|
||
MakeButton(AId, ACaption, ATip, LX, LW);
|
||
Inc(LX, LW + 6);
|
||
end;
|
||
|
||
begin
|
||
if AOn = LazyBarVisible then Exit;
|
||
if AOn then SetPref(LAZY_ON_KEY, '1') else SetPref(LAZY_ON_KEY, '0');
|
||
|
||
if not AOn then
|
||
begin
|
||
DeallocateHWnd(FLazyWnd); // the child buttons go with their parent
|
||
FLazyWnd := 0;
|
||
if Assigned(FOnFillBarChanged) then FOnFillBarChanged();
|
||
Exit;
|
||
end;
|
||
|
||
FLazyWnd := AllocateHWnd(LazyWindowHandler);
|
||
if FLazyWnd = 0 then Exit;
|
||
SetWindowLong(FLazyWnd, GWL_EXSTYLE,
|
||
GetWindowLong(FLazyWnd, GWL_EXSTYLE) or
|
||
WS_EX_NOACTIVATE or WS_EX_TOPMOST or WS_EX_TOOLWINDOW or WS_EX_LAYERED);
|
||
|
||
FLazyAlphaPct := StrToIntDef(GetPref(LAZY_ALPHA_KEY), LAZY_ALPHA_DEFAULT);
|
||
if (FLazyAlphaPct < 25) or (FLazyAlphaPct > 100) then
|
||
FLazyAlphaPct := LAZY_ALPHA_DEFAULT;
|
||
|
||
FLazyIcons := GetPref(LAZY_ICONS_KEY) = '1';
|
||
FLazyClear := GetPref(LAZY_CLEAR_KEY) = '1';
|
||
LList := GetPref(LAZY_BTNS_KEY);
|
||
if LList = '' then LList := LAZY_BTNS_DEFAULT;
|
||
InitCommonControls;
|
||
LTip := CreateWindowEx(WS_EX_TOPMOST, TOOLTIPS_CLASS, nil,
|
||
WS_POPUP or TTS_ALWAYSTIP or TTS_NOPREFIX,
|
||
Integer(CW_USEDEFAULT), Integer(CW_USEDEFAULT),
|
||
Integer(CW_USEDEFAULT), Integer(CW_USEDEFAULT),
|
||
FLazyWnd, 0, HInstance, nil);
|
||
|
||
LX := 5;
|
||
AddButton('full', LAZY_ID_FULL, 'Full', 'Fill username and password', 56);
|
||
AddButton('pwd', LAZY_ID_PWD, 'Password', 'Fill the password only', 76);
|
||
AddButton('totp', LAZY_ID_TOTP, '2FA', 'Fill the 2FA code', 52);
|
||
AddButton('add', LAZY_ID_ADD, 'Add', 'New entry from the current window', 48);
|
||
AddButton('search', LAZY_ID_SEARCH, 'Search', 'Quick search', 60);
|
||
// Meme raison que le X : quand la barrette est allumee, elle est souvent la
|
||
// seule chose de l'application a l'ecran. Revenir au coffre ne doit pas
|
||
// demander de retrouver l'icone du tray.
|
||
AddButton('open', LAZY_ID_OPEN, 'Open', 'Open the vault', 56);
|
||
AddButton('settings', LAZY_ID_SETTINGS, 'Settings', 'Fill bar settings', 64);
|
||
// Fermer la barrette sans passer par le menu du tray : elle flotte au-dessus
|
||
// de tout, donc s'en debarrasser doit se faire la ou elle est.
|
||
MakeButton(LAZY_ID_CLOSE, 'X', 'Close the fill bar', LX, 24);
|
||
FLazyW := LX + 24 + 5;
|
||
|
||
// Bottom right of the WORK area, so it does not start life under the
|
||
// taskbar. From there the user drags it wherever the login form is.
|
||
if not SystemParametersInfo(SPI_GETWORKAREA, 0, @LWork, 0) then
|
||
LWork := Rect(0, 0, GetSystemMetrics(SM_CXSCREEN),
|
||
GetSystemMetrics(SM_CYSCREEN));
|
||
LX := LWork.Right - FLazyW - 24;
|
||
LY := LWork.Bottom - LAZY_H - 24;
|
||
|
||
// LA POSITION RETENUE, SI ELLE TOMBE ENCORE SUR UN ECRAN. Un ecran debranche
|
||
// depuis, ou une resolution changee, laisserait sinon la barrette hors du
|
||
// bureau - et elle est sans entree dans la barre des taches, donc perdue pour
|
||
// de bon : ni a cliquer, ni a ramener. MonitorFromRect le dit d'un mot.
|
||
LSaved := GetPref(LAZY_POS_KEY).Split([',']);
|
||
if (Length(LSaved) = 2) and TryStrToInt(LSaved[0], LPosX)
|
||
and TryStrToInt(LSaved[1], LPosY) then
|
||
begin
|
||
LRect := Rect(LPosX, LPosY, LPosX + FLazyW, LPosY + LAZY_H);
|
||
if MonitorFromRect(@LRect, MONITOR_DEFAULTTONULL) <> 0 then
|
||
begin
|
||
LX := LPosX;
|
||
LY := LPosY;
|
||
end;
|
||
end;
|
||
|
||
SetWindowPos(FLazyWnd, HWND_TOPMOST, LX, LY, FLazyW, LAZY_H,
|
||
SWP_NOACTIVATE or SWP_SHOWWINDOW);
|
||
|
||
// La souris est ailleurs a la seconde ou la barrette parait : elle nait donc
|
||
// au repos, et non pleine puis fondue une fraction de seconde plus tard.
|
||
FLazyHover := False;
|
||
ApplyLazyAlpha(False);
|
||
SetTimer(FLazyWnd, LAZY_TIMER_ID, LAZY_POLL_MS, nil);
|
||
if Assigned(FOnFillBarChanged) then FOnFillBarChanged();
|
||
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_LAZY_CLOSE then
|
||
begin
|
||
SetLazyBar(False);
|
||
Exit;
|
||
end
|
||
|
||
else if AMsg.Msg = WM_LAZY_RELOAD then
|
||
begin
|
||
if LazyBarVisible then
|
||
begin
|
||
SetLazyBar(False);
|
||
SetLazyBar(True);
|
||
end;
|
||
Exit;
|
||
end
|
||
|
||
else 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
|
||
FireNewEntryRequest
|
||
|
||
else if (AMsg.Msg = WM_HOTKEY) and (AMsg.WParam = QUICK_SEARCH_HOTKEY_ID) then
|
||
FireQuickSearchRequest
|
||
|
||
else if (AMsg.Msg = WM_HOTKEY) and Assigned(FOnAutofillRequest) and
|
||
((AMsg.WParam = AUTOFILL_HOTKEY_ID_FULL) or
|
||
(AMsg.WParam = AUTOFILL_HOTKEY_ID_PWDONLY) or
|
||
(AMsg.WParam = AUTOFILL_HOTKEY_ID_TOTP)) 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 if AMsg.WParam = AUTOFILL_HOTKEY_ID_TOTP then
|
||
LKind := akTotp
|
||
else
|
||
LKind := akFull;
|
||
FireAutofillRequest(LKind);
|
||
end;
|
||
|
||
AMsg.Result := DefWindowProc(FMsgWindow, AMsg.Msg, AMsg.WParam, AMsg.LParam);
|
||
end;
|
||
|
||
// =============================================================================
|
||
// TPMBridge — Autofill hotkey + SendInput
|
||
// =============================================================================
|
||
|
||
function TPMBridge.SetAutofillHotkeys(AFullMods, AFullVk,
|
||
APwdMods, APwdVk, ATotpMods, ATotpVk: 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;
|
||
if FAutofillTotpActive then
|
||
begin
|
||
UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_TOTP);
|
||
FAutofillTotpActive := 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);
|
||
if (ATotpVk <> 0) and (ATotpMods <> 0) then
|
||
FAutofillTotpActive := RegisterHotKey(FMsgWindow,
|
||
AUTOFILL_HOTKEY_ID_TOTP, ATotpMods, ATotpVk);
|
||
|
||
FAutofillRegistered := FAutofillFullActive or FAutofillPwdActive
|
||
or FAutofillTotpActive;
|
||
// An unconfigured 2FA combo (vk=0) must not report a registration failure.
|
||
Result := FAutofillFullActive and FAutofillPwdActive
|
||
and (FAutofillTotpActive or (ATotpVk = 0));
|
||
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;
|
||
|
||
function TPMBridge.SetNewEntryHotkey(AMods, AVk: Word): Boolean;
|
||
begin
|
||
if FNewEntryHotkeyRegistered then
|
||
begin
|
||
UnregisterHotKey(FMsgWindow, NEW_ENTRY_HOTKEY_ID);
|
||
FNewEntryHotkeyRegistered := False;
|
||
end;
|
||
if (AVk <> 0) and (AMods <> 0) then
|
||
begin
|
||
FNewEntryHotkeyMods := AMods;
|
||
FNewEntryHotkeyVk := AVk;
|
||
FNewEntryHotkeyRegistered := RegisterHotKey(FMsgWindow,
|
||
NEW_ENTRY_HOTKEY_ID, AMods, AVk);
|
||
end;
|
||
Result := FNewEntryHotkeyRegistered;
|
||
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
|
||
FDarkTheme := ADark;
|
||
// La barrette ne suit aucun theme systeme : c'est nous qui la peignons, donc
|
||
// c'est nous qui la redessinons quand la page bascule.
|
||
if FLazyWnd <> 0 then
|
||
RedrawWindow(FLazyWnd, nil, 0,
|
||
RDW_INVALIDATE or RDW_ERASE or RDW_ALLCHILDREN or RDW_UPDATENOW);
|
||
|
||
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 defaults — Ctrl+Shift+L (full), Ctrl+Shift+P (password),
|
||
// Ctrl+Shift+T (2FA code).
|
||
// 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'),
|
||
AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('T'));
|
||
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;
|
||
if FAutofillTotpActive then
|
||
begin
|
||
UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_TOTP);
|
||
FAutofillTotpActive := 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
|
||
// 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);
|
||
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;
|
||
|
||
// LES DEUX SEULES ATTENTES QUI PEUVENT ENFLER, mesurees separement.
|
||
//
|
||
// Le total d'un remplissage est d'environ 660 ms par construction - des
|
||
// Sleep fixes. Quand il depasse plusieurs secondes, c'est forcement l'une de
|
||
// ces deux-la : la prise du premier plan, qui sonde jusqu'a 600 ms, ou
|
||
// l'attente de la relache des modificateurs, jusqu'a 1000 ms. Les confondre
|
||
// ferait chercher du cote du moteur web, qui n'y est pour rien.
|
||
var LSw := TStopwatch.StartNew;
|
||
if ATargetHWND <> 0 then
|
||
ForceForegroundWindow(ATargetHWND);
|
||
var LFgMs := LSw.ElapsedMilliseconds;
|
||
|
||
LSw := TStopwatch.StartNew;
|
||
WaitForModifierRelease(1000);
|
||
ForceReleaseModifiers; // timeout hit with keys still down → clean state
|
||
FLastFillWaits := Format('foreground=%d ms, modifiers=%d ms',
|
||
[LFgMs, LSw.ElapsedMilliseconds]);
|
||
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;
|
||
|
||
// Our own new-entry hotkey is Ctrl+Shift+A. The clear-field step injects a
|
||
// real Ctrl+A; if the user still physically holds Ctrl+Shift (they just
|
||
// pressed the autofill hotkey, and auto-repeat re-presses the modifiers
|
||
// faster than ForceReleaseModifiers can clear them), the system sees
|
||
// Ctrl+Shift+A and fires new-entry mid-fill. Unregister it for the duration
|
||
// of the SendInput so no WM_HOTKEY is generated; re-register in finally.
|
||
UnregisterHotKey(FMsgWindow, NEW_ENTRY_HOTKEY_ID);
|
||
try
|
||
// 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);
|
||
// Real-VK send: a physically held Shift here turns Tab into Shift+Tab —
|
||
// focus moves BACKWARD and the password lands in the username field.
|
||
ForceReleaseModifiers;
|
||
SendVKey(VK_TAB);
|
||
Sleep(200);
|
||
// No clear here: tabbing INTO an input already selects its whole content,
|
||
// so typing replaces it. Sending Ctrl+A as well is not just redundant — when
|
||
// Tab lands somewhere that is not an editable field (a link, a reveal
|
||
// button, the document itself, as on the Reddit login form) it selects the
|
||
// ENTIRE PAGE, and the password is then typed into nothing.
|
||
SendUnicodeString(APassword);
|
||
finally
|
||
// Re-register with the LIVE combo (user may have rebound it), not the
|
||
// hardcoded default — otherwise a fill would reset new-entry to Ctrl+Shift+A.
|
||
if FNewEntryHotkeyRegistered then
|
||
RegisterHotKey(FMsgWindow, NEW_ENTRY_HOTKEY_ID,
|
||
FNewEntryHotkeyMods, FNewEntryHotkeyVk);
|
||
end;
|
||
end;
|
||
|
||
initialization
|
||
// Runs before Application.Initialize, so no window of ours exists yet and the
|
||
// answer is genuinely "the application the user launched us from".
|
||
GStartupForeground := GetForegroundWindow;
|
||
|
||
end.
|