icns

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

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

appicon: write the icon bundle macOS 26 compiles

The bundle is a manifest and its layer images, so the part a Windows machine
cannot do is only the compile. Authoring it here leaves that one step for a
runner that has actool.

Diffstat:
Aappicon/appicon.go | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aappicon/appicon_test.go | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aappicon/writer.go | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 498 insertions(+), 0 deletions(-)

diff --git a/appicon/appicon.go b/appicon/appicon.go @@ -0,0 +1,104 @@ +// Package appicon writes the icon bundle Icon Composer authors, which macOS +// 26 compiles into an app's icon. +// +// A bundle is a directory named with a .icon extension holding icon.json and +// an Assets directory of layer images. The manifest names the layers and says +// how they are lit, shadowed and filled; the compiler decides what the icon +// looks like at each size, so a bundle carries artwork rather than +// renditions. +// +// Nothing here compiles a bundle. That is actool's work, and it runs only on +// macOS; a bundle written by this package is the input it takes. +package appicon + +import ( + "errors" + "image" +) + +// Errors returned when a bundle cannot be written. They are wrapped with +// detail, so compare with errors.Is. +var ( + // ErrNoLayers means the bundle names no artwork to draw. + ErrNoLayers = errors.New("no layers to write") + // ErrDuplicateLayer means two layers share a name, so one image would + // overwrite the other. + ErrDuplicateLayer = errors.New("two layers share a name") +) + +// Bundle is an icon: a manifest naming layers, and the images they hold. +type Bundle struct { + // Fill is how the space behind the layers is filled. Empty is written as + // "automatic", which leaves the choice to the compiler. + Fill string + // Groups are composited back to front. + Groups []Group + // Platforms are the platforms the icon offers square artwork for. Empty + // is written as macOS alone. + Platforms []string +} + +// Group is a set of layers that share lighting, shadow and translucency. +type Group struct { + // Layers are composited back to front within the group. + Layers []Layer + // Shadow is the shadow cast beneath the group. Nil leaves it out of the + // manifest. + Shadow *Shadow + // Translucency is how far the group lets the material behind it through. + // Nil leaves it out of the manifest. + Translucency *Translucency +} + +// Layer is one image in the stack. +type Layer struct { + // Name identifies the layer in the manifest and names its file. + Name string + // Image is the artwork. It is written as a PNG at the size it is given. + Image image.Image + // Glass asks for the layer to be treated as glass, which the compiler + // lights and refracts rather than drawing flat. + Glass bool +} + +// Shadow is the shadow a group casts. +type Shadow struct { + // Kind is how the shadow takes its colour, such as "neutral" or + // "layer-color". + Kind string + // Opacity is how dark it is, from 0 to 1. + Opacity float64 +} + +// Translucency is how far a group lets what is behind it through. +type Translucency struct { + Enabled bool + // Value is the amount, from 0 to 1. + Value float64 +} + +// Defaults written where a bundle leaves a choice open. +const ( + // FillAutomatic leaves the fill to the compiler. + FillAutomatic = "automatic" + // ShadowNeutral takes the shadow's colour from neither the layer nor the + // background. + ShadowNeutral = "neutral" + // PlatformMac is the platform macOS icons declare. + PlatformMac = "macOS" +) + +// New returns a bundle holding one layer, which is what a single image makes. +// The shadow and translucency match what Icon Composer writes for an icon +// composed the same way. +func New(img image.Image, name string) Bundle { + return Bundle{ + Fill: FillAutomatic, + Groups: []Group{{ + Layers: []Layer{{Name: name, Image: img}}, + Shadow: &Shadow{Kind: ShadowNeutral, Opacity: 0.5}, + Translucency: &Translucency{Enabled: true, Value: 0.5}, + }}, + Platforms: []string{PlatformMac}, + } +} diff --git a/appicon/appicon_test.go b/appicon/appicon_test.go @@ -0,0 +1,217 @@ +package appicon + +import ( + "bytes" + "encoding/json" + "errors" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +func art(side int) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, side, side)) + for y := 0; y < side; y++ { + for x := 0; x < side; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: 0x80, A: 0xFF}) + } + } + return img +} + +func paths(files map[string][]byte) []string { + out := make([]string, 0, len(files)) + for name := range files { + out = append(out, name) + } + sort.Strings(out) + return out +} + +// decode reads the manifest back as loosely typed JSON, so the test asserts +// the shape actool reads rather than the Go types that produced it. +func decode(t *testing.T, files map[string][]byte) map[string]any { + t.Helper() + var doc map[string]any + if err := json.Unmarshal(files["icon.json"], &doc); err != nil { + t.Fatalf("manifest does not parse: %v", err) + } + return doc +} + +func TestFilesHoldsAManifestAndItsLayers(t *testing.T) { + files, err := New(art(64), "Probe").Files() + if err != nil { + t.Fatalf("rendering: %v", err) + } + want := []string{"Assets/Probe.png", "icon.json"} + if got := paths(files); !reflect.DeepEqual(got, want) { + t.Errorf("files are %v, want %v", got, want) + } + img, err := png.Decode(bytes.NewReader(files["Assets/Probe.png"])) + if err != nil { + t.Fatalf("the layer is not a png: %v", err) + } + if got := img.Bounds().Size(); got.X != 64 || got.Y != 64 { + t.Errorf("layer is %v, want the artwork it was given", got) + } +} + +// TestManifestMatchesTheShapeIconComposerWrites pins the key names against a +// manifest taken from a shipping app's bundle. +func TestManifestMatchesTheShapeIconComposerWrites(t *testing.T) { + files, err := New(art(32), "Mist").Files() + if err != nil { + t.Fatalf("rendering: %v", err) + } + doc := decode(t, files) + if doc["fill"] != FillAutomatic { + t.Errorf("fill is %v, want %q", doc["fill"], FillAutomatic) + } + platforms, ok := doc["supported-platforms"].(map[string]any) + if !ok { + t.Fatalf("supported-platforms is %T, want an object", doc["supported-platforms"]) + } + if squares, _ := platforms["squares"].([]any); len(squares) != 1 || squares[0] != PlatformMac { + t.Errorf("squares are %v, want [%s]", platforms["squares"], PlatformMac) + } + groups, ok := doc["groups"].([]any) + if !ok || len(groups) != 1 { + t.Fatalf("groups are %v, want one", doc["groups"]) + } + group := groups[0].(map[string]any) + for key, want := range map[string]any{ + "shadow": map[string]any{"kind": ShadowNeutral, "opacity": 0.5}, + "translucency": map[string]any{"enabled": true, "value": 0.5}, + } { + if got := group[key]; !reflect.DeepEqual(got, want) { + t.Errorf("%s is %v, want %v", key, got, want) + } + } + layers := group["layers"].([]any) + if len(layers) != 1 { + t.Fatalf("layers are %v, want one", layers) + } + want := map[string]any{"glass": false, "image-name": "Mist.png", "name": "Mist"} + if got := layers[0]; !reflect.DeepEqual(got, want) { + t.Errorf("layer is %v, want %v", got, want) + } +} + +func TestGlassAndSeveralGroups(t *testing.T) { + b := Bundle{ + Groups: []Group{ + {Layers: []Layer{{Name: "Back", Image: art(16)}}}, + { + Layers: []Layer{{Name: "Front", Image: art(16), Glass: true}}, + Shadow: &Shadow{Kind: "layer-color", Opacity: 0.25}, + Translucency: &Translucency{Enabled: false, Value: 0.8}, + }, + }, + } + files, err := b.Files() + if err != nil { + t.Fatalf("rendering: %v", err) + } + want := []string{"Assets/Back.png", "Assets/Front.png", "icon.json"} + if got := paths(files); !reflect.DeepEqual(got, want) { + t.Fatalf("files are %v, want %v", got, want) + } + groups := decode(t, files)["groups"].([]any) + if len(groups) != 2 { + t.Fatalf("groups are %v, want two", groups) + } + // A group given neither is written without them rather than with zeroes. + first := groups[0].(map[string]any) + if _, ok := first["shadow"]; ok { + t.Errorf("a group with no shadow wrote one: %v", first) + } + if _, ok := first["translucency"]; ok { + t.Errorf("a group with no translucency wrote one: %v", first) + } + second := groups[1].(map[string]any) + if got := second["shadow"]; !reflect.DeepEqual(got, map[string]any{"kind": "layer-color", "opacity": 0.25}) { + t.Errorf("shadow is %v", got) + } + if got := second["layers"].([]any)[0].(map[string]any)["glass"]; got != true { + t.Errorf("glass is %v, want true", got) + } +} + +func TestFilesRejectsWhatItCannotWrite(t *testing.T) { + for _, tt := range []struct { + name string + bundle Bundle + want error + }{ + {"no groups", Bundle{}, ErrNoLayers}, + {"no layers", Bundle{Groups: []Group{{}}}, ErrNoLayers}, + { + name: "two layers of one name", + bundle: Bundle{Groups: []Group{{Layers: []Layer{ + {Name: "Same", Image: art(16)}, + {Name: "Same", Image: art(16)}, + }}}}, + want: ErrDuplicateLayer, + }, + } { + t.Run(tt.name, func(t *testing.T) { + if _, err := tt.bundle.Files(); !errors.Is(err, tt.want) { + t.Errorf("error is %v, want %v", err, tt.want) + } + }) + } +} + +func TestFilesRejectsALayerWithoutArtwork(t *testing.T) { + b := Bundle{Groups: []Group{{Layers: []Layer{{Name: "Empty"}}}}} + if _, err := b.Files(); err == nil { + t.Error("rendering a layer with no image returned no error") + } +} + +// TestLayerNamesBecomeFileNames keeps a name that cannot be a file from +// producing a manifest pointing at something unwritable. +func TestLayerNamesBecomeFileNames(t *testing.T) { + b := Bundle{Groups: []Group{{Layers: []Layer{ + {Name: "../escape", Image: art(16)}, + {Name: "", Image: art(16)}, + }}}} + files, err := b.Files() + if err != nil { + t.Fatalf("rendering: %v", err) + } + want := []string{"Assets/Layer 2.png", "Assets/escape.png", "icon.json"} + if got := paths(files); !reflect.DeepEqual(got, want) { + t.Errorf("files are %v, want %v", got, want) + } +} + +func TestWriteBuildsTheDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "Probe.icon") + if err := New(art(64), "Probe").Write(dir); err != nil { + t.Fatalf("writing: %v", err) + } + for _, name := range []string{"icon.json", filepath.Join("Assets", "Probe.png")} { + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Errorf("bundle is missing %s: %v", name, err) + } + } + // The manifest on disk is the one Files rendered. + onDisk, err := os.ReadFile(filepath.Join(dir, "icon.json")) + if err != nil { + t.Fatalf("reading the manifest: %v", err) + } + files, err := New(art(64), "Probe").Files() + if err != nil { + t.Fatalf("rendering: %v", err) + } + if !bytes.Equal(onDisk, files["icon.json"]) { + t.Error("the manifest on disk differs from the one rendered") + } +} diff --git a/appicon/writer.go b/appicon/writer.go @@ -0,0 +1,177 @@ +package appicon + +import ( + "bytes" + "encoding/json" + "fmt" + "image/png" + "os" + "path" + "path/filepath" + "strings" +) + +// manifest is the file name the bundle's directory holds beside its assets. +const manifest = "icon.json" + +// assets is the directory within the bundle that holds the layer images. +const assets = "Assets" + +// Files renders the bundle as the files its directory holds, keyed by their +// path within it. The manifest is at icon.json and every layer's image is +// under Assets. +func (b Bundle) Files() (map[string][]byte, error) { + doc, names, err := b.document() + if err != nil { + return nil, err + } + encoded, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return nil, fmt.Errorf("writing the manifest: %w", err) + } + out := map[string][]byte{manifest: append(encoded, '\n')} + for i, group := range b.Groups { + for j, layer := range group.Layers { + buf := bytes.NewBuffer(nil) + if err := png.Encode(buf, layer.Image); err != nil { + return nil, fmt.Errorf("writing layer %s: %w", names[i][j], err) + } + out[path.Join(assets, names[i][j])] = buf.Bytes() + } + } + return out, nil +} + +// Write writes the bundle into dir, which is created along with the assets +// directory beneath it. The directory is the bundle, so its name is what +// carries the .icon extension. +func (b Bundle) Write(dir string) error { + files, err := b.Files() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(dir, assets), 0o755); err != nil { + return fmt.Errorf("preparing the bundle: %w", err) + } + for name, data := range files { + at := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.WriteFile(at, data, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", name, err) + } + } + return nil +} + +// document builds the manifest and the file name chosen for every layer, +// which the caller needs to write the images where the manifest says. +func (b Bundle) document() (icon, [][]string, error) { + var layers int + for _, group := range b.Groups { + layers += len(group.Layers) + } + if layers == 0 { + return icon{}, nil, ErrNoLayers + } + doc := icon{ + Fill: b.Fill, + Platforms: platforms{Squares: b.Platforms}, + } + if doc.Fill == "" { + doc.Fill = FillAutomatic + } + if len(doc.Platforms.Squares) == 0 { + doc.Platforms.Squares = []string{PlatformMac} + } + var ( + names = make([][]string, len(b.Groups)) + taken = make(map[string]bool, layers) + n int + ) + for i, group := range b.Groups { + names[i] = make([]string, len(group.Layers)) + out := jsonGroup{Layers: make([]jsonLayer, len(group.Layers))} + for j, layer := range group.Layers { + if layer.Image == nil { + return icon{}, nil, fmt.Errorf("layer %q has no image", layer.Name) + } + n++ + name := layerName(layer.Name, n) + if taken[name] { + return icon{}, nil, fmt.Errorf("%w: %q", ErrDuplicateLayer, name) + } + taken[name] = true + file := name + ".png" + names[i][j] = file + out.Layers[j] = jsonLayer{ + Glass: layer.Glass, + ImageName: file, + Name: name, + } + } + if s := group.Shadow; s != nil { + kind := s.Kind + if kind == "" { + kind = ShadowNeutral + } + out.Shadow = &jsonShadow{Kind: kind, Opacity: s.Opacity} + } + if t := group.Translucency; t != nil { + out.Translucency = &jsonTranslucency{Enabled: t.Enabled, Value: t.Value} + } + doc.Groups = append(doc.Groups, out) + } + return doc, names, nil +} + +// layerName renders a layer's name as something that can also be a file name, +// falling back to its position when nothing usable is left. +func layerName(name string, position int) string { + clean := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + return r + case r == '-', r == '_', r == ' ': + return r + } + return -1 + }, name) + clean = strings.TrimSpace(clean) + if clean == "" { + return fmt.Sprintf("Layer %d", position) + } + return clean +} + +// The manifest's shape. The names are the ones Icon Composer writes, which +// is what actool reads. +type icon struct { + Fill string `json:"fill"` + Groups []jsonGroup `json:"groups"` + Platforms platforms `json:"supported-platforms"` +} + +type platforms struct { + Squares []string `json:"squares"` +} + +type jsonGroup struct { + Layers []jsonLayer `json:"layers"` + Shadow *jsonShadow `json:"shadow,omitempty"` + Translucency *jsonTranslucency `json:"translucency,omitempty"` +} + +type jsonLayer struct { + Glass bool `json:"glass"` + ImageName string `json:"image-name"` + Name string `json:"name"` +} + +type jsonShadow struct { + Kind string `json:"kind"` + Opacity float64 `json:"opacity"` +} + +type jsonTranslucency struct { + Enabled bool `json:"enabled"` + Value float64 `json:"value"` +}