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:
| A | appicon/appicon.go | | | 104 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | appicon/appicon_test.go | | | 217 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | appicon/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"`
+}