icns

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

commit 83d064702dfec5f6f64f1db75151c6daa3c089c9
parent 5001ec05889189ad6f7463133ec7d80e5bb4da34
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Mon, 21 Sep 2026 07:41:15 -0300

appicon: spell a specialised value the way the manifest does

A specialised translucency marshalled through the exported type, which
carried no tags, so it went out with Go's spelling and actool read nothing
where it wanted enabled and value.

Diffstat:
Mappicon/appicon.go | 33++++++++++-----------------------
Mappicon/appicon_test.go | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mappicon/writer.go | 18++++--------------
3 files changed, 73 insertions(+), 37 deletions(-)

diff --git a/appicon/appicon.go b/appicon/appicon.go @@ -10,26 +10,13 @@ // 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. // -// # A crash worth knowing about +// # How actool reports a manifest it cannot read // -// actool from Xcode 26.6 crashes, rather than reporting a problem, on some -// bundles that specialise translucency for the default appearance and for -// tinted. It falls over with an exception from inside its own asset -// selection, naming nothing. -// -// Two arrangements are known to bring it down: a group carrying only a -// shadow and that pair of translucencies, and a bundle spreading the pair -// and the layer they apply to across two groups. Two are known to compile: -// the same values gathered into one group with the layer they apply to, -// which is how a shipping app arranges them, and the pair with a -// translucency for dark added. -// -// The trigger is not characterised beyond that, and it is not simply the -// group's own fields: a group carrying lighting and a blend mode alongside -// the pair compiles on its own and crashes in a bundle of two groups. So -// this package writes what it is given and leaves the judgement to the -// compiler. A bundle that fails with an exception rather than an error is -// worth rearranging before it is worth debugging. +// It does not. Given a value it cannot make sense of, actool from Xcode 26.6 +// falls over with an exception from inside its own asset selection, naming +// neither the field nor the file. A manifest that is merely well formed JSON +// proves nothing, which is why this package is held against the tool rather +// than against a schema. package appicon import ( @@ -117,16 +104,16 @@ type Layer struct { type Shadow struct { // Kind is how the shadow takes its colour, such as "neutral" or // "layer-color". - Kind string + Kind string `json:"kind"` // Opacity is how dark it is, from 0 to 1. - Opacity float64 + Opacity float64 `json:"opacity"` } // Translucency is how far a group lets what is behind it through. type Translucency struct { - Enabled bool + Enabled bool `json:"enabled"` // Value is the amount, from 0 to 1. - Value float64 + Value float64 `json:"value"` } // Defaults written where a bundle leaves a choice open. diff --git a/appicon/appicon_test.go b/appicon/appicon_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "image" "image/color" "image/png" @@ -272,6 +273,64 @@ func TestHiddenIsWrittenOnlyWhenSet(t *testing.T) { } } +// TestEveryKeyIsTheManifestSpelling walks the whole manifest for a key +// spelled the way Go spells a field rather than the way the manifest does. A +// value marshalled through a type that carries no tags looks right until +// actool reads it, and actool does not say which key it wanted. +func TestEveryKeyIsTheManifestSpelling(t *testing.T) { + material := 1.0 + b := Bundle{ + Fills: []Specialized[Fill]{{Value: NamedFill("system-light")}}, + Groups: []Group{{ + BlendModes: []Specialized[string]{{Appearance: AppearanceTinted, Value: "normal"}}, + BlurMaterials: []Specialized[float64]{{Value: material}}, + Lighting: "individual", + Specular: true, + Shadow: &Shadow{Kind: "layer-color", Opacity: 0.5}, + Translucencies: []Specialized[Translucency]{ + {Value: Translucency{Enabled: true, Value: 0.84}}, + {Appearance: AppearanceDark, Value: Translucency{Enabled: false, Value: 0.5}}, + }, + Layers: []Layer{{ + Name: "One", Image: art(16), Glass: true, Hidden: true, + Position: &Position{Scale: 1.24, Translation: [2]float64{0, -12}}, + Fills: []Specialized[Fill]{{Value: GradientFill("srgb:1,1,1,1")}}, + BlendModes: []Specialized[string]{{Appearance: AppearanceDark, Value: "lighten"}}, + }}, + }}, + } + // The plain forms travel a different path from the specialised ones, so + // both are walked. + plain := Bundle{Groups: []Group{{ + Layers: []Layer{{Name: "One", Image: art(16)}}, + Shadow: &Shadow{Kind: ShadowNeutral, Opacity: 0.5}, + Translucency: &Translucency{Enabled: true, Value: 0.5}, + }}} + for _, bundle := range []Bundle{b, plain} { + files, err := bundle.Files() + if err != nil { + t.Fatalf("rendering: %v", err) + } + var walk func(any, string) + walk = func(node any, path string) { + switch v := node.(type) { + case map[string]any: + for key, child := range v { + if key != "" && key[0] >= 'A' && key[0] <= 'Z' { + t.Errorf("%s.%s is spelled the way Go spells a field", path, key) + } + walk(child, path+"."+key) + } + case []any: + for i, child := range v { + walk(child, fmt.Sprintf("%s[%d]", path, i)) + } + } + } + walk(any(decode(t, files)), "") + } +} + func TestFilesRejectsWhatItCannotWrite(t *testing.T) { for _, tt := range []struct { name string diff --git a/appicon/writer.go b/appicon/writer.go @@ -132,10 +132,10 @@ func (b Bundle) document() (icon, [][]string, error) { if kind == "" { kind = ShadowNeutral } - out.Shadow = &jsonShadow{Kind: kind, Opacity: s.Opacity} + out.Shadow = &Shadow{Kind: kind, Opacity: s.Opacity} } if t := group.Translucency; t != nil && len(group.Translucencies) == 0 { - out.Translucency = &jsonTranslucency{Enabled: t.Enabled, Value: t.Value} + out.Translucency = t } doc.Groups = append(doc.Groups, out) } @@ -180,9 +180,9 @@ type jsonGroup struct { BlurMaterials []Specialized[float64] `json:"blur-material-specializations,omitempty"` Layers []jsonLayer `json:"layers"` Lighting string `json:"lighting,omitempty"` - Shadow *jsonShadow `json:"shadow,omitempty"` + Shadow *Shadow `json:"shadow,omitempty"` Specular bool `json:"specular,omitempty"` - Translucency *jsonTranslucency `json:"translucency,omitempty"` + Translucency *Translucency `json:"translucency,omitempty"` Translucencies []Specialized[Translucency] `json:"translucency-specializations,omitempty"` } @@ -195,13 +195,3 @@ type jsonLayer struct { Name string `json:"name"` Position *Position `json:"position,omitempty"` } - -type jsonShadow struct { - Kind string `json:"kind"` - Opacity float64 `json:"opacity"` -} - -type jsonTranslucency struct { - Enabled bool `json:"enabled"` - Value float64 `json:"value"` -}