Merge pull request #14 from theta42/feat/tray-icons

feat(icons): clean tray badge set + Start menu/installer icon
This commit is contained in:
2026-08-10 18:50:43 -07:00
committed by GitHub
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