diff --git a/assets/icon/gen.go b/assets/icon/gen.go new file mode 100644 index 0000000..3de99d0 --- /dev/null +++ b/assets/icon/gen.go @@ -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_.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() +} diff --git a/assets/icon/talk.ico b/assets/icon/talk.ico new file mode 100644 index 0000000..926feb1 Binary files /dev/null and b/assets/icon/talk.ico differ diff --git a/assets/icon/talk.svg b/assets/icon/talk.svg new file mode 100644 index 0000000..af7fef8 --- /dev/null +++ b/assets/icon/talk.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cmd/talk/rsrc_windows_amd64.syso b/cmd/talk/rsrc_windows_amd64.syso new file mode 100644 index 0000000..829bef5 Binary files /dev/null and b/cmd/talk/rsrc_windows_amd64.syso differ diff --git a/cmd/talk/rsrc_windows_arm64.syso b/cmd/talk/rsrc_windows_arm64.syso new file mode 100644 index 0000000..ce9286c Binary files /dev/null and b/cmd/talk/rsrc_windows_arm64.syso differ diff --git a/internal/icon/icon.go b/internal/icon/icon.go new file mode 100644 index 0000000..47aa43f --- /dev/null +++ b/internal/icon/icon.go @@ -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 +} diff --git a/internal/icon/icon_test.go b/internal/icon/icon_test.go new file mode 100644 index 0000000..3cb5ce9 --- /dev/null +++ b/internal/icon/icon_test.go @@ -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") + } +} diff --git a/internal/icon/talk.png b/internal/icon/talk.png new file mode 100644 index 0000000..d1611f8 Binary files /dev/null and b/internal/icon/talk.png differ