icns

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

commit 2368c2fcc1e15fb4254f5d140d6303534026b366
parent e27066fa0261cfbe308bca6f57104db9be5d70e5
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Sun, 20 Sep 2026 16:35:51 -0300

icnsify: write an icon bundle

A bundle is the one output that is a directory rather than a file, so it is
built where the output path is resolved rather than through the writer every
other format goes out by.

Diffstat:
Mcmd/icnsify/check_test.go | 15+++++++++++++++
Mcmd/icnsify/main.go | 38+++++++++++++++++++++++++++-----------
Mcmd/icnsify/main_test.go | 3+++
3 files changed, 45 insertions(+), 11 deletions(-)

diff --git a/cmd/icnsify/check_test.go b/cmd/icnsify/check_test.go @@ -170,6 +170,21 @@ func TestCheckRefusesABinaryWithoutIcons(t *testing.T) { } } +// TestWritableCoversEveryFormatTheUsageOffers keeps the usage text and what +// the program will actually write from drifting apart. +func TestWritableCoversEveryFormatTheUsageOffers(t *testing.T) { + for _, ext := range []string{".icns", ".icon", ".ico", ".png", ".jpg", ".jpeg"} { + if !writable(ext) { + t.Errorf("writable(%q) is false", ext) + } + } + for _, ext := range []string{".exe", ".dll", ".gif", ""} { + if writable(ext) { + t.Errorf("writable(%q) is true", ext) + } + } +} + 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/appicon" "github.com/jackmordaunt/icns/v4/exe" "github.com/jackmordaunt/icns/v4/ico" ) @@ -27,6 +28,9 @@ var containers = map[string]bool{".icns": true, ".ico": true} // being icons themselves. var binaries = map[string]bool{".exe": true, ".dll": true} +// bundles are the formats written as a directory rather than as a file. +var bundles = map[string]bool{".icon": true} + // errUsage signals that no work was requested; usage has been printed. var errUsage = errors.New("usage") @@ -51,7 +55,7 @@ func run() error { stringFlag(&outputPath, "output", "o", "", "Output path, defaults to the input named with the target's extension.") stringFlag(&outputFormat, "format", "f", "", - "Output format: icns, ico, png or jpg. Defaults from the output path.") + "Output format: icns, icon, ico, png or jpg. Defaults from the output path.") intFlag(&resize, "resize", "r", 5, "Quality of resize algorithm, 0 to 5 from fastest to slowest.") var checkOnly bool @@ -98,7 +102,7 @@ func run() error { return check(inputPath, source) } if outputFormat != "" && !writable(extension(outputFormat)) { - return fmt.Errorf("cannot write %s: choose from icns, ico, png or jpg", outputFormat) + return fmt.Errorf("cannot write %s: choose from icns, icon, ico, png or jpg", outputFormat) } in, out, algorithm := sanitiseInputs(inputPath, outputPath, outputFormat, resize) if piping { @@ -119,15 +123,19 @@ func run() error { } defer sourcef.Close() input = sourcef - if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { - return fmt.Errorf("preparing output directory: %w", err) - } - outputf, err := os.Create(out) - if err != nil { - return fmt.Errorf("creating output file: %w", err) + // A bundle is a directory the encoder builds itself, so there is no + // file to open for it. + if !bundles[target(outputFormat, out, false, extension(filepath.Ext(in)))] { + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + return fmt.Errorf("preparing output directory: %w", err) + } + outputf, err := os.Create(out) + if err != nil { + return fmt.Errorf("creating output file: %w", err) + } + defer outputf.Close() + output = outputf } - defer outputf.Close() - output = outputf } source, err := io.ReadAll(input) if err != nil { @@ -153,6 +161,14 @@ func run() error { return fmt.Errorf("decoding input: %w", err) } switch kind := target(outputFormat, out, piping, format); kind { + case ".icon": + if piping { + return errors.New("a .icon is a directory, so it cannot be written to a pipe") + } + name := strings.TrimSuffix(filepath.Base(out), filepath.Ext(out)) + if err := appicon.New(img, name).Write(out); err != nil { + return fmt.Errorf("writing icon bundle: %w", err) + } case ".icns": if err := icns.NewEncoder(output).WithAlgorithm(algorithm).Encode(img); err != nil { return fmt.Errorf("encoding icns: %w", err) @@ -234,7 +250,7 @@ func extension(name string) string { // writable reports whether this program can write the format. func writable(ext string) bool { - return containers[ext] || encoders[ext] != nil + return containers[ext] || bundles[ext] || encoders[ext] != nil } func sanitiseInputs( diff --git a/cmd/icnsify/main_test.go b/cmd/icnsify/main_test.go @@ -23,6 +23,9 @@ func TestTarget(t *testing.T) { {"a pipe packs artwork", "", "", true, "png", ".icns"}, {"a pipe unpacks an icns", "", "", true, "icns", ".png"}, {"a pipe unpacks an ico", "", "", true, "ico", ".png"}, + {"a bundle from the flag", "icon", "", false, "png", ".icon"}, + {"a bundle from the output path", "", "App.icon", false, "png", ".icon"}, + {"a binary unpacks to an image", "", "", true, "exe", ".png"}, {"a pipe takes the flag", "ico", "", true, "png", ".ico"}, {"an extension we cannot write, from artwork", "", "icon.gif", false, "png", ".icns"}, {"an extension we cannot write, from a container", "", "icon.gif", false, "icns", ".png"},