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:
1 parent
a0f91c2d64
commit
2e77cf0a11
8 files changed
+395
No files matched your search
@@ -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 |
@@ -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.
@@ -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
|
||||
}
|
||||
@@ -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 |
Reference in new issue
Block a user