icns

Easily create .icns files (Mac Icons) with this Go library or the included CLI.
Log | Files | Refs | LICENSE

commit c74311c5ee4dc722cded3cffcb748cd5fe9da86c
parent 9eb03ebdb77803f4855049bdd6254036417eb98d
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 16:09:39 -0400

ico: check the format against Windows itself

Windows reads a PNG icon only when it carries an alpha channel, which Go's
encoder drops from an image that is entirely opaque, so a 256 pixel icon was
invisible to every Windows decoder. Nothing short of asking Windows finds
that.

Diffstat:
Aico/oracle_windows_test.go | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mreadme.md | 2+-
2 files changed, 152 insertions(+), 1 deletion(-)

diff --git a/ico/oracle_windows_test.go b/ico/oracle_windows_test.go @@ -0,0 +1,151 @@ +//go:build windows + +package ico + +// Windows decides what an ico file may contain, so these tests hand it files +// this package wrote and read back a file it wrote itself. They run wherever +// Windows PowerShell is present, which covers the Windows CI runner. + +import ( + "image" + "image/color" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// readFrames lists every frame the Windows Imaging Component finds, as +// "width height r g b a". This is the decoder behind Explorer's preview. +const readFrames = ` +param([string]$Path) +Add-Type -AssemblyName PresentationCore +$stream = [System.IO.File]::OpenRead($Path) +$decoder = [System.Windows.Media.Imaging.BitmapDecoder]::Create($stream, 'None', 'OnLoad') +foreach ($frame in $decoder.Frames) { + $converted = New-Object System.Windows.Media.Imaging.FormatConvertedBitmap($frame, [System.Windows.Media.PixelFormats]::Bgra32, $null, 0) + $pixel = New-Object byte[] 4 + $at = New-Object System.Windows.Int32Rect ([int]($converted.PixelWidth/2)), ([int]($converted.PixelHeight/2)), 1, 1 + $converted.CopyPixels($at, $pixel, 4, 0) + "{0} {1} {2} {3} {4} {5}" -f $converted.PixelWidth, $converted.PixelHeight, $pixel[2], $pixel[1], $pixel[0], $pixel[3] +} +$stream.Close() +` + +// writeIcon has Windows write an ico file of one solid colour. +const writeIcon = ` +param([string]$Path, [int]$Size, [int]$R, [int]$G, [int]$B) +Add-Type -AssemblyName System.Drawing +$bitmap = New-Object System.Drawing.Bitmap($Size, $Size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) +$graphics = [System.Drawing.Graphics]::FromImage($bitmap) +$graphics.Clear([System.Drawing.Color]::FromArgb(255, $R, $G, $B)) +$graphics.Dispose() +$icon = [System.Drawing.Icon]::FromHandle($bitmap.GetHicon()) +$stream = [System.IO.File]::Create($Path) +$icon.Save($stream) +$stream.Close() +` + +// powershell runs a script and returns what it printed, failing the test +// with whatever it wrote if it did not run. +func powershell(t *testing.T, script string, args ...string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "oracle.ps1") + if err := os.WriteFile(path, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + args = append([]string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path}, args...) + out, err := exec.Command("powershell.exe", args...).CombinedOutput() + if err != nil { + t.Fatalf("powershell %v: %v\n%s", args, err, out) + } + return string(out) +} + +// TestOracleWindowsReads checks that Windows finds every icon this package +// writes, at the size and colour it was given. +func TestOracleWindowsReads(t *testing.T) { + var ( + images = map[uint]image.Image{} + want = map[int]color.NRGBA{} + ) + for i, size := range Sizes() { + c := color.NRGBA{R: uint8(20 + i*30), G: uint8(200 - i*20), B: 0x40, A: 0xFF} + images[size] = solid(int(size), c) + want[int(size)] = c + } + path := filepath.Join(t.TempDir(), "icon.ico") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := NewEncoder(f).EncodeSizes(images); err != nil { + f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + out := powershell(t, readFrames, "-Path", path) + seen := map[int]bool{} + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) != 6 { + continue + } + n := make([]int, 6) + for i, field := range fields { + if n[i], err = strconv.Atoi(field); err != nil { + t.Fatalf("the oracle printed %q", line) + } + } + width, height := n[0], n[1] + if width != height { + t.Errorf("Windows read a %dx%d icon, which is not square", width, height) + } + got := color.NRGBA{R: uint8(n[2]), G: uint8(n[3]), B: uint8(n[4]), A: uint8(n[5])} + if got != want[width] { + t.Errorf("Windows read the %d pixel icon as %v, want %v", width, got, want[width]) + } + seen[width] = true + } + for size := range want { + if !seen[size] { + t.Errorf("Windows did not read the %d pixel icon at all:\n%s", size, out) + } + } +} + +// TestOracleWindowsWrites decodes an icon Windows wrote. It writes them +// indexed, so this covers the colour table against a real writer. +func TestOracleWindowsWrites(t *testing.T) { + const size = 48 + // Red is in the palette Windows picks, so the colour survives exactly. + want := color.NRGBA{R: 0xFF, A: 0xFF} + path := filepath.Join(t.TempDir(), "windows.ico") + powershell(t, writeIcon, + "-Path", path, + "-Size", strconv.Itoa(size), + "-R", strconv.Itoa(int(want.R)), + "-G", strconv.Itoa(int(want.G)), + "-B", strconv.Itoa(int(want.B)), + ) + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + img, err := Decode(f) + if err != nil { + t.Fatal(err) + } + if got := img.Bounds().Dx(); got != size { + t.Errorf("decoded a %d pixel icon, want %d", got, size) + } + if got := centre(img); got != want { + t.Errorf("centre = %v, want %v", got, want) + } +} diff --git a/readme.md b/readme.md @@ -200,7 +200,7 @@ go test ./... ./cmd/preview/... ./cmd/shell-extension/... The two command modules require the library at a published tag. Inside the workspace that requirement only shapes the module graph; the code always comes from the working tree, so the tag can lag behind without affecting development. -CI builds and tests the library on Linux, macOS and Windows, runs the encoder under the race detector, fuzzes the decoder, and reports `gofmt`, `go vet` and `govulncheck`. The shell extension is tested on Windows, where cgo can reach a C compiler, and `preview` is built on macOS and Windows, since Gio needs a long list of X11 and Wayland headers on Linux. Both command modules are also built with `GOWORK=off`, which is what `go install` sees. +CI builds and tests the library on Linux, macOS and Windows, runs the encoder under the race detector, fuzzes the decoder, and reports `gofmt`, `go vet` and `govulncheck`. Two oracles check the formats against the systems that own them: `iconutil` on the macOS runner, and the Windows Imaging Component and `System.Drawing` on the Windows one. The shell extension is tested on Windows, where cgo can reach a C compiler, and `preview` is built on macOS and Windows, since Gio needs a long list of X11 and Wayland headers on Linux. Both command modules are also built with `GOWORK=off`, which is what `go install` sees. Releasing: tag the root module (`vX.Y.Z`), which releases the library and `icnsify` together. When `go install .../cmd/preview@latest`, or a shell extension built outside the checkout, should pick up a newer library, bump that module's requirement and tidy it outside the workspace: