icns

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

commit d23e32332b2c909c6dfdec5f489c00e00ef1729d
parent ebed95fc91a2d6f1e042400b283edfe8d19e4875
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Sun, 20 Sep 2026 16:04:23 -0300

icnsify: read icons out of a Windows binary

The icon a program ships with has no file beside it, so checking or
converting it meant finding a copy of the artwork. Explorer draws what is in
the resources, which is what --check now reads.

Diffstat:
Mcmd/icnsify/check.go | 32+++++++++++++++++++++++++++++---
Mcmd/icnsify/check_test.go | 32+++++++++++++++++++++++++++++++-
Mcmd/icnsify/main.go | 46++++++++++++++++++++++++++++++++++++----------
3 files changed, 96 insertions(+), 14 deletions(-)

diff --git a/cmd/icnsify/check.go b/cmd/icnsify/check.go @@ -8,6 +8,7 @@ import ( "path/filepath" "github.com/jackmordaunt/icns/v4" + "github.com/jackmordaunt/icns/v4/exe" "github.com/jackmordaunt/icns/v4/ico" ) @@ -44,8 +45,27 @@ func check(path string, r io.Reader) error { serious++ } } + case ".exe", ".dll": + // The icons Explorer draws for a shipped binary are the ones worth + // checking, and they are only reachable through its resources. + groups, err := exe.Icons(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("reading %s: %w", name(path), err) + } + for _, group := range groups { + problems, err := ico.Validate(bytes.NewReader(group.ICO())) + if err != nil { + return fmt.Errorf("reading %s: %s: %w", name(path), group, err) + } + for _, p := range problems { + lines = append(lines, fmt.Sprintf("%s: %s", group, p)) + if p.Severity != ico.Advice { + serious++ + } + } + } default: - return fmt.Errorf("%s is not an icns or ico file", name(path)) + return fmt.Errorf("%s is not an icns, ico or Windows binary", name(path)) } for _, line := range lines { fmt.Fprintf(os.Stdout, "%s: %s\n", name(path), line) @@ -56,14 +76,20 @@ func check(path string, r io.Reader) error { return nil } -// container names the icon format the data holds, by the bytes it begins -// with and failing that by the extension it was given. +// container names what the data holds, by the bytes it begins with and +// failing that by the extension it was given. A Windows binary is named by +// its extension, since the icons are inside it rather than at its start. func container(data []byte, ext string) string { switch { case len(data) >= 4 && string(data[:4]) == "icns": return ".icns" case len(data) >= 4 && string(data[:4]) == "\x00\x00\x01\x00": return ".ico" + case len(data) >= 2 && string(data[:2]) == "MZ": + if binaries[ext] { + return ext + } + return ".exe" } if containers[ext] { return ext diff --git a/cmd/icnsify/check_test.go b/cmd/icnsify/check_test.go @@ -6,6 +6,7 @@ import ( "image" "image/color" "image/png" + "os" "strings" "testing" @@ -113,7 +114,7 @@ func TestCheckFailsOnFindingsThatShow(t *testing.T) { func TestCheckRejectsWhatIsNotAnIcon(t *testing.T) { plain := encoded(t, func(b *bytes.Buffer) error { return png.Encode(b, art(64)) }) err := check("art.png", bytes.NewReader(plain)) - if err == nil || !strings.Contains(err.Error(), "not an icns or ico file") { + if err == nil || !strings.Contains(err.Error(), "not an icns, ico or Windows binary") { t.Errorf("check returned %v, want it to refuse a plain image", err) } } @@ -131,6 +132,9 @@ func TestContainerReadsTheBytesFirst(t *testing.T) { {"ico named icns", encoded(t, func(b *bytes.Buffer) error { return ico.Encode(b, art(64)) }), ".icns", ".ico"}, {"unknown bytes, known extension", []byte("rubbish"), ".icns", ".icns"}, {"unknown bytes, plain extension", []byte("rubbish"), ".png", ""}, + {"a binary named exe", []byte("MZ\x90\x00"), ".exe", ".exe"}, + {"a binary named dll", []byte("MZ\x90\x00"), ".dll", ".dll"}, + {"a binary named nothing", []byte("MZ\x90\x00"), "", ".exe"}, } { t.Run(tt.name, func(t *testing.T) { if got := container(tt.data, tt.ext); got != tt.want { @@ -140,6 +144,32 @@ func TestContainerReadsTheBytesFirst(t *testing.T) { } } +// TestCheckReadsABinary uses the exe package's fixture, a DLL whose resource +// section mingw laid out, rather than building another one here. +func TestCheckReadsABinary(t *testing.T) { + const fixture = "../../exe/testdata/icon.dll" + data, err := os.ReadFile(fixture) + if err != nil { + t.Fatalf("reading fixture: %v", err) + } + // The fixture holds 32, 24 and 16, so the only finding is the advice + // that larger sizes are absent. + if err := check("icon.dll", bytes.NewReader(data)); err != nil { + t.Errorf("check reported %v", err) + } +} + +func TestCheckRefusesABinaryWithoutIcons(t *testing.T) { + data, err := os.ReadFile("../../exe/testdata/plain.dll") + if err != nil { + t.Fatalf("reading fixture: %v", err) + } + err = check("plain.dll", bytes.NewReader(data)) + if err == nil || !strings.Contains(err.Error(), "no icons found") { + t.Errorf("check returned %v, want it to report no icons", err) + } +} + func TestNameLabelsAPipe(t *testing.T) { if got := name(""); got != "stdin" { t.Errorf("name(\"\") = %q, want stdin", got) diff --git a/cmd/icnsify/main.go b/cmd/icnsify/main.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/jackmordaunt/icns/v4" + "github.com/jackmordaunt/icns/v4/exe" "github.com/jackmordaunt/icns/v4/ico" ) @@ -22,6 +23,10 @@ import ( // to the plain images they are built from and unpacked into. var containers = map[string]bool{".icns": true, ".ico": true} +// binaries are the Windows files that carry icons inside them rather than +// being icons themselves. +var binaries = map[string]bool{".exe": true, ".dll": true} + // errUsage signals that no work was requested; usage has been printed. var errUsage = errors.New("usage") @@ -124,18 +129,27 @@ func run() error { defer outputf.Close() output = outputf } - if containers[extension(filepath.Ext(in))] { - by, err := io.ReadAll(input) - if err != nil { - return fmt.Errorf("probing file: reading file: %w", err) - } - if err := describe(extension(filepath.Ext(in)), bytes.NewReader(by)); err != nil { + source, err := io.ReadAll(input) + if err != nil { + return fmt.Errorf("reading input: %w", err) + } + var ( + img image.Image + format string + ) + if kind := container(source, extension(filepath.Ext(in))); kind != "" { + if err := describe(kind, bytes.NewReader(source)); err != nil { return fmt.Errorf("probing file: %w", err) } - input = bytes.NewReader(by) } - img, format, err := image.Decode(input) - if err != nil { + // A Windows binary carries icons rather than being one, so the artwork + // comes out of its resources instead of through an image decoder. + if binaries[container(source, extension(filepath.Ext(in)))] { + format = ".exe" + if img, err = exe.Decode(bytes.NewReader(source)); err != nil { + return fmt.Errorf("reading icons from the binary: %w", err) + } + } else if img, format, err = image.Decode(bytes.NewReader(source)); err != nil { return fmt.Errorf("decoding input: %w", err) } switch kind := target(outputFormat, out, piping, format); kind { @@ -158,6 +172,18 @@ func run() error { // describe logs the icons a container holds. func describe(ext string, r io.Reader) error { switch ext { + case ".exe", ".dll": + by, err := io.ReadAll(r) + if err != nil { + return err + } + groups, err := exe.Icons(bytes.NewReader(by)) + if err != nil { + return err + } + for _, group := range groups { + slog.Info("found", "icon", group) + } case ".ico": d, err := ico.NewDecoder(r) if err != nil { @@ -191,7 +217,7 @@ func target(want, out string, piping bool, got string) string { return ext } } - if containers[extension(got)] { + if containers[extension(got)] || binaries[extension(got)] { return ".png" } return ".icns"