Add an ICNS encoder and favicon discovery for app icons

A pure-Go .icns writer (PNG-embedded entries from 16 to 1024) and a finder that
digs the site's favicon out of a cloned mirror, preferring a large apple-touch
icon and unwrapping a PNG-based .ico. These feed the bundle icon for the upcoming
app formats. Adds golang.org/x/image for high-quality resampling.
This commit is contained in:
Duc-Tam Nguyen
2026-06-15 00:42:40 +07:00
parent 0cffea568a
commit 09543d1e11
6 changed files with 446 additions and 0 deletions
+1
View File
@@ -10,6 +10,7 @@ require (
github.com/klauspost/compress v1.18.6
github.com/spf13/cobra v1.10.2
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
golang.org/x/image v0.42.0
golang.org/x/net v0.56.0
)
+2
View File
@@ -87,6 +87,8 @@ github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+92
View File
@@ -0,0 +1,92 @@
package pack
import (
"bytes"
"encoding/binary"
"fmt"
"image"
"image/png"
xdraw "golang.org/x/image/draw"
)
// An .icns file is a tiny container: the magic "icns", a uint32 big-endian total
// length, then a run of entries. Each entry is a four-byte OSType, a uint32
// big-endian length covering the 8-byte entry header plus the payload, and the
// payload itself. Since OS X 10.7 the payload may be a PNG, which lets us avoid
// the old packed-RGBA formats entirely and just store one PNG per size.
//
// We emit the retina-era PNG OSTypes. macOS picks whichever size it needs for
// the Dock, Finder, and Cmd-Tab, so covering 16 through 1024 keeps the icon
// crisp everywhere without shipping a huge file.
var icnsSizes = []struct {
osType string
px int
}{
{"icp4", 16},
{"icp5", 32},
{"icp6", 64},
{"ic07", 128},
{"ic08", 256},
{"ic09", 512},
{"ic10", 1024},
}
// EncodeICNS renders img into a macOS .icns at every standard size. The source
// is scaled to each size with Catmull-Rom resampling, which keeps a small
// favicon from turning to mush when it is enlarged for the Dock. It returns an
// error only if img is empty or a PNG fails to encode.
func EncodeICNS(img image.Image) ([]byte, error) {
if img == nil || img.Bounds().Empty() {
return nil, fmt.Errorf("pack: icns source image is empty")
}
var body bytes.Buffer
for _, s := range icnsSizes {
scaled := scaleSquare(img, s.px)
var pngBuf bytes.Buffer
if err := png.Encode(&pngBuf, scaled); err != nil {
return nil, fmt.Errorf("pack: encode %s icon: %w", s.osType, err)
}
body.WriteString(s.osType)
writeU32BE(&body, uint32(8+pngBuf.Len()))
body.Write(pngBuf.Bytes())
}
var out bytes.Buffer
out.WriteString("icns")
writeU32BE(&out, uint32(8+body.Len()))
out.Write(body.Bytes())
return out.Bytes(), nil
}
// scaleSquare returns img resampled to a px-by-px RGBA image. A non-square
// source is fitted into the square and centred, so a wide or tall favicon is not
// stretched.
func scaleSquare(img image.Image, px int) image.Image {
dst := image.NewRGBA(image.Rect(0, 0, px, px))
src := img.Bounds()
sw, sh := src.Dx(), src.Dy()
// Scale to fit, preserving aspect ratio.
scale := float64(px) / float64(sw)
if float64(sh)*scale > float64(px) {
scale = float64(px) / float64(sh)
}
dw, dh := int(float64(sw)*scale), int(float64(sh)*scale)
if dw < 1 {
dw = 1
}
if dh < 1 {
dh = 1
}
off := image.Pt((px-dw)/2, (px-dh)/2)
rect := image.Rectangle{Min: off, Max: off.Add(image.Pt(dw, dh))}
xdraw.CatmullRom.Scale(dst, rect, img, src, xdraw.Over, nil)
return dst
}
func writeU32BE(w *bytes.Buffer, v uint32) {
var b [4]byte
binary.BigEndian.PutUint32(b[:], v)
w.Write(b[:])
}
+73
View File
@@ -0,0 +1,73 @@
package pack
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/png"
"testing"
)
// solid returns a w-by-h image filled with one colour, enough to exercise the
// encoder without depending on any fixture file.
func solid(w, h int, c color.Color) image.Image {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, c)
}
}
return img
}
func TestEncodeICNS(t *testing.T) {
data, err := EncodeICNS(solid(48, 48, color.RGBA{R: 0x33, G: 0x66, B: 0x99, A: 0xff}))
if err != nil {
t.Fatal(err)
}
if string(data[:4]) != "icns" {
t.Fatalf("magic = %q, want icns", data[:4])
}
if got := binary.BigEndian.Uint32(data[4:8]); int(got) != len(data) {
t.Fatalf("header length = %d, want %d", got, len(data))
}
// Walk the entries and confirm each declared OSType is present and its PNG
// decodes to the size it claims.
seen := map[string]int{}
off := 8
for off < len(data) {
osType := string(data[off : off+4])
size := int(binary.BigEndian.Uint32(data[off+4 : off+8]))
if size < 8 || off+size > len(data) {
t.Fatalf("entry %s has bogus length %d at offset %d", osType, size, off)
}
img, err := png.Decode(bytes.NewReader(data[off+8 : off+size]))
if err != nil {
t.Fatalf("entry %s payload is not a PNG: %v", osType, err)
}
seen[osType] = img.Bounds().Dx()
off += size
}
for _, s := range icnsSizes {
px, ok := seen[s.osType]
if !ok {
t.Errorf("missing OSType %s", s.osType)
continue
}
if px != s.px {
t.Errorf("OSType %s is %dpx, want %d", s.osType, px, s.px)
}
}
}
func TestEncodeICNSEmpty(t *testing.T) {
if _, err := EncodeICNS(nil); err == nil {
t.Fatal("EncodeICNS(nil) should error")
}
if _, err := EncodeICNS(image.NewRGBA(image.Rect(0, 0, 0, 0))); err == nil {
t.Fatal("EncodeICNS(empty) should error")
}
}
+143
View File
@@ -0,0 +1,143 @@
package pack
import (
"bytes"
"encoding/binary"
"fmt"
"image"
"io/fs"
"os"
"path/filepath"
"strings"
// Register the decoders image.Decode dispatches to. A favicon is almost
// always one of these; SVG and legacy BMP-in-ICO are handled (or skipped)
// explicitly below.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
)
// iconNames ranks the file names sites use for their icon, best first. A large
// PNG (an apple-touch icon, typically 180px) makes a far better app icon than a
// 16px favicon.ico, so we prefer those even though .ico is the classic name.
var iconNames = []string{
"apple-touch-icon-precomposed.png",
"apple-touch-icon.png",
"icon.png",
"favicon.png",
"favicon.ico",
}
// FindIcon looks through a cloned mirror for the site's icon and decodes it. It
// returns the image, the path it came from (for a friendly log line), and
// ok=false when nothing usable is found, in which case the caller just builds a
// bundle with the default icon. Discovery never fails the pack.
func FindIcon(mirrorDir string) (image.Image, string, bool) {
for _, name := range iconNames {
for _, p := range globIcon(mirrorDir, name) {
if img, err := DecodeIcon(p); err == nil {
return img, p, true
}
}
}
return nil, "", false
}
// globIcon returns every file under dir whose base name equals name, nearest the
// root first. Clones store assets under rewritten paths, so the icon may sit a
// few directories deep rather than at the mirror root.
func globIcon(dir, name string) []string {
var hits []string
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if strings.EqualFold(d.Name(), name) {
hits = append(hits, p)
}
return nil
})
// Shallower paths (fewer separators) are likelier to be the real site icon.
for i := 1; i < len(hits); i++ {
for j := i; j > 0 && depth(hits[j]) < depth(hits[j-1]); j-- {
hits[j], hits[j-1] = hits[j-1], hits[j]
}
}
return hits
}
func depth(p string) int { return strings.Count(p, string(filepath.Separator)) }
// DecodeIcon reads an icon file into an image. It handles the stdlib raster
// formats (PNG, JPEG, GIF) directly and unwraps a PNG stored inside a .ico
// container, which is how modern sites ship a high-resolution favicon.ico. A
// legacy BMP-only .ico returns an error rather than a mangled image, so the
// caller falls back to the default icon.
func DecodeIcon(path string) (image.Image, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if isICO(data) {
png, err := largestPNGInICO(data)
if err != nil {
return nil, err
}
data = png
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("pack: decode icon %q: %w", path, err)
}
return img, nil
}
// isICO reports whether data begins with an ICONDIR header: reserved 0, type 1
// (icon), and a non-zero image count.
func isICO(data []byte) bool {
return len(data) >= 6 &&
data[0] == 0 && data[1] == 0 &&
data[2] == 1 && data[3] == 0 &&
(uint16(data[4])|uint16(data[5])<<8) > 0
}
var pngMagic = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
// largestPNGInICO scans an .ico directory for PNG-encoded entries and returns
// the bytes of the largest one. It ignores BMP entries; if none of the entries
// are PNG it returns an error.
func largestPNGInICO(data []byte) ([]byte, error) {
count := int(binary.LittleEndian.Uint16(data[4:6]))
var best []byte
var bestArea int
for i := 0; i < count; i++ {
e := 6 + i*16
if e+16 > len(data) {
break
}
w, h := int(data[e]), int(data[e+1])
if w == 0 {
w = 256
}
if h == 0 {
h = 256
}
size := int(binary.LittleEndian.Uint32(data[e+8 : e+12]))
off := int(binary.LittleEndian.Uint32(data[e+12 : e+16]))
if off < 0 || size <= 0 || off+size > len(data) {
continue
}
chunk := data[off : off+size]
if !bytes.HasPrefix(chunk, pngMagic) {
continue // a BMP/DIB entry; skip it
}
if w*h > bestArea {
bestArea, best = w*h, chunk
}
}
if best == nil {
return nil, fmt.Errorf("pack: .ico holds no PNG entry")
}
return best, nil
}
+135
View File
@@ -0,0 +1,135 @@
package pack
import (
"bytes"
"encoding/binary"
"image/color"
"image/png"
"os"
"path/filepath"
"testing"
)
// pngBytes encodes a solid square as PNG, used to seed icon fixtures.
func pngBytes(t *testing.T, px int) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, solid(px, px, color.RGBA{R: 0x10, G: 0x80, B: 0x40, A: 0xff})); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// leWrite appends v to w in little-endian, swallowing the error a bytes.Buffer
// never returns. It keeps the .ico fixtures readable without an errcheck flag on
// every field.
func leWrite(w *bytes.Buffer, v any) { _ = binary.Write(w, binary.LittleEndian, v) }
// icoWithPNG wraps PNG payloads in a minimal .ico container so the ICO path is
// exercised without a binary fixture on disk.
func icoWithPNG(pngs [][]byte) []byte {
var dir, body bytes.Buffer
put := leWrite
put(&dir, uint16(0)) // reserved
put(&dir, uint16(1)) // type: icon
put(&dir, uint16(len(pngs))) // count
offset := 6 + len(pngs)*16
for i, p := range pngs {
// width/height bytes: 0 means 256; use a distinct small size per entry.
dim := byte(16 * (i + 1))
dir.WriteByte(dim) // width
dir.WriteByte(dim) // height
dir.WriteByte(0) // colours
dir.WriteByte(0) // reserved
put(&dir, uint16(1)) // planes
put(&dir, uint16(32)) // bit count
put(&dir, uint32(len(p))) // bytes in resource
put(&dir, uint32(offset)) // offset
offset += len(p)
body.Write(p)
}
return append(dir.Bytes(), body.Bytes()...)
}
func TestDecodeIconPNG(t *testing.T) {
p := filepath.Join(t.TempDir(), "favicon.png")
if err := os.WriteFile(p, pngBytes(t, 64), 0o644); err != nil {
t.Fatal(err)
}
img, err := DecodeIcon(p)
if err != nil {
t.Fatal(err)
}
if img.Bounds().Dx() != 64 {
t.Errorf("decoded width = %d, want 64", img.Bounds().Dx())
}
}
func TestDecodeIconICOWithPNG(t *testing.T) {
// Two PNG entries; the decoder should pick the larger (second, 32px square).
ico := icoWithPNG([][]byte{pngBytes(t, 16), pngBytes(t, 32)})
p := filepath.Join(t.TempDir(), "favicon.ico")
if err := os.WriteFile(p, ico, 0o644); err != nil {
t.Fatal(err)
}
img, err := DecodeIcon(p)
if err != nil {
t.Fatal(err)
}
if img.Bounds().Dx() != 32 {
t.Errorf("decoded width = %d, want the larger 32px entry", img.Bounds().Dx())
}
}
func TestDecodeIconBMPOnlyICOFails(t *testing.T) {
// A one-entry .ico whose payload is not a PNG (here just filler) must error
// so the caller falls back to the default icon.
var ico bytes.Buffer
leWrite(&ico, uint16(0))
leWrite(&ico, uint16(1))
leWrite(&ico, uint16(1))
ico.Write([]byte{32, 32, 0, 0})
leWrite(&ico, uint16(1))
leWrite(&ico, uint16(32))
leWrite(&ico, uint32(4))
leWrite(&ico, uint32(22))
ico.Write([]byte{0x42, 0x4d, 0x00, 0x00}) // "BM" DIB filler
p := filepath.Join(t.TempDir(), "favicon.ico")
if err := os.WriteFile(p, ico.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
if _, err := DecodeIcon(p); err == nil {
t.Fatal("BMP-only .ico should fail to decode")
}
}
func TestFindIconPrefersAppleTouch(t *testing.T) {
dir := t.TempDir()
// A tiny favicon at the root and a bigger apple-touch icon a level down.
if err := os.WriteFile(filepath.Join(dir, "favicon.png"), pngBytes(t, 16), 0o644); err != nil {
t.Fatal(err)
}
sub := filepath.Join(dir, "assets")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "apple-touch-icon.png"), pngBytes(t, 180), 0o644); err != nil {
t.Fatal(err)
}
img, src, ok := FindIcon(dir)
if !ok {
t.Fatal("FindIcon found nothing")
}
if filepath.Base(src) != "apple-touch-icon.png" {
t.Errorf("picked %s, want apple-touch-icon.png", filepath.Base(src))
}
if img.Bounds().Dx() != 180 {
t.Errorf("width = %d, want 180", img.Bounds().Dx())
}
}
func TestFindIconNone(t *testing.T) {
if _, _, ok := FindIcon(t.TempDir()); ok {
t.Fatal("FindIcon should report nothing in an empty dir")
}
}