From 09543d1e111f7c91ca94b958aec348aa4fe2f88d Mon Sep 17 00:00:00 2001 From: Duc-Tam Nguyen Date: Mon, 15 Jun 2026 00:42:40 +0700 Subject: [PATCH] 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. --- go.mod | 1 + go.sum | 2 + pack/icns.go | 92 +++++++++++++++++++++++++++++ pack/icns_test.go | 73 +++++++++++++++++++++++ pack/icon.go | 143 ++++++++++++++++++++++++++++++++++++++++++++++ pack/icon_test.go | 135 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 446 insertions(+) create mode 100644 pack/icns.go create mode 100644 pack/icns_test.go create mode 100644 pack/icon.go create mode 100644 pack/icon_test.go diff --git a/go.mod b/go.mod index e8bc238..11be999 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index f08a247..e099953 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pack/icns.go b/pack/icns.go new file mode 100644 index 0000000..26ef28a --- /dev/null +++ b/pack/icns.go @@ -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[:]) +} diff --git a/pack/icns_test.go b/pack/icns_test.go new file mode 100644 index 0000000..cf77da9 --- /dev/null +++ b/pack/icns_test.go @@ -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") + } +} diff --git a/pack/icon.go b/pack/icon.go new file mode 100644 index 0000000..03beb58 --- /dev/null +++ b/pack/icon.go @@ -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 +} diff --git a/pack/icon_test.go b/pack/icon_test.go new file mode 100644 index 0000000..0874170 --- /dev/null +++ b/pack/icon_test.go @@ -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") + } +}