feat(icon): app icon with embedded PNG, Windows .ico and resource objects

One SVG source (assets/icon/talk.svg: speech bubble with level bars).
assets/icon/gen.go renders it with inkscape into the embedded PNG
(internal/icon, installed next to the config for notifications), the
multi-size talk.ico and the COFF .rsrc objects for windows/amd64 and
arm64 that give talk.exe its icon. The generator writes the resource
objects itself, so no resource compiler is needed; everything it
produces is committed and a plain go build picks it up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-25 12:43:14 +01:00
1 parent a0f91c2d64
commit 2e77cf0a11
8 files changed
+395

No files matched your search

+299
View File
@@ -0,0 +1,299 @@
//go:build ignore
// gen renders the talk icon from assets/icon/talk.svg into everything the
// build embeds, so no resource compiler is needed:
//
// internal/icon/talk.png 256 px, embedded in the binary (toast
// and notification icon)
// assets/icon/talk.ico multi-size Windows icon
// cmd/talk/rsrc_windows_<arch>.syso COFF resource object with the icon;
// go build links it into talk.exe
//
// Run from the repository root (needs inkscape in PATH):
//
// go run assets/icon/gen.go
package main
import (
"bytes"
"encoding/binary"
"fmt"
"image/color"
"image/png"
"os"
"os/exec"
"path/filepath"
)
// sizes are the icon sizes Windows asks for (Explorer, taskbar, Alt-Tab,
// toast header); 256 is stored as PNG, the rest as plain bitmaps.
var sizes = []int{16, 20, 24, 32, 40, 48, 64, 128, 256}
const svgPath = "assets/icon/talk.svg"
// iconImage is one size of the icon in the form ICO files and RT_ICON
// resources store it.
type iconImage struct {
size int
data []byte // PNG (256) or BITMAPINFOHEADER + pixels + mask
}
func main() {
if _, err := os.Stat("go.mod"); err != nil {
fail("run from the repository root: %v", err)
}
tmp, err := os.MkdirTemp("", "talk-icon-")
if err != nil {
fail("%v", err)
}
defer os.RemoveAll(tmp)
var images []iconImage
for _, s := range sizes {
out := filepath.Join(tmp, fmt.Sprintf("%d.png", s))
cmd := exec.Command("inkscape", svgPath, "-w", fmt.Sprint(s), "-h", fmt.Sprint(s), "-o", out)
if b, err := cmd.CombinedOutput(); err != nil {
fail("inkscape %d px: %v: %s", s, err, b)
}
b, err := os.ReadFile(out)
if err != nil {
fail("%v", err)
}
if s == 256 {
write("internal/icon/talk.png", b)
images = append(images, iconImage{s, b})
continue
}
dib, err := toDIB(b)
if err != nil {
fail("%d px: %v", s, err)
}
images = append(images, iconImage{s, dib})
}
write("assets/icon/talk.ico", buildICO(images))
for arch, machine := range map[string]uint16{"amd64": 0x8664, "arm64": 0xAA64} {
write("cmd/talk/rsrc_windows_"+arch+".syso", buildSyso(machine, images))
}
}
func write(path string, b []byte) {
if err := os.WriteFile(path, b, 0o644); err != nil {
fail("%v", err)
}
fmt.Printf("wrote %s (%d bytes)\n", path, len(b))
}
func fail(format string, a ...any) {
fmt.Fprintf(os.Stderr, "gen: "+format+"\n", a...)
os.Exit(1)
}
// toDIB converts a PNG into the classic icon bitmap: BITMAPINFOHEADER with
// doubled height, 32-bit BGRA rows bottom-up, then the 1-bit AND mask.
func toDIB(pngData []byte) ([]byte, error) {
img, err := png.Decode(bytes.NewReader(pngData))
if err != nil {
return nil, err
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
maskRow := ((w + 31) / 32) * 4
var buf bytes.Buffer
hdr := []uint32{40, uint32(w), uint32(2 * h)}
binary.Write(&buf, binary.LittleEndian, hdr)
binary.Write(&buf, binary.LittleEndian, []uint16{1, 32}) // planes, bit count
binary.Write(&buf, binary.LittleEndian, []uint32{0, uint32(w*h*4 + maskRow*h), 0, 0, 0, 0})
for y := h - 1; y >= 0; y-- {
for x := 0; x < w; x++ {
c := color.NRGBAModel.Convert(img.At(b.Min.X+x, b.Min.Y+y)).(color.NRGBA)
buf.Write([]byte{c.B, c.G, c.R, c.A})
}
}
for y := h - 1; y >= 0; y-- {
row := make([]byte, maskRow)
for x := 0; x < w; x++ {
if _, _, _, a := img.At(b.Min.X+x, b.Min.Y+y).RGBA(); a == 0 {
row[x/8] |= 0x80 >> (x % 8)
}
}
buf.Write(row)
}
return buf.Bytes(), nil
}
// dirEntry writes the 12 bytes ICONDIRENTRY and GRPICONDIRENTRY share.
func dirEntry(buf *bytes.Buffer, im iconImage) {
dim := byte(im.size)
if im.size >= 256 {
dim = 0
}
buf.Write([]byte{dim, dim, 0, 0})
binary.Write(buf, binary.LittleEndian, []uint16{1, 32})
binary.Write(buf, binary.LittleEndian, uint32(len(im.data)))
}
// buildICO lays out an .ico file: ICONDIR, one ICONDIRENTRY per image with
// the file offset of its data, then the images.
func buildICO(images []iconImage) []byte {
var buf bytes.Buffer
binary.Write(&buf, binary.LittleEndian, []uint16{0, 1, uint16(len(images))})
offset := 6 + 16*len(images)
for _, im := range images {
dirEntry(&buf, im)
binary.Write(&buf, binary.LittleEndian, uint32(offset))
offset += len(im.data)
}
for _, im := range images {
buf.Write(im.data)
}
return buf.Bytes()
}
// buildGroupIcon is the RT_GROUP_ICON payload: like ICONDIR, but each entry
// ends with the RT_ICON resource id instead of a file offset.
func buildGroupIcon(images []iconImage) []byte {
var buf bytes.Buffer
binary.Write(&buf, binary.LittleEndian, []uint16{0, 1, uint16(len(images))})
for i, im := range images {
dirEntry(&buf, im)
binary.Write(&buf, binary.LittleEndian, uint16(i+1))
}
return buf.Bytes()
}
const (
rtIcon = 3
rtGroupIcon = 14
langNeutral = 0x0409
)
// resource is one leaf of the .rsrc tree: type/id/language and its data.
type resource struct {
typ, id uint32
data []byte
}
// buildSyso writes a COFF object with a single .rsrc section the Go linker
// merges into the PE resource table: the directory tree (type → id →
// language → data entry), the data, one ADDR32NB relocation per data entry
// (the linker turns the section offsets into image RVAs), and the section
// symbol the relocations refer to.
func buildSyso(machine uint16, images []iconImage) []byte {
var res []resource
for i, im := range images {
res = append(res, resource{rtIcon, uint32(i + 1), im.data})
}
res = append(res, resource{rtGroupIcon, 1, buildGroupIcon(images)})
// Layout: root dir, the two type dirs, one language dir per resource,
// one data entry per resource, then the data.
const dirHdr, dirEnt, dataEnt = 16, 8, 16
types := []uint32{rtIcon, rtGroupIcon}
byType := map[uint32][]resource{}
for _, r := range res {
byType[r.typ] = append(byType[r.typ], r)
}
rootOff := uint32(0)
typeDirOff := map[uint32]uint32{}
off := uint32(dirHdr + dirEnt*len(types))
for _, t := range types {
typeDirOff[t] = off
off += uint32(dirHdr + dirEnt*len(byType[t]))
}
langDirOff := make([]uint32, len(res))
for i := range res {
langDirOff[i] = off
off += dirHdr + dirEnt
}
dataEntOff := make([]uint32, len(res))
for i := range res {
dataEntOff[i] = off
off += dataEnt
}
dataOff := make([]uint32, len(res))
for i, r := range res {
off = (off + 7) &^ 7
dataOff[i] = off
off += uint32(len(r.data))
}
sectionSize := off
var sec bytes.Buffer
le := binary.LittleEndian
dir := func(entries int) {
binary.Write(&sec, le, []uint32{0, 0})
binary.Write(&sec, le, []uint16{0, 0, 0, uint16(entries)})
}
sub := func(id, target uint32) { binary.Write(&sec, le, []uint32{id, target | 0x80000000}) }
leaf := func(id, target uint32) { binary.Write(&sec, le, []uint32{id, target}) }
index := func(r resource) int {
for i := range res {
if res[i].typ == r.typ && res[i].id == r.id {
return i
}
}
panic("unreachable")
}
_ = rootOff
dir(len(types))
for _, t := range types {
sub(t, typeDirOff[t])
}
for _, t := range types {
dir(len(byType[t]))
for _, r := range byType[t] {
sub(r.id, langDirOff[index(r)])
}
}
for i := range res {
dir(1)
leaf(langNeutral, dataEntOff[i])
}
var relocs []uint32
for i, r := range res {
relocs = append(relocs, uint32(sec.Len()))
binary.Write(&sec, le, []uint32{dataOff[i], uint32(len(r.data)), 0, 0})
}
for i, r := range res {
sec.Write(make([]byte, int(dataOff[i])-sec.Len()))
sec.Write(r.data)
}
if uint32(sec.Len()) != sectionSize {
fail("section layout mismatch: %d != %d", sec.Len(), sectionSize)
}
relocType := uint16(0x0003) // IMAGE_REL_AMD64_ADDR32NB
if machine == 0xAA64 {
relocType = 0x0002 // IMAGE_REL_ARM64_ADDR32NB
}
const fileHdr, secHdr, relocSize, symSize = 20, 40, 10, 18
rawOff := uint32(fileHdr + secHdr)
relocOff := rawOff + sectionSize
symOff := relocOff + uint32(relocSize*len(relocs))
var out bytes.Buffer
binary.Write(&out, le, machine)
binary.Write(&out, le, uint16(1)) // sections
binary.Write(&out, le, uint32(0)) // timestamp
binary.Write(&out, le, symOff) // symbol table
binary.Write(&out, le, uint32(1)) // symbols
binary.Write(&out, le, uint16(0)) // optional header
binary.Write(&out, le, uint16(0x0104)) // 32-bit machine, line numbers stripped
name := [8]byte{'.', 'r', 's', 'r', 'c'}
out.Write(name[:])
binary.Write(&out, le, []uint32{0, 0, sectionSize, rawOff, relocOff, 0})
binary.Write(&out, le, []uint16{uint16(len(relocs)), 0})
binary.Write(&out, le, uint32(0x40000040)) // initialized data, readable
out.Write(sec.Bytes())
for _, at := range relocs {
binary.Write(&out, le, []uint32{at, 0})
binary.Write(&out, le, relocType)
}
out.Write(name[:])
binary.Write(&out, le, uint32(0)) // value
binary.Write(&out, le, int16(1)) // section number
binary.Write(&out, le, uint16(0)) // type
out.Write([]byte{3, 0}) // static, no aux symbols
binary.Write(&out, le, uint32(4)) // empty string table
return out.Bytes()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<path fill="#0F6E86" d="M56 28h144c22 0 40 18 40 40v96c0 22-18 40-40 40H112l-48 36c-6 5-16 1-16-8v-28H56c-22 0-40-18-40-40V68c0-22 18-40 40-40z"/>
<g fill="#FFFFFF">
<rect x="76" y="100" width="24" height="56" rx="12"/>
<rect x="116" y="72" width="24" height="112" rx="12"/>
<rect x="156" y="88" width="24" height="80" rx="12"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
// Package icon carries the talk icon for the desktop: the PNG is embedded
// in the binary and written next to the config so the OS can show it in
// notifications (Windows toast identity, freedesktop app_icon). The source
// is assets/icon/talk.svg; `go run assets/icon/gen.go` renders it, the
// Windows .ico and the resource objects for talk.exe.
package icon
import (
"bytes"
_ "embed"
"errors"
"os"
"path/filepath"
)
//go:embed talk.png
var PNG []byte
// FileName is the icon's name in the config directory.
const FileName = "talk.png"
// Install writes the icon into dir (if missing or outdated) and returns its
// path. Idempotent: an unchanged file is not touched.
func Install(dir string) (string, error) {
if dir == "" {
return "", errors.New("icon: no directory")
}
path := filepath.Join(dir, FileName)
if cur, err := os.ReadFile(path); err == nil && bytes.Equal(cur, PNG) {
return path, nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
if err := os.WriteFile(path, PNG, 0o644); err != nil {
return "", err
}
return path, nil
}
+49
View File
@@ -0,0 +1,49 @@
package icon
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// TestInstall: the icon lands in the directory, is left alone when current,
// and is repaired when the file on disk differs.
func TestInstall(t *testing.T) {
if len(PNG) < 8 || !bytes.HasPrefix(PNG, []byte("\x89PNG")) {
t.Fatalf("embedded icon is not a PNG (%d bytes)", len(PNG))
}
dir := filepath.Join(t.TempDir(), "talk")
path, err := Install(dir)
if err != nil {
t.Fatal(err)
}
if path != filepath.Join(dir, FileName) {
t.Errorf("path = %q", path)
}
got, err := os.ReadFile(path)
if err != nil || !bytes.Equal(got, PNG) {
t.Fatalf("installed file differs (err %v)", err)
}
first, _ := os.Stat(path)
if _, err := Install(dir); err != nil {
t.Fatal(err)
}
second, _ := os.Stat(path)
if !second.ModTime().Equal(first.ModTime()) {
t.Error("unchanged icon was rewritten")
}
if err := os.WriteFile(path, []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Install(dir); err != nil {
t.Fatal(err)
}
got, _ = os.ReadFile(path)
if !bytes.Equal(got, PNG) {
t.Error("stale icon was not replaced")
}
if _, err := Install(""); err == nil {
t.Error("Install(\"\") succeeded")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB