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