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:
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"