fix(tray): give the foreground back to the application we took it from

Hiding a window hands the foreground to nobody, so we stayed foreground while
invisible. The next hotkey then captured our own window as its target and typed
the password into it, and Ctrl+Shift+A opened a New entry with an empty title —
the guard correctly refusing to prefill with "Password Manager".

Track foreground changes with an EVENT_SYSTEM_FOREGROUND hook and keep the last
window that is neither ours nor a shell one, then reactivate it just before
hiding. Sampling on demand cannot work: by then the user's application is two
foreground changes behind (their app, the taskbar they clicked, us), and
reactivating after the hide is silently refused since only the current
foreground owner may give focus away.

Autofill paths pass AReturnForeground=False — they already placed the
foreground on the window they filled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-27 23:10:22 +01:00
parent a9e56c52b5
commit 33d8df3dee
3 changed files with 177 additions and 12 deletions
+156 -8
View File
@@ -101,6 +101,12 @@ type
// (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
@@ -155,7 +161,14 @@ type
// True (a manual minimise wipes any copied password immediately);
// the quick-search "copy then hide so I can paste" flow passes False
// so it doesn't nuke the password it just placed on the clipboard.
procedure MinimizeToTray(AClearClipboard: Boolean = True);
// 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);
// 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+
@@ -264,6 +277,40 @@ implementation
uses
FMX.Platform.Win;
// 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
@@ -476,6 +523,15 @@ begin
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);
@@ -518,6 +574,9 @@ end;
destructor TPMBridge.Destroy;
begin
if FForegroundHook <> 0 then
UnhookWinEvent(FForegroundHook);
if FDebugHotkeyRegistered then
UnregisterHotKey(FMsgWindow, DEBUG_HOTKEY_ID);
@@ -612,7 +671,25 @@ begin
until LWnd = 0;
end;
procedure TPMBridge.MinimizeToTray(AClearClipboard: Boolean = True);
// 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);
var
LFormHwnd, LAppHwnd: HWND;
begin
@@ -642,6 +719,16 @@ begin
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;
if AReturnForeground and (FPrevForeground <> 0) and IsWindow(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;
@@ -703,6 +790,8 @@ begin
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
@@ -824,6 +913,40 @@ begin
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;
procedure TPMBridge.MsgWindowHandler(var AMsg: TMessage);
var
LMouseEvent: Word;
@@ -897,12 +1020,13 @@ begin
begin
var LTarget := GetForegroundWindow;
var LTitle: string := '';
var LPid: DWORD := 0;
GetWindowThreadProcessId(LTarget, LPid);
// 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 (LTarget <> 0) and (LPid <> GetCurrentProcessId) then
if not IsOwnProcessWindow(LTarget) then
begin
SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
@@ -920,10 +1044,18 @@ begin
if Assigned(FOnQuickSearchHotkey) then
begin
var 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;
var LTitle: string;
SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
SetLength(LTitle, LLen);
if LTarget <> 0 then
begin
SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
SetLength(LTitle, LLen);
end;
FOnQuickSearchHotkey(akPasswordOnly, LTarget, LTitle);
end;
end
@@ -943,6 +1075,17 @@ begin
else
LKind := akFull;
var LTarget := GetForegroundWindow;
CaptureForegroundOwner;
// Hiding to the tray does not hand the foreground to anyone else, so ours
// stays foreground while invisible: from there the hotkey 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, then press the shortcut.', True);
AMsg.Result := DefWindowProc(FMsgWindow, AMsg.Msg, AMsg.WParam, AMsg.LParam);
Exit;
end;
var LTitle: string;
SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
@@ -1450,4 +1593,9 @@ begin
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.