diff --git a/CHANGELOG.md b/CHANGELOG.md index f38360e..f7565f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ 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] + +### 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 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 + 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/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..40c5388 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 0000000..65be31a Binary files /dev/null and b/src/CuiSharp.Templates/CuiSharp.App/CuiSharp.App/CuiSharp.App.ico differ diff --git a/src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.cs b/src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.cs index 4036603..594c60e 100644 --- a/src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.cs +++ b/src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.cs @@ -1,6 +1,6 @@ #:package CuiSharp@__CUISHARP_VERSION__ -// This file is the whole app: no project, no solution, nothing to set up. +// This file and its .ico are the whole app: no project, no solution, nothing to set up. // // Start it // dotnet run MiniApp.cs @@ -10,17 +10,22 @@ // // Ship it // dotnet publish MiniApp.cs -c Release -o . -// One native executable, MiniApp.exe, beside this file — about 1.1 MB, nothing else in the -// folder, and no .NET runtime on the machine that runs it. Without -o it lands in +// One native executable, MiniApp.exe, beside this file — about 1.2 MB, just the exe beside +// the source, and no .NET runtime on the machine that runs it. Without -o it lands in // artifacts/MiniApp/ instead, which keeps the source folder clean. // 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 properties below: OutputType=WinExe makes the exe a GUI-subsystem binary, so the +// published app opens its window without a console behind it; ApplicationIcon embeds the .ico +// beside this file, so replacing MiniApp.ico with your own and rebuilding changes the exe and +// window icon. The rest 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 ApplicationIcon=MiniApp.ico #:property OptimizationPreference=Size #:property InvariantGlobalization=true #:property StripSymbols=true diff --git a/src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.ico b/src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.ico new file mode 100644 index 0000000..65be31a Binary files /dev/null and b/src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.ico differ 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 66878c0..f719e3a 100644 --- a/tools/verify-template.ps1 +++ b/tools/verify-template.ps1 @@ -43,6 +43,44 @@ $elapsed = [System.Diagnostics.Stopwatch]::StartNew() function Step([string] $text) { Write-Host "==> $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. @@ -391,6 +440,23 @@ 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" + } + $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 + } } if ($IsWindows) { @@ -408,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); } '@ } @@ -428,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' }