From 4a78dc2e905fae2e0b0b51f00a33b5251d09619d Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:03:03 +0100 Subject: [PATCH] fix(hotkeys): find the native dialog by enumerating the thread windows GetLastActivePopup returns the form itself - the common Save dialog is not an owned popup - so the code that raises it never ran. It does live on our thread with the standard #32770 class, which EnumThreadWindows finds regardless of its owner. Logging confirmed two things worth keeping: WM_HOTKEY is dispatched during the dialog modal loop, and IsWindowEnabled detects the state correctly. --- TEST_SCENARIOS.md | 4 +++- delphi-backend/UMainForm.pas | 28 +++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/TEST_SCENARIOS.md b/TEST_SCENARIOS.md index 7d573f2..5c3a784 100644 --- a/TEST_SCENARIOS.md +++ b/TEST_SCENARIOS.md @@ -185,7 +185,9 @@ plus bas. - **Deuxième cause, côté Delphi** : `Ctrl+Shift+Q` et `Ctrl+Shift+A` appellent `RestoreWindowFromTray` **avant** que JS ne puisse refuser — un garde JS arrive trop tard par construction. Restaurer la fenêtre propriétaire d'une modale native laisse le dialogue en rade derrière tout le reste : l'app semble gelée alors qu'elle est simplement *désactivée* par une boîte devenue introuvable (au point qu'il fallait un utilitaire de capture d'écran pour la faire réapparaître). - **Garde** : `NativeModalActive` teste `IsWindowEnabled` sur notre fenêtre — Windows la désactive tant qu'une modale native lui appartient. Posé en tête des **quatre** points d'entrée hotkey (quick search hotkey + menu tray, new entry, autofill). Il ne se contente pas de refuser : il **ramène le dialogue au premier plan** via `GetLastActivePopup`, ce qui est précisément ce dont l'utilisateur a besoin. - **ATTENDU après correctif** : pendant l'« Enregistrer sous », n'importe quel hotkey **fait réapparaître le dialogue au premier plan, avec le focus**, et une **notification tray** explique que les raccourcis restent inactifs tant qu'il n'a pas répondu. -- **Piège du focus** : `SetForegroundWindow` est **refusé** à un processus qui n'est pas déjà devant, et le hotkey vient d'une autre application. Il faut attacher notre file d'entrées au thread de la **fenêtre de premier plan** (VS Code). Réutiliser le helper d'autofill ne marche **pas** : il attache au thread de la *cible*, or le dialogue appartient à notre propre thread — l'attache est un no-op et le dialogue remontait sans jamais recevoir le focus. +- **Piège n°1, localiser le dialogue** : `GetLastActivePopup` renvoie **le formulaire lui-même** — la boîte « Enregistrer sous » n'est pas une popup possédée par notre fenêtre. Elle vit en revanche sur notre thread avec la classe standard `#32770`, donc `EnumThreadWindows` la trouve quel que soit son propriétaire. Mesuré : sans ça le bloc de remontée ne s'exécutait jamais, silencieusement. +- **Piège n°2, le focus** : `SetForegroundWindow` est **refusé** à un processus qui n'est pas déjà devant, et le hotkey vient d'une autre application. Il faut attacher notre file d'entrées au thread de la **fenêtre de premier plan** (VS Code). Réutiliser le helper d'autofill ne marche **pas** : il attache au thread de la *cible*, or le dialogue appartient à notre propre thread — l'attache est un no-op et le dialogue remontait sans jamais recevoir le focus. +- **Vérifié au log** : le `WM_HOTKEY` **est** bien distribué pendant la boucle modale du dialogue, et `IsWindowEnabled` détecte correctement l'état. Ces deux points étaient les hypothèses coûteuses à écarter avant de chercher ailleurs. - **Piège du message** : une **balloon tray**, pas un toast — notre fenêtre est désactivée et derrière le dialogue, rien de rendu dans la page ne serait visible. Elle suit le réglage « alertes tray » : désactivé, tu auras le focus sans le message. ### B14 · dialogues natifs ouverts par un clic programmatique diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index b61dd30..47af1c2 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -201,6 +201,11 @@ implementation {$R *.fmx} +// Result slot for EnumOwnDialogProc — the enumeration is main-thread only and +// consumed immediately, so a unit variable is enough. +var + GFoundDialogHwnd: HWND = 0; + // Forward — used by WebBrowserInitialized (which sits above the actual // definition lower in the unit). function MaskAccessToken(const AUrl: string): string; forward; @@ -503,6 +508,24 @@ end; // the user can no longer find. Every hotkey entry point checks this first — and // rather than just refusing, it surfaces the dialog, which is what the user // actually needs. +// The common Save/Open dialog is NOT an owned popup of the form — +// GetLastActivePopup returns the form itself — but it does live on our thread +// and carries the standard dialog class. Enumerating the thread's windows finds +// it whoever its owner turns out to be. +function EnumOwnDialogProc(AWnd: HWND; AParam: LPARAM): BOOL; stdcall; +var + LCls: array[0..63] of Char; +begin + Result := True; // keep enumerating + if (AWnd = HWND(AParam)) or (not IsWindowVisible(AWnd)) then Exit; + if GetClassName(AWnd, LCls, Length(LCls)) = 0 then Exit; + if SameText(LCls, '#32770') then + begin + GFoundDialogHwnd := AWnd; + Result := False; // stop + end; +end; + function TMainForm.NativeModalActive: Boolean; var LHwnd, LPopup: HWND; @@ -510,7 +533,10 @@ begin LHwnd := WindowHandleToPlatform(Self.Handle).Wnd; Result := (LHwnd <> 0) and (not IsWindowEnabled(LHwnd)); if not Result then Exit; - LPopup := GetLastActivePopup(LHwnd); + GFoundDialogHwnd := 0; + EnumThreadWindows(GetCurrentThreadId, @EnumOwnDialogProc, LPARAM(LHwnd)); + LPopup := GFoundDialogHwnd; + if LPopup = 0 then LPopup := GetLastActivePopup(LHwnd); // fallback if (LPopup <> 0) and (LPopup <> LHwnd) and IsWindow(LPopup) then begin ShowWindow(LPopup, SW_SHOW);