Merge pull request #14 from theta42/feat/tray-icons
feat(icons): clean tray badge set + Start menu/installer icon
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user