fix(startup): use ApplicationHWND + WS_EX_TOOLWINDOW, restore it on show

Adopts the approach that actually works: ApplicationHWND (FMX.Platform.Win) IS
the taskbar proxy, so there is nothing to hunt for and it is valid before the
form exists. And hiding it was never enough — the taskbar had already drawn
the button; clearing WS_EX_APPWINDOW and setting WS_EX_TOOLWINDOW is what
removes it, since a tool window is never listed.

That style is permanent, which is why the button never came back after a
restore. TrayProxyShowInTaskbar puts it back, called from
RestoreWindowFromTray — the single choke point every tray restore already goes
through — so it lands before RestoreFromTray shows the windows and the style
takes effect on that show.

Removes the scaffolding this chase accumulated and that the real fix makes
pointless: the EVENT_OBJECT_SHOW hook, the polling worker, FindOurFMXAppWindow,
TrayHideNow with its four probes, the one-shot OnActivate hide, and the [tray]
tracing. ~4.8 KB of dead code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-27 14:06:19 +01:00
parent ab8186ef75
commit 04f52851fc
4 changed files with 29 additions and 153 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
program PMServer; program PMServer;
uses uses
System.SysUtils, // FindCmdLineSwitch System.SysUtils, // FindCmdLineSwitch
+1
View File
@@ -204,6 +204,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
<Icon_MainIcon>PMServer_Icon1.ico</Icon_MainIcon> <Icon_MainIcon>PMServer_Icon1.ico</Icon_MainIcon>
<UWP_DelphiLogo44>app.png</UWP_DelphiLogo44> <UWP_DelphiLogo44>app.png</UWP_DelphiLogo44>
<UWP_DelphiLogo150>app.png</UWP_DelphiLogo150> <UWP_DelphiLogo150>app.png</UWP_DelphiLogo150>
<Debugger_RunParams>-tray</Debugger_RunParams>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<DelphiCompile Include="$(MainSource)"> <DelphiCompile Include="$(MainSource)">
-6
View File
@@ -127,10 +127,4 @@ object MainForm: TMainForm
Left = 424 Left = 424
Top = 240 Top = 240
end end
object Timer1: TTimer
Interval = 1
OnTimer = Timer1Timer
Left = 568
Top = 184
end
end end
+25 -144
View File
@@ -64,7 +64,6 @@ type
Memo: TMemo; Memo: TMemo;
Splitter: TSplitter; Splitter: TSplitter;
PopupMenu1: TPopupMenu; PopupMenu1: TPopupMenu;
Timer1: TTimer;
procedure FormCreate(Sender: TObject); procedure FormCreate(Sender: TObject);
procedure FormDestroy(Sender: TObject); procedure FormDestroy(Sender: TObject);
procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean); procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean);
@@ -72,7 +71,6 @@ type
procedure btnStopClick(Sender: TObject); procedure btnStopClick(Sender: TObject);
procedure btnToggleLogClick(Sender: TObject); procedure btnToggleLogClick(Sender: TObject);
procedure btnReloadClick(Sender: TObject); procedure btnReloadClick(Sender: TObject);
procedure Timer1Timer(Sender: TObject);
private private
// Windows minimising the window from the outside (Win+D / Show desktop / // Windows minimising the window from the outside (Win+D / Show desktop /
// taskbar) makes FMX forget it was maximised: the window comes back // taskbar) makes FMX forget it was maximised: the window comes back
@@ -86,11 +84,6 @@ type
// restore's resize is indistinguishable from a user gesture by placement // restore's resize is indistinguishable from a user gesture by placement
// flags or foreground ownership — both were measured and rejected). // flags or foreground ownership — both were measured and rejected).
FWinEventHook: THandle; FWinEventHook: THandle;
// --tray only: catches EVENT_OBJECT_SHOW so any top-level window of ours
// is hidden the instant Windows shows it. The FMX taskbar proxy is created
// partway through FormCreate — it does not exist at the first hide points
// and was therefore visible for the best part of a second.
FShowEventHook: THandle;
// True from "minimise starts" until just after the restore settles, so the // True from "minimise starts" until just after the restore settles, so the
// resizes FMX fires while restoring are not recorded as user intent. // resizes FMX fires while restoring are not recorded as user intent.
FIgnoreResizes: Boolean; FIgnoreResizes: Boolean;
@@ -180,10 +173,6 @@ type
// stuck raised (see the implementation). // stuck raised (see the implementation).
// One-shot: hides the window the first time FMX shows it on a --tray // One-shot: hides the window the first time FMX shows it on a --tray
// launch, then unhooks itself. // launch, then unhooks itself.
// Hides the form AND the FMX taskbar proxy right now, logging whether the
// window was actually visible — that is what pins down when FMX shows it.
procedure TrayHideNow(const ATag: string);
procedure TrayStartupHideOnce(Sender: TObject);
procedure RestoreWindowFromTray; procedure RestoreWindowFromTray;
procedure WinStateMinimizeStart; procedure WinStateMinimizeStart;
procedure WinStateMinimizeEnd; procedure WinStateMinimizeEnd;
@@ -210,11 +199,6 @@ function MaskAccessToken(const AUrl: string): string; forward;
// ---- WinEvent hook plumbing (see the maximised-state block further down) -- // ---- WinEvent hook plumbing (see the maximised-state block further down) --
const const
EVENT_OBJECT_SHOW = $8002;
// Synchronous delivery, in our own process. OUTOFCONTEXT would queue the
// callback until the thread pumps messages — which does not happen during
// FormCreate, exactly when we need to react.
WINEVENT_INCONTEXT = $0004;
EVENT_SYSTEM_MINIMIZESTART = $0016; EVENT_SYSTEM_MINIMIZESTART = $0016;
EVENT_SYSTEM_MINIMIZEEND = $0017; EVENT_SYSTEM_MINIMIZEEND = $0017;
WINEVENT_OUTOFCONTEXT = $0000; WINEVENT_OUTOFCONTEXT = $0000;
@@ -226,66 +210,39 @@ function SetWinEventHook(eventMin, eventMax: DWORD; hmodWinEventProc: HMODULE;
function UnhookWinEvent(hWinEventHook: THandle): BOOL; function UnhookWinEvent(hWinEventHook: THandle): BOOL;
stdcall; external 'user32.dll' name 'UnhookWinEvent'; stdcall; external 'user32.dll' name 'UnhookWinEvent';
// Defined lower down, next to the window-state logic it serves.
// Unit-level, NOT a form field: Application.CreateForm only assigns MainForm
// after the constructor returns, so during FormCreate the global is still nil
// and the callback could not reach the form at all.
var
GTrayStartupPending: Boolean = False;
procedure WinStateEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND; procedure WinStateEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall; forward; idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall; forward;
procedure TrayShowEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall; forward;
function FindOurFMXAppWindow: HWND; forward;
procedure StartTrayProxyHider; procedure StartTrayProxyHider;
begin begin
GTrayStartupPending := True; // ApplicationHWND (FMX.Platform.Win) IS the taskbar proxy — no hunting for a
TThread.CreateAnonymousThread( // TFMAppClass window, and it is valid this early, before the form exists.
procedure ShowWindow(ApplicationHWND, SW_HIDE);
var // Hiding alone does not reliably drop the button: the taskbar had already
LWnd: HWND; // drawn it. Clearing WS_EX_APPWINDOW and setting WS_EX_TOOLWINDOW does —
LTicks: Integer; // a tool window is never listed. Undone by TrayProxyShowInTaskbar before any
begin // restore, otherwise the app would come back with no taskbar button at all.
LTicks := 0; SetWindowLong(ApplicationHWND, GWL_EXSTYLE,
// 5 ms cadence, 20 s ceiling. Stops as soon as the tray startup marks (GetWindowLong(ApplicationHWND, GWL_EXSTYLE) and (not WS_EX_APPWINDOW))
// itself done, so a user restoring from the tray is never fought. or WS_EX_TOOLWINDOW);
while GTrayStartupPending and (LTicks < 4000) do
begin
LWnd := FindOurFMXAppWindow;
if LWnd <> 0 then
ShowWindow(LWnd, SW_HIDE); // unconditional: the button can be up
Sleep(5); // before WS_VISIBLE reads as set
Inc(LTicks);
end; end;
end).Start;
// Give the proxy its normal styles back. Takes effect on the next show, which
// is exactly what RestoreFromTray does right after.
procedure TrayProxyShowInTaskbar;
begin
SetWindowLong(ApplicationHWND, GWL_EXSTYLE,
(GetWindowLong(ApplicationHWND, GWL_EXSTYLE) and (not WS_EX_TOOLWINDOW))
or WS_EX_APPWINDOW);
end; end;
procedure TMainForm.FormCreate(Sender: TObject); procedure TMainForm.FormCreate(Sender: TObject);
begin begin
// --tray ("Start with Windows"): get the window off screen as early as // --tray ("Start with Windows"): the taskbar proxy was already dealt with in
// possible. Visible := False alone is not enough — FMX re-shows the main // the .dpr (StartTrayProxyHider) — it is shown before FormCreate is even
// form after the constructor returns — so OnActivate hides it again the // reached. This just keeps the form itself off screen.
// instant it appears. That fires on the very first show, long before the
// deferred MinimizeToTray below, which only runs once the message loop is
// free (i.e. after the DB open, the server start and the WebView2 init:
// the second the window used to sit on screen during the Windows logon).
if FindCmdLineSwitch('tray', True) then if FindCmdLineSwitch('tray', True) then
begin
Visible := False; Visible := False;
OnActivate := TrayStartupHideOnce;
GTrayStartupPending := True;
FShowEventHook := SetWinEventHook(
EVENT_OBJECT_SHOW, EVENT_OBJECT_SHOW, 0,
@TrayShowEventProc, GetCurrentProcessId, 0, WINEVENT_INCONTEXT);
LogLine('[tray] FormCreate begin at ' + FormatDateTime('hh:nn:ss.zzz', Now) +
' show-hook=' + BoolToStr(FShowEventHook <> 0, True));
// The proxy watcher is NOT started here: FMX shows the taskbar proxy
// before FormCreate is even reached, so it is launched from the .dpr
// (StartTrayProxyHider) right after Application.Initialize.
TrayHideNow('hide#1 top-of-FormCreate');
end;
// The .fmx ships WindowState = wsMaximized, so that is the starting intent. // The .fmx ships WindowState = wsMaximized, so that is the starting intent.
// Wired here (not in the .fmx) to keep the designer file untouched. // Wired here (not in the .fmx) to keep the designer file untouched.
@@ -325,9 +282,6 @@ begin
{$ENDIF} {$ENDIF}
WebBrowser.Parent := Self; WebBrowser.Parent := Self;
WebBrowser.Align := TAlignLayout.Client; WebBrowser.Align := TAlignLayout.Client;
// Parenting forces the form's window handle to exist, and that is the point
// FMX may show it — hide again right away.
if FindCmdLineSwitch('tray', True) then TrayHideNow('hide#2 after-browser-parent');
FServer := TPMHTTPServer.Create; FServer := TPMHTTPServer.Create;
FServer.OnLog := LogLine; FServer.OnLog := LogLine;
@@ -404,21 +358,13 @@ begin
// it's noise when Windows itself put us in the tray (the user // it's noise when Windows itself put us in the tray (the user
// didn't actively minimise). Manual launches still see it once. // didn't actively minimise). Manual launches still see it once.
FBridge.BalloonShown := True; FBridge.BalloonShown := True;
TrayHideNow('hide#3 end-of-FormCreate');
TThread.ForceQueue(nil, TThread.ForceQueue(nil,
procedure procedure
begin begin
FBridge.MinimizeToTray; FBridge.MinimizeToTray;
// Hidden now — safe to pay the startup cost. // Hidden now — safe to pay the startup cost.
btnStartClick(Nil); btnStartClick(Nil);
GTrayStartupPending := False; LogLine('Launched with --tray, started hidden (balloon suppressed)');
if FShowEventHook <> 0 then
begin
UnhookWinEvent(FShowEventHook);
FShowEventHook := 0;
end;
LogLine('[tray] deferred MinimizeToTray ran at ' +
FormatDateTime('hh:nn:ss.zzz', Now));
end); end);
end; end;
end; end;
@@ -430,11 +376,6 @@ begin
UnhookWinEvent(FWinEventHook); UnhookWinEvent(FWinEventHook);
FWinEventHook := 0; FWinEventHook := 0;
end; end;
if FShowEventHook <> 0 then
begin
UnhookWinEvent(FShowEventHook);
FShowEventHook := 0;
end;
if Assigned(FFileSaveChunks) then if Assigned(FFileSaveChunks) then
begin begin
for var LSB in FFileSaveChunks.Values do LSB.Free; for var LSB in FFileSaveChunks.Values do LSB.Free;
@@ -1692,69 +1633,6 @@ begin
else if AEvent = EVENT_SYSTEM_MINIMIZEEND then MainForm.WinStateMinimizeEnd; else if AEvent = EVENT_SYSTEM_MINIMIZEEND then MainForm.WinStateMinimizeEnd;
end; end;
// While a --tray launch is still starting up, every top-level window our
// process shows gets hidden again immediately. Scoped to our process by the
// hook itself; WebView2 runs in separate processes so its windows never reach
// here. Torn down as soon as startup finishes.
// Our FMX taskbar proxy (TFMAppClass), found by class name and filtered on our
// own process id — the same lookup TPMBridge does, duplicated here because it
// must work from a worker thread before FBridge even exists.
function FindOurFMXAppWindow: HWND;
var
LPid: DWORD;
begin
Result := FindWindowEx(0, 0, 'TFMAppClass', nil);
while Result <> 0 do
begin
LPid := 0;
GetWindowThreadProcessId(Result, LPid);
if LPid = GetCurrentProcessId then Exit;
Result := FindWindowEx(0, Result, 'TFMAppClass', nil);
end;
end;
procedure TrayShowEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall;
begin
if not GTrayStartupPending then Exit;
if (idObject <> OBJID_WINDOW) or (AWnd = 0) then Exit;
if GetParent(AWnd) <> 0 then Exit; // top-level windows only
ShowWindow(AWnd, SW_HIDE);
end;
pprocedure TMainForm.Timer1Timer(Sender: TObject);
begin
end;
rocedure TMainForm.TrayHideNow(const ATag: string);
var
LHwnd, LAppHwnd: HWND;
LWasVisible: Boolean;
begin
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
LWasVisible := (LHwnd <> 0) and IsWindowVisible(LHwnd);
if LHwnd <> 0 then ShowWindow(LHwnd, SW_HIDE);
LAppHwnd := 0;
if Assigned(FBridge) then LAppHwnd := FBridge.FindFMXAppWindow;
if LAppHwnd <> 0 then ShowWindow(LAppHwnd, SW_HIDE);
LogLine(Format('[tray] %s at %s — form_visible_was=%s hwnd=%d app=%d',
[ATag, FormatDateTime('hh:nn:ss.zzz', Now),
BoolToStr(LWasVisible, True), LHwnd, LAppHwnd]));
end;
procedure TMainForm.TrayStartupHideOnce(Sender: TObject);
begin
OnActivate := nil; // one shot — never fight the user afterwards
Visible := False;
TrayHideNow('hide#4 on-activate');
var AppHandle := ApplicationHWND;
ShowWindow(AppHandle, SW_HIDE);
SetWindowLong(AppHandle, GWL_EXSTYLE,
GetWindowLong(AppHandle, GWL_EXSTYLE) and (not WS_EX_APPWINDOW) or WS_EX_TOOLWINDOW);
end;
// Single entry point for every tray restore. ExecuteAutofill really minimises // Single entry point for every tray restore. ExecuteAutofill really minimises
// the window (SW_MINIMIZE) before typing, which raises the guard; if the window // the window (SW_MINIMIZE) before typing, which raises the guard; if the window
// is then HIDDEN to the tray instead of being un-minimised, MINIMIZEEND may // is then HIDDEN to the tray instead of being un-minimised, MINIMIZEEND may
@@ -1764,6 +1642,9 @@ end;
procedure TMainForm.RestoreWindowFromTray; procedure TMainForm.RestoreWindowFromTray;
begin begin
FIgnoreResizes := False; FIgnoreResizes := False;
// A --tray launch turned the proxy into a tool window to keep it off the
// taskbar during startup; put it back before showing anything.
TrayProxyShowInTaskbar;
FBridge.RestoreFromTray; FBridge.RestoreFromTray;
end; end;