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
+12
View File
@@ -316,6 +316,18 @@ l'éditeur slideover : créer + éditer une entrée, ajouter/retirer des tags,
- **Variantes** : match unique (pas de picker) → reste au tray ; app déjà visible → reste visible ; app **minimisée dans la barre des tâches** → n'est **pas** envoyée au tray (`not Self.Visible` est faux pour une fenêtre iconique) ; picker fermé par Esc sans choisir → l'app **repart au tray** elle aussi (symétrique de quick-search, qui se cache sur Esc) et le hotkey suivant n'hérite pas de l'état capturé. - **Variantes** : match unique (pas de picker) → reste au tray ; app déjà visible → reste visible ; app **minimisée dans la barre des tâches** → n'est **pas** envoyée au tray (`not Self.Visible` est faux pour une fenêtre iconique) ; picker fermé par Esc sans choisir → l'app **repart au tray** elle aussi (symétrique de quick-search, qui se cache sur Esc) et le hotkey suivant n'hérite pas de l'état capturé.
- **Historique** : seul le chemin quick-search capturait « était cachée » (`LWasHidden`) et le faisait remonter via `hide_after` ; JS n'envoie jamais ce paramètre depuis le picker d'autofill, donc l'app restait sur le bureau. Capturé désormais côté Delphi dans `BridgeAutofillRequest`, au moment du hotkey — après, le picker a déjà détruit l'information. - **Historique** : seul le chemin quick-search capturait « était cachée » (`LWasHidden`) et le faisait remonter via `hide_after` ; JS n'envoie jamais ce paramètre depuis le picker d'autofill, donc l'app restait sur le bureau. Capturé désormais côté Delphi dans `BridgeAutofillRequest`, au moment du hotkey — après, le picker a déjà détruit l'information.
### H10 · le focus revient à l'application précédente ✅
Cacher une fenêtre ne transfère le premier plan à personne (contrairement à une
minimisation). On restait donc au premier plan **en étant invisible** : le
hotkey suivant capturait **notre propre fenêtre** comme cible et tapait le mot
de passe dedans, et `Ctrl+Shift+A` ouvrait un « New entry » au titre vide (le
garde refusait, à raison, de pré-remplir avec « Password Manager »).
- **ACTION** : depuis une application X → ouvrir l'app (icône du tray, hotkey, ou lancement à froid) → fermer par la croix.
- **ATTENDU** : X reprend le focus. Puis un hotkey d'autofill depuis X doit cibler X, et `Ctrl+Shift+A` pré-remplir son titre.
- **ACTION 2** : enchaîner plusieurs allers-retours en changeant d'application entre deux — c'est **toujours la dernière réellement utilisée** qui récupère le focus.
- **Non-régression** : après un autofill (pick ou Esc), le focus reste sur la fenêtre **remplie** — ces deux chemins passent `AReturnForeground = False`, ayant déjà placé le premier plan eux-mêmes.
- **Historique — quatre tentatives, toutes tranchées par le log** : (1) rendre le focus *après* avoir caché la fenêtre — Windows n'autorise que le propriétaire courant du premier plan à le céder, l'appel était silencieusement refusé ; (2) échantillonner le premier plan au moment du besoin — il vaut alors *notre* fenêtre, la capture se rejetait elle-même et n'apprenait jamais rien (`prev=<none>` à chaque fois) ; (3) capturer au clic sur l'icône du tray — on obtient une fenêtre du **shell** (`NotifyIconOverflowWindow`, « Fenêtre de dépassement de capacité de la barre d'état système ») ; (4) consommer la valeur après usage — le premier masquage marchait, les suivants plus. **Solution** : un hook `EVENT_SYSTEM_FOREGROUND` (`WINEVENT_OUTOFCONTEXT`, aucune injection) note *chaque* changement et garde le dernier qui n'est ni à nous ni au shell ; la restitution se fait **avant** de cacher.
### H7 · combo déjà pris par une autre application ✅ ### H7 · combo déjà pris par une autre application ✅
- **ACTION** : assigner à un hotkey un combo qu'une autre app tient déjà en global (ex. `Ctrl+Shift+Q` si Quick Assist / un lanceur l'occupe ; sinon, tester en assignant le même combo dans deux apps). - **ACTION** : assigner à un hotkey un combo qu'une autre app tient déjà en global (ex. `Ctrl+Shift+Q` si Quick Assist / un lanceur l'occupe ; sinon, tester en assignant le même combo dans deux apps).
- **ATTENDU** : toast **nommant le combo refusé** — « Already used by another app: 2FA code — pick another combo » — **et** un ⚠ ambre persistant sur cette ligne de Settings, **et** une pastille ambre sur l'entrée *Settings* de la sidebar (seul indice hors du panneau). Le toast d'avertissement reste ~10 s et se ferme au clic. Les **autres** hotkeys restent fonctionnels (enregistrement best-effort, pas de tout-ou-rien). - **ATTENDU** : toast **nommant le combo refusé** — « Already used by another app: 2FA code — pick another combo » — **et** un ⚠ ambre persistant sur cette ligne de Settings, **et** une pastille ambre sur l'entrée *Settings* de la sidebar (seul indice hors du panneau). Le toast d'avertissement reste ~10 s et se ferme au clic. Les **autres** hotkeys restent fonctionnels (enregistrement best-effort, pas de tout-ou-rien).
+153 -5
View File
@@ -101,6 +101,12 @@ type
// (maximised / normal + position + size) as before hiding. // (maximised / normal + position + size) as before hiding.
FSavedPlacement: TWindowPlacement; FSavedPlacement: TWindowPlacement;
FHasSavedPlacement: Boolean; 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; FOnSystemLock: TProc;
// Set TRUE the moment Windows tells us the session is ending // Set TRUE the moment Windows tells us the session is ending
// (WM_QUERYENDSESSION / WM_ENDSESSION). FormCloseQuery checks this // (WM_QUERYENDSESSION / WM_ENDSESSION). FormCloseQuery checks this
@@ -155,7 +161,14 @@ type
// True (a manual minimise wipes any copied password immediately); // True (a manual minimise wipes any copied password immediately);
// the quick-search "copy then hide so I can paste" flow passes False // 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. // 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. // Restore main window and remove tray icon.
procedure RestoreFromTray; procedure RestoreFromTray;
// Apply Windows dark-mode title bar to the main form. Win10 19044+ // Apply Windows dark-mode title bar to the main form. Win10 19044+
@@ -264,6 +277,40 @@ implementation
uses uses
FMX.Platform.Win; 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. // Win32 format name that suppresses Win+V clipboard history recording.
// Introduced in Windows 10 1809 (build 17763). Silently ignored on older builds. // Introduced in Windows 10 1809 (build 17763). Silently ignored on older builds.
const const
@@ -476,6 +523,15 @@ begin
FTrayAdded := False; FTrayAdded := False;
FBalloonShown := False; FBalloonShown := False;
FShowNotifications := True; // default on; JS pushes user pref on load 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. // Dedicated message-only window for tray + WTS notifications.
FMsgWindow := AllocateHWnd(MsgWindowHandler); FMsgWindow := AllocateHWnd(MsgWindowHandler);
@@ -518,6 +574,9 @@ end;
destructor TPMBridge.Destroy; destructor TPMBridge.Destroy;
begin begin
if FForegroundHook <> 0 then
UnhookWinEvent(FForegroundHook);
if FDebugHotkeyRegistered then if FDebugHotkeyRegistered then
UnregisterHotKey(FMsgWindow, DEBUG_HOTKEY_ID); UnregisterHotKey(FMsgWindow, DEBUG_HOTKEY_ID);
@@ -612,7 +671,25 @@ begin
until LWnd = 0; until LWnd = 0;
end; 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 var
LFormHwnd, LAppHwnd: HWND; LFormHwnd, LAppHwnd: HWND;
begin begin
@@ -642,6 +719,16 @@ begin
FSavedPlacement.length := SizeOf(FSavedPlacement); FSavedPlacement.length := SizeOf(FSavedPlacement);
FHasSavedPlacement := GetWindowPlacement(LFormHwnd, @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. // 1. Hide the visible form via both FMX state and Win32 ShowWindow.
// Keeps the form invisible to the user. // Keeps the form invisible to the user.
FMainForm.Hide; FMainForm.Hide;
@@ -703,6 +790,8 @@ begin
LFormHwnd := MainFormHWND(FMainForm); LFormHwnd := MainFormHWND(FMainForm);
LAppHwnd := FindFMXAppWindow; LAppHwnd := FindFMXAppWindow;
CaptureForegroundOwner;
// Already visible and not minimised (quick-search / focus hotkey while the // Already visible and not minimised (quick-search / focus hotkey while the
// window is open): just bring it to front. Re-applying FSavedPlacement — // window is open): just bring it to front. Re-applying FSavedPlacement —
// captured at the LAST MinimizeToTray — would teleport the window to a // captured at the LAST MinimizeToTray — would teleport the window to a
@@ -824,6 +913,40 @@ begin
end; end;
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); procedure TPMBridge.MsgWindowHandler(var AMsg: TMessage);
var var
LMouseEvent: Word; LMouseEvent: Word;
@@ -897,12 +1020,13 @@ begin
begin begin
var LTarget := GetForegroundWindow; var LTarget := GetForegroundWindow;
var LTitle: string := ''; var LTitle: string := '';
var LPid: DWORD := 0; // The window we are about to cover is exactly the one to hand the
GetWindowThreadProcessId(LTarget, LPid); // foreground back to when we return to the tray.
CaptureForegroundOwner;
// From the tray the foreground can resolve to OUR own window — don't // 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 // pre-fill the new entry with "Password Manager". Only capture a real
// external window's title; otherwise pass '' (blank title). // external window's title; otherwise pass '' (blank title).
if (LTarget <> 0) and (LPid <> GetCurrentProcessId) then if not IsOwnProcessWindow(LTarget) then
begin begin
SetLength(LTitle, 512); SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512); var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
@@ -920,10 +1044,18 @@ begin
if Assigned(FOnQuickSearchHotkey) then if Assigned(FOnQuickSearchHotkey) then
begin begin
var LTarget := GetForegroundWindow; 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; var LTitle: string;
if LTarget <> 0 then
begin
SetLength(LTitle, 512); SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512); var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
SetLength(LTitle, LLen); SetLength(LTitle, LLen);
end;
FOnQuickSearchHotkey(akPasswordOnly, LTarget, LTitle); FOnQuickSearchHotkey(akPasswordOnly, LTarget, LTitle);
end; end;
end end
@@ -943,6 +1075,17 @@ begin
else else
LKind := akFull; LKind := akFull;
var LTarget := GetForegroundWindow; 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; var LTitle: string;
SetLength(LTitle, 512); SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512); var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
@@ -1450,4 +1593,9 @@ begin
end; end;
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. end.
+9 -4
View File
@@ -477,10 +477,13 @@ begin
// focus has to be on the browser control first. // focus has to be on the browser control first.
WebBrowser.SetFocus; WebBrowser.SetFocus;
LWasHiddenJs := BoolToStr(LWasHidden, True).ToLower; LWasHiddenJs := BoolToStr(LWasHidden, True).ToLower;
// Second arg forFill=true tells JS to SendInput on pick instead of copy. // Second arg forFill tells JS to SendInput on pick instead of copy. The bridge
// reports HWND 0 when the foreground was our own hidden window: there is
// nothing to type into, so offer copy instead of a fill that goes nowhere.
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.openQuickSearch==="function")' + 'if(window.Bridge&&typeof Bridge.openQuickSearch==="function")' +
'Bridge.openQuickSearch(' + LWasHiddenJs + ',true)'); 'Bridge.openQuickSearch(' + LWasHiddenJs + ',' +
BoolToStr(ATargetHWND <> 0, True).ToLower + ')');
LogLine(Format('Quick-search hotkey — target=%s, title="%s"', LogLine(Format('Quick-search hotkey — target=%s, title="%s"',
[IntToHex(ATargetHWND, 8), ATitle])); [IntToHex(ATargetHWND, 8), ATitle]));
end; end;
@@ -1093,7 +1096,8 @@ begin
if FAutofillHotkeyWasHidden then if FAutofillHotkeyWasHidden then
begin begin
FAutofillHotkeyWasHidden := False; FAutofillHotkeyWasHidden := False;
FBridge.MinimizeToTray; // Foreground already handed to the target above.
FBridge.MinimizeToTray(True, False);
end; end;
LogLine('Autofill cancelled (no match or dismissed)'); LogLine('Autofill cancelled (no match or dismissed)');
end end
@@ -1777,7 +1781,8 @@ begin
// confusing the Win10/11 foreground-stealing watchdog. // confusing the Win10/11 foreground-stealing watchdog.
if HideAfter then if HideAfter then
begin begin
FBridge.MinimizeToTray; // The filled window must keep the foreground we just handed it.
FBridge.MinimizeToTray(True, False);
LogLine('Hidden back to tray (post-autofill).'); LogLine('Hidden back to tray (post-autofill).');
end; end;
end; end;