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;