From bb105d121217f5c7911d23a99e45681f93e4c794 Mon Sep 17 00:00:00 2001 From: binwenw Date: Thu, 12 Nov 2026 08:28:07 +0800 Subject: [PATCH 1/3] Open no console window from a published cui-mini app The generated file-based app set no OutputType, so dotnet publish produced a console-subsystem apphost: the published exe opened a black console behind its window. Set #:property OutputType=WinExe in the template, the same choice cui-app makes in its csproj, and have verify-template.ps1 read the PE subsystem out of the published binary so the promise cannot regress silently. The headless stdio path is unaffected: dotnet cui and CUISHARP_HEADLESS=1 launch the apphost with redirected pipes. Verified: tools/verify-template.ps1 green end to end, including one file at 1,163,264 B, subsystem 2 (Windows GUI), and the visible-window probe. --- CHANGELOG.md | 9 +++++++++ src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.cs | 9 ++++++--- tools/verify-template.ps1 | 13 +++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f38360e..bd225db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ Every user-visible change, newest first. Pre-1.0 the minor number carries new su carries fixes. The section for a tag is what the GitHub release publishes, so the two cannot say different things. +## [Unreleased] + +### Fixed + +- **`dotnet new cui-mini` no longer opens a console window.** The generated file-based app now + sets `#:property OutputType=WinExe`, so `dotnet publish` produces a GUI-subsystem executable + like the one `dotnet new cui-app` builds; `tools/verify-template.ps1` reads that subsystem out + of the published binary. + ## [0.3.2] — 2026-11-11 The gallery now draws everything CUI ships, and a published binary carries only what it uses. diff --git a/src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.cs b/src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.cs index 4036603..fa6cb86 100644 --- a/src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.cs +++ b/src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.cs @@ -16,11 +16,14 @@ // A publish also trims the binary, and that includes the channel `dotnet cui` drives an app // with: add -p:CuiSharpHeadless=true if a tool should inspect the published exe. // -// The five properties below are what makes that binary small: no ICU tables (this app formats the -// invariant way), size rather than speed in the generated code, and no debug symbols — that last -// pair is the difference between one file and an exe next to a six-megabyte .pdb. +// The six properties below: OutputType=WinExe makes the exe a GUI-subsystem binary, so the +// published app opens its window without a console behind it. The other five make that binary +// small: no ICU tables (this app formats the invariant way), size rather than speed in the +// generated code, and no debug symbols — that last pair is the difference between one file and +// an exe next to a six-megabyte .pdb. // 'dotnet new cui-app' sets the same ones in its project file, for the same reasons. #:property PublishAot=true +#:property OutputType=WinExe #:property OptimizationPreference=Size #:property InvariantGlobalization=true #:property StripSymbols=true diff --git a/tools/verify-template.ps1 b/tools/verify-template.ps1 index 66878c0..792dc09 100644 --- a/tools/verify-template.ps1 +++ b/tools/verify-template.ps1 @@ -391,6 +391,19 @@ internal static class AnalyzerProbe throw "a publish of $miniFile must leave exactly one file, $miniExe; found: $($published.Name -join ', ')" } Write-Host (" one file, {0:N0} B: {1}" -f $published[0].Length, $miniExe) -ForegroundColor Green + + # The other half of the promise: launching that one file opens the window with no console + # behind it. Whether it does is recorded in the apphost's PE subsystem, so the check reads + # the published binary rather than trusting the property list that produced it. + if ($IsWindows) { + $exeBytes = [System.IO.File]::ReadAllBytes($published[0].FullName) + $peOffset = [System.BitConverter]::ToInt32($exeBytes, 0x3C) + $subsystem = [System.BitConverter]::ToUInt16($exeBytes, $peOffset + 24 + 68) + if ($subsystem -ne 2) { + throw "the published $miniExe is PE subsystem $subsystem, not 2 (Windows GUI); running it opens a console window" + } + Write-Host ' GUI-subsystem executable: no console window when launched' -ForegroundColor Green + } } if ($IsWindows) { From 7727a770a2d8abd3b09fc669dd86f496e2c53fe8 Mon Sep 17 00:00:00 2001 From: binwenw Date: Thu, 12 Nov 2026 08:57:44 +0800 Subject: [PATCH 2/3] Ship a demo CUI icon in both templates Both templates now carry a black .ico reading CUI, wired in with ApplicationIcon: cui-app ships .ico beside the project file, cui-mini generates .ico beside .cs. Replacing the file and rebuilding changes the exe icon, and the window follows it because the CUI window class loads the executable's own icon resource before falling back to the system default. The icon holds 16/24/32/48/64 as classic BMP entries (34 494 B): a 256 entry would have added 262 KB to every published app for a size no shell asks a desktop exe for. verify-template.ps1 grew a PE resource walk and asserts, on Windows, that both published executables carry RT_GROUP_ICON and at least 20 KB of RT_ICON; a plain apphost has no resource directory at all, so the check can only pass with the icon embedded. The mini generation check now expects the .cs and the .ico, and docs/SIZE.md carries the two new publish sizes. --- CHANGELOG.md | 7 ++ README.md | 5 +- docs/SIZE.md | 6 +- skills/cui-dotnet/SKILL.md | 7 +- .../CuiSharp.App/CuiSharp.App.csproj | 4 ++ .../CuiSharp.App/CuiSharp.App.ico | Bin 0 -> 34494 bytes .../CuiSharp.Mini/MiniApp.cs | 18 ++--- .../CuiSharp.Mini/MiniApp.ico | Bin 0 -> 34494 bytes tools/verify-template.ps1 | 67 ++++++++++++++++-- 9 files changed, 92 insertions(+), 22 deletions(-) create mode 100644 src/CuiSharp.Templates/CuiSharp.App/CuiSharp.App/CuiSharp.App.ico create mode 100644 src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.ico diff --git a/CHANGELOG.md b/CHANGELOG.md index bd225db..afb8b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ different things. ## [Unreleased] +### Added + +- **A demo icon in both templates.** `dotnet new cui-app` ships `.ico` wired in with + `ApplicationIcon`, and `dotnet new cui-mini` generates `.cs` plus a matching `.ico`; + replacing the file and rebuilding changes the exe icon and, because the CUI window class loads + the executable's own icon, the window icon too. The shipped icon is a black square reading CUI. + ### Fixed - **`dotnet new cui-mini` no longer opens a console window.** The generated file-based app now diff --git a/README.md b/README.md index 2e4fc07..33cca32 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,10 @@ dotnet run demo1.cs # a JIT build of one file: a second o dotnet publish demo1.cs -c Release -o . # one native binary beside it, no .NET to install ``` -`-n demo1` creates only `demo1.cs` in the current directory, without a new folder. Add `-o ` +`-n demo1` creates `demo1.cs` and a demo icon `demo1.ico` in the current directory, without a new +folder; replace the .ico with your own to change the exe and window icon. Add `-o ` only when you want a separate output directory. The generated file carries the properties that make -that publish one file of about 1.1 MB, and says so at the top. It starts with +that publish one file of about 1.2 MB, and says so at the top. It starts with `#:package CuiSharp@*`, resolving the latest stable package; use `--CuiSharpVersion 0.3.1` when generating to pin a version. Its counter and theme switch use the same frame API as a full app; `CUISHARP_HEADLESS=1` runs it without a window for automation. diff --git a/docs/SIZE.md b/docs/SIZE.md index fc564c3..06525f9 100644 --- a/docs/SIZE.md +++ b/docs/SIZE.md @@ -56,8 +56,8 @@ way: |---|---|---| | minimal app | 1 498 112 | 1 165 312 (−22.2%) | | gallery, 28 pages | 3 167 232 | 2 862 592 (−9.6%) | -| `dotnet new cui-app`, published | — | 2 244 608 | -| `dotnet new cui-mini`, published | — | 1 163 264 | +| `dotnet new cui-app`, published | — | 2 282 496 | +| `dotnet new cui-mini`, published | — | 1 198 080 | Nearly all of it is the headless channel: `System.IO`, `Console`, `Encoding` and the reflection over an enum the command lexer reads arrive with it, and a switch only removes them when the @@ -67,6 +67,8 @@ hot-reload metadata that a shipped app never reads. The two template rows carry the app's own name and version inside the executable, so `-n` moves them by a few hundred bytes; the mini template sets the properties that keep that publish to one file. +Both now also embed the template's demo .ico, which adds the 34 494 B of the icon plus a few +kilobytes of resource directory to the executable. `dotnet cui` passes `-p:CuiSharpHeadless=true` to the builds it runs itself, and says so when a **published** app has no channel: publishing one for a tool to inspect needs the same property. diff --git a/skills/cui-dotnet/SKILL.md b/skills/cui-dotnet/SKILL.md index 5eeca90..04d0ab6 100644 --- a/skills/cui-dotnet/SKILL.md +++ b/skills/cui-dotnet/SKILL.md @@ -35,10 +35,11 @@ draw again. Conditional UI is an `if`; a list is a `for`. ## A whole app For a file-based C# app: install `CuiSharp.Templates`, then `dotnet new cui-mini -n demo1` -and `dotnet run demo1.cs` — a JIT build of one file, so a second or two. This creates only -`demo1.cs` in the current directory, without a new folder. Add `-o ` only for a separate +and `dotnet run demo1.cs` — a JIT build of one file, so a second or two. This creates +`demo1.cs` and a demo icon `demo1.ico` in the current directory, without a new folder; replacing +the .ico and rebuilding changes the exe and window icon. Add `-o ` only for a separate output directory. `dotnet publish demo1.cs -c Release -o .` turns it into one native binary beside -the source, about 1.1 MB, needing no .NET on the machine that runs it; the generated file sets the +the source, about 1.2 MB, needing no .NET on the machine that runs it; the generated file sets the properties that do that, explained in its header. The generated file uses `#:package CuiSharp@*`; pass `--CuiSharpVersion ` to the template to pin it. Use `cui-app` instead for a solution with diff --git a/src/CuiSharp.Templates/CuiSharp.App/CuiSharp.App/CuiSharp.App.csproj b/src/CuiSharp.Templates/CuiSharp.App/CuiSharp.App/CuiSharp.App.csproj index e60f7f0..ee21f11 100644 --- a/src/CuiSharp.Templates/CuiSharp.App/CuiSharp.App/CuiSharp.App.csproj +++ b/src/CuiSharp.Templates/CuiSharp.App/CuiSharp.App/CuiSharp.App.csproj @@ -3,6 +3,10 @@ net10.0 WinExe + + CuiSharp.App.ico enable enable CuiSharp.App diff --git a/src/CuiSharp.Templates/CuiSharp.App/CuiSharp.App/CuiSharp.App.ico b/src/CuiSharp.Templates/CuiSharp.App/CuiSharp.App/CuiSharp.App.ico new file mode 100644 index 0000000000000000000000000000000000000000..65be31afeb4abef5a8a0ce7f8f862bae1ee595e6 GIT binary patch literal 34494 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 $text" -ForegroundColor Cyan } +# Byte totals of the icon resources of a PE file: RT_ICON (3) pixel data and RT_GROUP_ICON (14) +# directory. A plain apphost with no ApplicationIcon has no resource directory at all, so both +# numbers being non-zero can only mean the .ico was embedded. Windows-only caller. +function Get-PeIconResourceBytes([string] $Path) { + $b = [System.IO.File]::ReadAllBytes($Path) + $pe = [System.BitConverter]::ToInt32($b, 0x3C) + $sections = [System.BitConverter]::ToUInt16($b, $pe + 6) + $optional = [System.BitConverter]::ToUInt16($b, $pe + 20) + $rsrc = 0 + for ($i = 0; $i -lt $sections; $i++) { + $s = $pe + 24 + $optional + 40 * $i + if ([System.Text.Encoding]::ASCII.GetString($b, $s, 6) -eq '.rsrc') { + $rsrc = [System.BitConverter]::ToUInt32($b, $s + 20) + } + } + if ($rsrc -eq 0) { return @{ Group = 0; Images = 0 } } + + $group = 0; $images = 0 + $typeCount = [System.BitConverter]::ToUInt16($b, $rsrc + 14) + for ($t = 0; $t -lt $typeCount; $t++) { + $id = [System.BitConverter]::ToUInt32($b, $rsrc + 16 + 8 * $t) -band 0xFFFF + if ($id -ne 3 -and $id -ne 14) { continue } + $nameDir = $rsrc + ([System.BitConverter]::ToUInt32($b, $rsrc + 20 + 8 * $t) -band 0x7FFFFFFF) + $nameCount = [System.BitConverter]::ToUInt16($b, $nameDir + 14) + for ($n = 0; $n -lt $nameCount; $n++) { + $langDir = $rsrc + ([System.BitConverter]::ToUInt32($b, $nameDir + 20 + 8 * $n) -band 0x7FFFFFFF) + $langCount = [System.BitConverter]::ToUInt16($b, $langDir + 14) + for ($l = 0; $l -lt $langCount; $l++) { + $dataChild = [System.BitConverter]::ToUInt32($b, $langDir + 20 + 8 * $l) + if ($dataChild -band 0x80000000) { continue } + $size = [System.BitConverter]::ToUInt32($b, $rsrc + ($dataChild -band 0x7FFFFFFF) + 4) + if ($id -eq 14) { $group += $size } else { $images += $size } + } + } + } + return @{ Group = $group; Images = $images } +} + try { New-Item -ItemType Directory -Path $packages -Force | Out-Null @@ -128,6 +166,16 @@ try { } } Write-Host ' no native library left beside the executable' -ForegroundColor Green + + # The template ships an .ico and ApplicationIcon wires it into the apphost; that is how a + # user changes the app's icon, so a generated app must actually carry the resource. + if ($IsWindows) { + $icon = Get-PeIconResourceBytes (Join-Path $app "TodoApp/bin/$Configuration/net10.0/$rid/publish/$exeName") + if ($icon.Group -eq 0 -or $icon.Images -lt 20000) { + throw "the published TodoApp embeds no application icon (RT_GROUP_ICON $($icon.Group) B, RT_ICON $($icon.Images) B)" + } + Write-Host (" application icon embedded: RT_ICON {0:N0} B" -f $icon.Images) -ForegroundColor Green + } } # The analyzers ship inside the package, and a package's analyzers are what an app @@ -258,10 +306,10 @@ internal static class AnalyzerProbe $defaultMini = Join-Path $work 'demo1' & dotnet new cui-mini -o $defaultMini | Out-Null if ($LASTEXITCODE -ne 0) { throw 'generating the default mini app failed' } - $defaultFiles = @(Get-ChildItem $defaultMini -Recurse -File) - if ($defaultFiles.Count -ne 1 -or $defaultFiles[0].Name -cne 'demo1.cs' -or - (Get-Content $defaultFiles[0].FullName -First 1) -ne '#:package CuiSharp@*') { - throw 'dotnet new cui-mini -o demo1 must generate only demo1.cs referencing CuiSharp@*' + $defaultFiles = @(Get-ChildItem $defaultMini -Recurse -File | Sort-Object Name) + if ($defaultFiles.Count -ne 2 -or ($defaultFiles.Name -join ',') -cne 'demo1.cs,demo1.ico' -or + (Get-Content (Join-Path $defaultMini 'demo1.cs') -First 1) -ne '#:package CuiSharp@*') { + throw 'dotnet new cui-mini -o demo1 must generate only demo1.cs and demo1.ico, the cs referencing CuiSharp@*' } # An explicit -n also names the file and gives the native window a unique title. $miniName = 'CuiMini' + [guid]::NewGuid().ToString('N') @@ -269,8 +317,9 @@ internal static class AnalyzerProbe & dotnet new cui-mini -n $miniName -o $miniApp --CuiSharpVersion $version | Out-Null if ($LASTEXITCODE -ne 0) { throw 'generating the mini app failed' } $generated = @(Get-ChildItem $miniApp -Recurse -File) - if ($generated.Count -ne 1 -or $generated[0].Name -cne $miniFile) { - throw "cui-mini must generate exactly one file named $miniFile" + if ($generated.Count -ne 2 -or -not ($generated.Name -ccontains $miniFile) -or + -not ($generated.Name -ccontains "$miniName.ico")) { + throw "cui-mini must generate exactly $miniFile and $miniName.ico; found: $($generated.Name -join ', ')" } # Deterministic non-ASCII label: the tool's strict UTF-8 decoder below must observe these # exact characters; this check never silently skips because a sample page happened to be ASCII. @@ -402,7 +451,11 @@ internal static class AnalyzerProbe if ($subsystem -ne 2) { throw "the published $miniExe is PE subsystem $subsystem, not 2 (Windows GUI); running it opens a console window" } - Write-Host ' GUI-subsystem executable: no console window when launched' -ForegroundColor Green + $icon = Get-PeIconResourceBytes $published[0].FullName + if ($icon.Group -eq 0 -or $icon.Images -lt 20000) { + throw "the published $miniExe embeds no application icon (RT_GROUP_ICON $($icon.Group) B, RT_ICON $($icon.Images) B)" + } + Write-Host (" GUI-subsystem executable with the application icon embedded (RT_ICON {0:N0} B)" -f $icon.Images) -ForegroundColor Green } } From 2fc8e244c578695ed9ecfa00c94d4e26b9bc2257 Mon Sep 17 00:00:00 2001 From: binwenw Date: Thu, 12 Nov 2026 09:43:40 +0800 Subject: [PATCH 3/3] Put the embedded icon on the window, not just the exe file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An apphost embeds ApplicationIcon as RT_GROUP_ICON 32512 — IDI_APPLICATION. The CUI window class looks for group 1 and falls back to LoadIconW(NULL, IDI_APPLICATION), the system's generic logo, so a published app showed its real icon on the file in Explorer and the generic one on the title bar, taskbar button and Alt+Tab card: Explorer reads the resource, the shell's window decorations ask the window. CuiApp now loads RT_GROUP_ICON 32512 from the host module with LoadImageW at SM_CXICON and SM_CXSMICON and sends WM_SETICON when the window opens — in Create for the loop-style host and in Hooking, which runs on the first frame of Run and on live attach. The handles are process-lifetime, loaded once; a host with no embedded icon keeps the system default untouched. The verify gate's WM_GETICON probe asks the live window from another process (0x007F — the earlier 0x0033 is not WM_GETICON, which made the first gate version probe a nonexistent message) and polls, because the icon goes in on the first frame. Proven on a published AOT exe: both WM_GETICON(ICON_SMALL) and WM_GETICON(ICON_BIG) answer with real handles. --- CHANGELOG.md | 9 ++- .../CuiSharp.App/CuiSharp.App.csproj | 4 +- src/CuiSharp/CuiApp.cs | 2 + src/CuiSharp/Hosting/WindowIcon.cs | 57 +++++++++++++++++++ src/CuiSharp/Interop/Win32.cs | 13 +++++ tools/verify-template.ps1 | 20 +++++++ 6 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 src/CuiSharp/Hosting/WindowIcon.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index afb8b2f..f7565f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,16 @@ different things. - **A demo icon in both templates.** `dotnet new cui-app` ships `.ico` wired in with `ApplicationIcon`, and `dotnet new cui-mini` generates `.cs` plus a matching `.ico`; - replacing the file and rebuilding changes the exe icon and, because the CUI window class loads - the executable's own icon, the window icon too. The shipped icon is a black square reading CUI. + replacing the file and rebuilding changes the exe icon and the icon on the title bar, taskbar + button and `Alt+Tab` card. The shipped icon is a black square reading CUI. ### Fixed +- **The embedded icon reaches the window.** An apphost embeds `ApplicationIcon` as `IDI_APPLICATION`, + which Explorer reads off the exe but the CUI window class does not find, so a published app wore + the system's generic logo in the title bar and taskbar. `CuiApp` now loads that resource and sends + `WM_SETICON` itself when the window opens, small and large, at the system's icon metrics. + - **`dotnet new cui-mini` no longer opens a console window.** The generated file-based app now sets `#:property OutputType=WinExe`, so `dotnet publish` produces a GUI-subsystem executable like the one `dotnet new cui-app` builds; `tools/verify-template.ps1` reads that subsystem out diff --git a/src/CuiSharp.Templates/CuiSharp.App/CuiSharp.App/CuiSharp.App.csproj b/src/CuiSharp.Templates/CuiSharp.App/CuiSharp.App/CuiSharp.App.csproj index ee21f11..40c5388 100644 --- a/src/CuiSharp.Templates/CuiSharp.App/CuiSharp.App/CuiSharp.App.csproj +++ b/src/CuiSharp.Templates/CuiSharp.App/CuiSharp.App/CuiSharp.App.csproj @@ -4,8 +4,8 @@ net10.0 WinExe + It becomes the exe icon, and CuiApp puts the same image on the window — the title bar, + the taskbar button and Alt+Tab — when the app starts. --> CuiSharp.App.ico enable enable diff --git a/src/CuiSharp/CuiApp.cs b/src/CuiSharp/CuiApp.cs index c78ba81..57e3323 100644 --- a/src/CuiSharp/CuiApp.cs +++ b/src/CuiSharp/CuiApp.cs @@ -149,6 +149,7 @@ public Ui Ui var app = new CuiApp(handle, options); app.Window.FileDialog = options.FileDialog; LiveResize.Install(handle == 0 ? 0 : app.NativeWindow, options.NoVsync); + WindowIcon.Install(app.NativeWindow); options.WindowCreated?.Invoke(app.Window); return app; } @@ -339,6 +340,7 @@ internal void Hooking(nint ui) WindowHandle = Native.AppNativeWindow(app); Window = new CuiWindow(app) { FileDialog = Options.FileDialog }; LiveResize.Install(WindowHandle, Options.NoVsync); + WindowIcon.Install(WindowHandle); Options.WindowCreated?.Invoke(Window); if (Features.Live) Live?.Bind(app, ui, WindowHandle); } diff --git a/src/CuiSharp/Hosting/WindowIcon.cs b/src/CuiSharp/Hosting/WindowIcon.cs new file mode 100644 index 0000000..f2ddb91 --- /dev/null +++ b/src/CuiSharp/Hosting/WindowIcon.cs @@ -0,0 +1,57 @@ +// Hand-written. Puts the icon the .NET apphost embeds — the one ApplicationIcon points at — into +// the title bar and the taskbar button. +using CuiSharp.Interop; + +namespace CuiSharp.Hosting; + +internal static class WindowIcon +{ + // The .NET apphost embeds the ApplicationIcon as RT_GROUP_ICON 32512, which is IDI_APPLICATION. + // The CUI window class asks for group 1 and, not finding it, falls back to the icon a NULL + // instance loads: the system's generic application logo. The embedded icon then shows on the + // exe file in Explorer but not on the window — Explorer reads the resource, the shell's window + // decorations ask the window. WM_SETICON is the call only managed code that knows the id can make. + private const int IdiApplication = 32512; + private const int ImageIcon = 1; + private const int SmCxIcon = 11; + private const int SmCyIcon = 12; + private const int SmCxSmIcon = 49; + private const int SmCySmIcon = 50; + private const int IconSmall = 0; + private const int IconBig = 1; + + // Process-lifetime copies, loaded once and reused by every window: WM_SETICON does not take + // ownership of an icon, so destroying one on window close would strand the other windows, and + // two handles that live until the process exits are the honest alternative. + private static nint _large; + private static nint _small; + private static bool _tried; + + /// Applies the host executable's icon to a top-level window. Does nothing when the + /// host embeds none, leaving the class icon — the system default — in place. + internal static void Install(nint hwnd) + { + if (!OperatingSystem.IsWindows() || hwnd == 0) + { + return; + } + + if (!_tried) + { + _tried = true; + nint module = Win32.GetModuleHandle(null); + _large = Win32.LoadIcon(module, IdiApplication, ImageIcon, + Win32.GetSystemMetrics(SmCxIcon), Win32.GetSystemMetrics(SmCyIcon), 0); + _small = Win32.LoadIcon(module, IdiApplication, ImageIcon, + Win32.GetSystemMetrics(SmCxSmIcon), Win32.GetSystemMetrics(SmCySmIcon), 0); + } + + if (_large == 0 && _small == 0) + { + return; + } + + _ = Win32.SendMessage(hwnd, Win32.WmSetIcon, IconSmall, _small); + _ = Win32.SendMessage(hwnd, Win32.WmSetIcon, IconBig, _large); + } +} diff --git a/src/CuiSharp/Interop/Win32.cs b/src/CuiSharp/Interop/Win32.cs index 7e7ad7a..dc8843f 100644 --- a/src/CuiSharp/Interop/Win32.cs +++ b/src/CuiSharp/Interop/Win32.cs @@ -16,6 +16,7 @@ internal static unsafe partial class Win32 internal const uint WmTimer = 0x0113; internal const uint WmPaint = 0x000F; internal const uint WmClose = 0x0010; + internal const uint WmSetIcon = 0x0080; internal const uint WmNcDestroy = 0x0082; internal const uint WmApp = 0x8000; internal const uint WmEnterSizeMove = 0x0231; @@ -82,6 +83,18 @@ internal static unsafe partial class Win32 [LibraryImport("kernel32.dll", EntryPoint = "GetModuleHandleW", StringMarshalling = StringMarshalling.Utf16)] internal static partial nint GetModuleHandle(string? name); + /// HICON LoadImageW(HINSTANCE, LPCWSTR, IMAGE_ICON, int, int, UINT). + [LibraryImport("user32.dll", EntryPoint = "LoadImageW", SetLastError = true)] + internal static partial nint LoadIcon(nint module, nint name, int imageType, int width, int height, uint flags); + + /// int GetSystemMetrics(int). + [LibraryImport("user32.dll", EntryPoint = "GetSystemMetrics")] + internal static partial int GetSystemMetrics(int index); + + /// LRESULT SendMessageW(HWND, UINT, WPARAM, LPARAM). + [LibraryImport("user32.dll", EntryPoint = "SendMessageW")] + internal static partial nint SendMessage(nint hwnd, uint message, nint wparam, nint lparam); + /// FARPROC GetProcAddress(HMODULE, LPCSTR). [LibraryImport("kernel32.dll", EntryPoint = "GetProcAddress")] internal static partial nint GetProcAddress(nint module, [MarshalAs(UnmanagedType.LPStr)] string name); diff --git a/tools/verify-template.ps1 b/tools/verify-template.ps1 index 3a93d37..f719e3a 100644 --- a/tools/verify-template.ps1 +++ b/tools/verify-template.ps1 @@ -474,6 +474,9 @@ public static class CuiMiniWindowProbe public static extern bool IsWindowVisible(IntPtr window); [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr window, uint message, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll", SetLastError = true)] + public static extern IntPtr SendMessageTimeout(IntPtr window, uint message, IntPtr wParam, IntPtr lParam, + uint flags, uint timeoutMs, out IntPtr result); } '@ } @@ -494,6 +497,23 @@ public static class CuiMiniWindowProbe if (-not $mini.HasExited) { $mini.Kill($true); $mini.WaitForExit() } throw "dotnet run $miniFile did not open a visible native window: $($stderr.GetAwaiter().GetResult()) $($stdout.GetAwaiter().GetResult())" } + # WM_GETICON (0x007F) answers with the window's own icon and with nothing when only the + # class icon is in play, so it distinguishes an app that installed ApplicationIcon from + # one whose window still wears the system default. The icon goes in on the first frame, + # so poll; a handle from another process cannot be dereferenced here, being non-zero is + # the whole assertion. + $hicon = [IntPtr]::Zero + $deadline = [DateTime]::UtcNow.AddSeconds(10) + while ([DateTime]::UtcNow -lt $deadline) { + $sent = [CuiMiniWindowProbe]::SendMessageTimeout($window, 0x007F, [IntPtr]1, [IntPtr]::Zero, 0x0002, 2000, [ref]$hicon) + if ($sent -ne [IntPtr]::Zero -and $hicon -ne [IntPtr]::Zero) { break } + $hicon = [IntPtr]::Zero + Start-Sleep -Milliseconds 100 + } + if ($hicon -eq [IntPtr]::Zero) { + if (-not $mini.HasExited) { $mini.Kill($true); $mini.WaitForExit() } + throw 'the mini app window carries no icon: WM_GETICON(ICON_BIG) answered with nothing' + } if (-not [CuiMiniWindowProbe]::PostMessage($window, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)) { throw 'could not send WM_CLOSE to the mini app window' }