From 33d8df3deeed06de16e901177532def4b5e70894 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:10:22 +0100 Subject: [PATCH] fix(tray): give the foreground back to the application we took it from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- TEST_SCENARIOS.md | 12 ++ delphi-backend/Source/PM.Bridge.pas | 164 ++++++++++++++++++++++++++-- delphi-backend/UMainForm.pas | 13 ++- 3 files changed, 177 insertions(+), 12 deletions(-) diff --git a/TEST_SCENARIOS.md b/TEST_SCENARIOS.md index 91aba4f..4c55279 100644 --- a/TEST_SCENARIOS.md +++ b/TEST_SCENARIOS.md @@ -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é. - **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=` à 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 ✅ - **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). diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index bf7607d..c0f4585 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -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. diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index c8789d0..b6922a7 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -477,10 +477,13 @@ begin // focus has to be on the browser control first. WebBrowser.SetFocus; 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( '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"', [IntToHex(ATargetHWND, 8), ATitle])); end; @@ -1093,7 +1096,8 @@ begin if FAutofillHotkeyWasHidden then begin FAutofillHotkeyWasHidden := False; - FBridge.MinimizeToTray; + // Foreground already handed to the target above. + FBridge.MinimizeToTray(True, False); end; LogLine('Autofill cancelled (no match or dismissed)'); end @@ -1777,7 +1781,8 @@ begin // confusing the Win10/11 foreground-stealing watchdog. if HideAfter then 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).'); end; end;