feat(icons): clean tray badge set + Start menu/installer icon

New cmd/icon-gen renders the four state badges (Red/Yellow/Green/Blue) as a
rounded-square badge with a subtle vertical gradient and a crisp white theta,
256px with 4x4 supersampling. The tray embeds these (icons.go, generated) and
builds a proper multi-size Windows ICO (16..256) via exact box filtering --
replacing the old flat 48px circle and nearest-neighbour scaling.

The installer now bundles theta-agent.ico (multi-size, Blue badge) and uses it
for the Start menu 'Theta Agent Tray' shortcut, the setup.exe icon
(SetupIconFile), and the uninstaller display icon. Dead duplicate icon arrays
in the root package (tray_icons.go) removed.
This commit is contained in:
2026-08-10 18:49:30 -07:00
parent 1b332cacab
commit 12d55a4454
8 changed files with 3408 additions and 40 deletions
+7
View File
@@ -5,6 +5,13 @@ All notable changes to the `theta-agent` daemon will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- **New tray icon set** (`cmd/icon-gen`, generated `cmd/theta-agent-tray/icons.go`) — the state badges (Red/Yellow/Green/Blue) are now a rounded-square badge with a subtle vertical gradient and a crisp white theta glyph, rendered at 256px with 4x4 supersampling instead of the old flat 48px circle. Windows gets a proper multi-size ICO (16/24/32/48/64/128/256) built with an exact box filter (was nearest-neighbour over three sizes), so the tray/taskbar icon is sharp at every DPI.
- **Start menu / installer icon** — `installer/windows/theta-agent.ico` (multi-size, Blue badge) is bundled by the installer and used for the Start menu "Theta Agent Tray" shortcut, the setup.exe's own icon (`SetupIconFile`), and the uninstaller's display icon.
- Removed the dead duplicate icon byte arrays in the root package's `tray_icons.go` (nothing referenced them; the tray binary carries its own copy).
## [v2.2.0] - 2026-08-10
### Added
+327
View File
@@ -0,0 +1,327 @@
// Command icon-gen renders the theta-agent tray icon set (Red/Yellow/Green/
// Blue state badges) and writes:
//
// - <tray-dir>/icons.go — the Go source with the embedded 256x256 PNG byte
// arrays the tray binary compiles in (gofmt-formatted).
// - <tray-dir>/icon-*.png — a preview of each color, for eyeballing.
// - <ico-path> — the multi-size Windows .ico (16..256) of the Blue badge,
// used as the installer's own icon, the Start menu shortcut icon, and the
// uninstaller display icon.
//
// Design: a rounded-square badge in the state color with a subtle vertical
// gradient, and a white theta (ring + horizontal bar) glyph knocked out of it.
// Rendered at 256px with 4x4 supersampling so the edges are crisp at every
// downscaled size. Pure stdlib; run with:
//
// go run ./cmd/icon-gen cmd/theta-agent-tray installer/windows/theta-agent.ico
//
// The generated icons.go and theta-agent.ico must not be edited by hand.
package main
import (
"bytes"
"encoding/binary"
"fmt"
"go/format"
"image"
"image/color"
"image/png"
"math"
"os"
"path/filepath"
"strings"
)
const size = 256
type badgeColor struct {
name string
base color.RGBA
}
var palette = []badgeColor{
{"Red", color.RGBA{0xEF, 0x44, 0x44, 0xFF}},
{"Yellow", color.RGBA{0xEA, 0xB3, 0x08, 0xFF}},
{"Green", color.RGBA{0x22, 0xC5, 0x5E, 0xFF}},
{"Blue", color.RGBA{0x3B, 0x82, 0xF6, 0xFF}},
}
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: go run ./cmd/icon-gen <tray-package-dir> <ico-output-path>")
os.Exit(2)
}
dir := os.Args[1]
icoPath := os.Args[2]
// The Blue badge doubles as the product/app icon (Start menu shortcut,
// installer, uninstaller).
var blue *image.RGBA
var src strings.Builder
src.WriteString("// Code generated by cmd/icon-gen; DO NOT EDIT.\n")
src.WriteString("\npackage main\n\n")
src.WriteString("// Theta Agent tray state badges, 256x256 PNG.\n")
src.WriteString("var (\n")
for _, c := range palette {
img := renderBadge(c.base)
preview := filepath.Join(dir, "icon-"+strings.ToLower(c.name)+".png")
writePNG(img, preview)
if c.name == "Blue" {
blue = img
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
fmt.Fprintln(os.Stderr, "encode:", err)
os.Exit(1)
}
src.WriteString(fmt.Sprintf("\ticon%s = %s\n", c.name, goBytes(buf.Bytes())))
}
src.WriteString(")\n")
out := filepath.Join(dir, "icons.go")
formatted, err := format.Source([]byte(src.String()))
if err != nil {
fmt.Fprintln(os.Stderr, "format:", err)
os.Exit(1)
}
if err := os.WriteFile(out, formatted, 0644); err != nil {
fmt.Fprintln(os.Stderr, "write:", err)
os.Exit(1)
}
fmt.Println("wrote", out)
if err := writeICO(blue, icoPath); err != nil {
fmt.Fprintln(os.Stderr, "ico:", err)
os.Exit(1)
}
fmt.Println("wrote", icoPath)
}
// renderBadge draws the state badge at `size` with 4x4 supersampling.
func renderBadge(base color.RGBA) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, size, size))
white := color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}
top := mix(base, white, 0.16)
bottom := mix(base, color.RGBA{0, 0, 0, 0xFF}, 0.20)
const margin = 8.0
const corner = 52.0
cx, cy := size/2.0, size/2.0
half := (float64(size) - 2*margin) / 2
// Theta glyph geometry (ring + horizontal bar, bar extends past the ring).
const ringOuter = 80.0
const ringInner = 56.0
const barHalf = 12.0
const barLen = 86.0
const ss = 4 // supersample factor
for py := 0; py < size; py++ {
for px := 0; px < size; px++ {
var accR, accG, accB, accA float64
for sy := 0; sy < ss; sy++ {
for sx := 0; sx < ss; sx++ {
x := float64(px) + (float64(sx)+0.5)/ss
y := float64(py) + (float64(sy)+0.5)/ss
if !inRoundedRect(x, y, cx, cy, half, corner) {
continue // transparent outside the badge
}
t := clamp01((y - (cy - half)) / (2 * half))
col := lerp(top, bottom, t)
if inTheta(x, y, cx, cy, ringOuter, ringInner, barHalf, barLen) {
col = white
}
accR += float64(col.R)
accG += float64(col.G)
accB += float64(col.B)
accA += 255
}
}
n := float64(ss * ss)
img.SetRGBA(px, py, color.RGBA{
R: uint8(accR / n),
G: uint8(accG / n),
B: uint8(accB / n),
A: uint8(accA / n),
})
}
}
return img
}
func inRoundedRect(x, y, cx, cy, half, r float64) bool {
dx := math.Abs(x-cx) - (half - r)
dy := math.Abs(y-cy) - (half - r)
if dx <= 0 && dy <= 0 {
return true
}
return dx*dx+dy*dy <= r*r
}
func inTheta(x, y, cx, cy, ringOuter, ringInner, barHalf, barLen float64) bool {
d := math.Hypot(x-cx, y-cy)
inRing := d >= ringInner && d <= ringOuter
inBar := math.Abs(y-cy) <= barHalf && math.Abs(x-cx) <= barLen
return inRing || inBar
}
func mix(a, b color.RGBA, t float64) color.RGBA {
return color.RGBA{
R: uint8(float64(a.R) + (float64(b.R)-float64(a.R))*t),
G: uint8(float64(a.G) + (float64(b.G)-float64(a.G))*t),
B: uint8(float64(a.B) + (float64(b.B)-float64(a.B))*t),
A: 0xFF,
}
}
func lerp(a, b color.RGBA, t float64) color.RGBA {
return mix(a, b, t)
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func writePNG(img *image.RGBA, path string) {
f, err := os.Create(path)
if err != nil {
fmt.Fprintln(os.Stderr, "create:", err)
return
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
fmt.Fprintln(os.Stderr, "encode:", err)
}
}
// icoSizes are the entries embedded in the .ico. The 256px source is an
// integer multiple of each, so every entry is an exact box-filter downscale.
var icoSizes = []int{16, 24, 32, 48, 64, 128, 256}
// writeICO writes a multi-size Windows .ico (classic BMP XOR+AND entries, the
// format LoadImage has always supported) from the 256px source image.
func writeICO(src *image.RGBA, path string) error {
sizes := icoSizes
var dir bytes.Buffer
var payload bytes.Buffer
offset := 6 + len(sizes)*16
dir.Write([]byte{0, 0, 1, 0, byte(len(sizes)), 0}) // ICONDIR
for _, s := range sizes {
bmp := toDIB(scaleBox(src, s))
w, h := byte(s), byte(s)
if s >= 256 {
w, h = 0, 0
}
dir.Write([]byte{w, h, 0, 0, 1, 0, 32, 0})
dir.Write(u32le(len(bmp)))
dir.Write(u32le(offset + payload.Len()))
payload.Write(bmp)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Write(dir.Bytes()); err != nil {
return err
}
_, err = f.Write(payload.Bytes())
return err
}
// scaleBox resizes src to w x w with an exact box (area-average) filter. Only
// correct when src's dimensions are an integer multiple of w — 256 is for
// every icoSizes entry — so no interpolation blur is introduced.
func scaleBox(src *image.RGBA, w int) *image.RGBA {
b := src.Bounds()
sw, sh := b.Dx(), b.Dy()
fx := sw / w
fy := sh / w
dst := image.NewRGBA(image.Rect(0, 0, w, w))
for y := 0; y < w; y++ {
for x := 0; x < w; x++ {
var r, g, bl, a int64
for sy := 0; sy < fy; sy++ {
yy := b.Min.Y + y*fy + sy
for sx := 0; sx < fx; sx++ {
xx := b.Min.X + x*fx + sx
cr, cg, cb, ca := src.At(xx, yy).RGBA()
r += int64(cr >> 8)
g += int64(cg >> 8)
bl += int64(cb >> 8)
a += int64(ca >> 8)
}
}
n := int64(fx * fy)
dst.SetRGBA(x, y, color.RGBA{
R: uint8(r / n),
G: uint8(g / n),
B: uint8(bl / n),
A: uint8(a / n),
})
}
}
return dst
}
// toDIB encodes an image as a 32-bit bottom-up DIB with an all-transparent
// AND mask — the classic icon bitmap entry.
func toDIB(img *image.RGBA) []byte {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
andRow := ((w + 31) / 32) * 4
dib := make([]byte, 0, 40+w*h*4+andRow*h)
dib = append(dib, u32le(40)...) // biSize
dib = append(dib, u32le(w)...) // biWidth
dib = append(dib, u32le(h*2)...) // biHeight (XOR + AND)
dib = append(dib, u16le(1)...) // biPlanes
dib = append(dib, u16le(32)...) // biBitCount
dib = append(dib, 0, 0, 0, 0) // biCompression = 0 (BI_RGB)
dib = append(dib, u32le(w*h*4+andRow*h)...)
dib = append(dib, make([]byte, 16)...) // remaining header fields
for y := 0; y < h; y++ {
srcY := b.Min.Y + (h - 1 - y) // DIB rows are bottom-up
for x := 0; x < w; x++ {
r, g, bl, a := img.At(b.Min.X+x, srcY).RGBA()
dib = append(dib, byte(bl>>8), byte(g>>8), byte(r>>8), byte(a>>8))
}
}
dib = append(dib, make([]byte, andRow*h)...) // AND mask: all zero
return dib
}
func u16le(v int) []byte {
return []byte{byte(v), byte(v >> 8)}
}
func u32le(v int) []byte {
var b [4]byte
binary.LittleEndian.PutUint32(b[:], uint32(v))
return b[:]
}
func goBytes(b []byte) string {
var sb strings.Builder
sb.WriteString("[]byte{")
for i, x := range b {
if i%12 == 0 {
sb.WriteString("\n\t\t")
}
sb.WriteString(fmt.Sprintf("0x%02x, ", x))
}
sb.WriteString("\n\t}")
return sb.String()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -23,8 +23,8 @@ func TestPNGToIco(t *testing.T) {
t.Errorf("type must be 1 (icon)")
}
count := int(ico[4]) | int(ico[5])<<8
if count != 3 {
t.Fatalf("expected 3 icon entries, got %d", count)
if count != len(iconSizes) {
t.Fatalf("expected %d icon entries, got %d", len(iconSizes), count)
}
// Each ICONDIRENTRY: valid size, planes=1, bpp=32, DIB with BITMAPINFOHEADER.
@@ -38,6 +38,9 @@ func TestPNGToIco(t *testing.T) {
if h == 0 {
h = 256
}
if w != iconSizes[i] || h != iconSizes[i] {
t.Errorf("entry %d: encoded size %dx%d, want %dx%d", i, w, h, iconSizes[i], iconSizes[i])
}
planes := int(ico[e+4]) | int(ico[e+5])<<8
bpp := int(ico[e+6]) | int(ico[e+7])<<8
size := int(ico[e+8]) | int(ico[e+9])<<8 | int(ico[e+10])<<16 | int(ico[e+11])<<24
+8 -2
View File
@@ -43,10 +43,12 @@ ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir={#AgentDir}
OutputBaseFilename=theta-agent-{#MyAppVersion}-windows-amd64-setup
SetupIconFile=theta-agent.ico
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
UninstallDisplayName={#MyAppName}
UninstallDisplayIcon={app}\theta-agent.ico
CloseApplications=no
MinVersion=10.0.17763
@@ -69,15 +71,19 @@ Source: "{#AgentDir}\theta-agent-helper-windows-amd64.exe"; DestDir: "{app}"; Fl
; driver is signed; no signature phone-home).
Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi"; DestDir: "{app}\vendor"; Flags: ignoreversion
; Product icon (Start menu shortcut, uninstaller display icon). Generated by
; cmd/icon-gen from the same badge the tray uses.
Source: "theta-agent.ico"; DestDir: "{app}"; Flags: ignoreversion
; OpenCredential credential provider installer (BSD-3 pGina fork) + the VC++
; runtime it needs. Both install silently at [Run].
Source: "{#VendorDir}\OpenCredentialInstaller-1.0.0.0.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion
Source: "{#VendorDir}\vc_redist.x64.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion
[Icons]
Name: "{group}\Theta Agent Tray"; Filename: "{app}\tray\theta-agent-tray-windows-amd64.exe"; Comment: "Theta Agent status tray"
Name: "{group}\Theta Agent Tray"; Filename: "{app}\tray\theta-agent-tray-windows-amd64.exe"; IconFilename: "{app}\theta-agent.ico"; Comment: "Theta Agent status tray"
Name: "{group}\Open Agent Config"; Filename: "notepad.exe"; Parameters: "{commonappdata}\Theta42\agent.yml"; Comment: "Open the agent configuration file"
Name: "{group}\Uninstall Theta Agent"; Filename: "{uninstallexe}"
Name: "{group}\Uninstall Theta Agent"; Filename: "{uninstallexe}"; IconFilename: "{app}\theta-agent.ico"
[Registry]
; Start the tray for every interactive logon.
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

-15
View File
File diff suppressed because one or more lines are too long