fix(tray): Windows tray icon now loads — PNG->ICO with BMP entries

fyne.io/systray requires .ico content on Windows, and LoadImage cannot read
PNG-in-ICO. The embedded icons are PNG, so SetIcon always failed and no tray
icon ever appeared. pngToIco decodes the PNG and re-encodes classic BMP
(XOR + AND mask) entries at 16/32/48px — the format LoadImage has always
supported. Verified: the 'unable to set icon' error is gone and the tray
process stays up.

feat(installer): wizard page, Start Menu icons, silent install params

- Wizard page asks for the SSO Manager URL + join key, with an 'Open SSO
  install-agent page' button (ShellExec); values feed agent.yml
- /SERVER_URL /JOIN_KEY /AUTH_TOKEN /PUBLIC_KEY and /B64_CONFIG (base64 of a
  full agent.yml, decoded by an inline B64Decode) drive the same result in
  /SILENT mode, so the SSO's Install Agent modal can emit one Windows command
- [Icons]: Theta Agent Tray / Open Agent Config / Uninstall in Start Menu
- WireGuard client launches its UI at the end of the MSI; taskkill after the
  msiexec step closes it (the tunnel is agent-managed)
- tray starts right after install (not just at next logon)
- Inno 7 fixes: controls parent to Page.Surface, SaveStringsToUTF8FileWithoutBOM

Adds pngToIco structure tests (valid ICO dir + 32bpp DIBs) and a platform
passthrough test for toWindowsIcon.

Rebuilds all tracked dist binaries + the setup.exe.
This commit is contained in:
2026-08-09 19:06:00 -07:00
parent ba2a619db5
commit 016a8fc6b6
6 changed files with 267 additions and 17 deletions
+109 -8
View File
@@ -21,8 +21,11 @@ package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"image"
"image/png"
"log"
"net"
"os"
@@ -121,7 +124,7 @@ var (
func onReady() {
// Initial icon — red until we hear from the daemon.
systray.SetIcon(iconRed)
systray.SetIcon(toWindowsIcon(iconRed))
systray.SetTitle("Theta Agent")
systray.SetTooltip("Theta Agent — connecting…")
@@ -216,19 +219,20 @@ func streamStatus(conn net.Conn) {
func updateUI(s TrayStatus) {
currentStatus = s
// Icon color.
// Icon color. fyne.io/systray needs .ico on Windows; the PNG icons are
// wrapped in an ICO container (Windows Vista+ supports PNG-in-ICO).
icon := iconRed
switch s.Color {
case ColorRed:
systray.SetIcon(iconRed)
icon = iconRed
case ColorYellow:
systray.SetIcon(iconYellow)
icon = iconYellow
case ColorGreen:
systray.SetIcon(iconGreen)
icon = iconGreen
case ColorBlue:
systray.SetIcon(iconBlue)
default:
systray.SetIcon(iconRed)
icon = iconBlue
}
systray.SetIcon(toWindowsIcon(icon))
// Tooltip.
tooltip := s.StatusText
@@ -273,3 +277,100 @@ func sendCmd(cmd TrayCommand) {
log.Printf("theta-agent-tray: send command error: %v", err)
}
}
// toWindowsIcon converts PNG bytes into a Windows .ico for fyne.io/systray,
// which requires .ico content on Windows (LoadImage cannot read PNG-in-ICO).
// On non-Windows the PNG is returned untouched.
func toWindowsIcon(pngBytes []byte) []byte {
if runtime.GOOS != "windows" {
return pngBytes
}
return pngToIco(pngBytes)
}
// pngToIco decodes a PNG and re-encodes it as classic BMP (XOR + AND mask)
// entries at 16/32/48px — the format LoadImage has always supported.
func pngToIco(pngBytes []byte) []byte {
src, err := png.Decode(bytes.NewReader(pngBytes))
if err != nil {
return pngBytes // give systray the raw bytes; it will log and continue
}
sizes := []int{16, 32, 48}
var dir []byte
var payload []byte
offset := 6 + len(sizes)*16 // ICONDIR + all ICONDIRENTRYs
// ICONDIR: reserved(2)=0 type(2)=1 count(2)
dir = append(dir, 0, 0, 1, 0, byte(len(sizes)), 0)
for _, s := range sizes {
bmp := rgbaToDIB(scaleNearest(src, s, s))
dw, dh := byte(s), byte(s)
if s >= 256 {
dw, dh = 0, 0
}
entry := []byte{dw, dh, 0, 0, 1, 0, 32, 0}
entry = append(entry, putU32le(len(bmp))...)
entry = append(entry, putU32le(offset+len(payload))...)
dir = append(dir, entry...)
payload = append(payload, bmp...)
}
return append(dir, payload...)
}
// scaleNearest resizes src to w x h with nearest-neighbour sampling.
func scaleNearest(src image.Image, w, h int) image.Image {
b := src.Bounds()
if b.Dx() == w && b.Dy() == h {
return src
}
dst := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
sy := b.Min.Y + y*b.Dy()/h
for x := 0; x < w; x++ {
sx := b.Min.X + x*b.Dx()/w
dst.Set(x, y, src.At(sx, sy))
}
}
return dst
}
// rgbaToDIB encodes an image as a 32-bit bottom-up DIB with an all-transparent
// AND mask — the classic icon bitmap LoadImage understands.
func rgbaToDIB(img image.Image) []byte {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
hdr := make([]byte, 40)
copy(hdr, putU32le(40)) // biSize
copy(hdr[4:], putU32le(w)) // biWidth
copy(hdr[8:], putU32le(h*2)) // biHeight (XOR + AND)
copy(hdr[12:], putU16le(1)) // biPlanes
copy(hdr[14:], putU16le(32)) // biBitCount
andRow := ((w + 31) / 32) * 4 // AND mask row, padded to 32 bits
copy(hdr[20:], putU32le(w*h*4+andRow*h)) // biSizeImage
xor := make([]byte, w*h*4)
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()
o := y*w*4 + x*4
xor[o+0] = byte(bl >> 8) // B
xor[o+1] = byte(g >> 8) // G
xor[o+2] = byte(r >> 8) // R
xor[o+3] = byte(a >> 8) // A
}
}
and := make([]byte, andRow*h) // all zeros: no transparency holes
return append(append(hdr, xor...), and...)
}
func putU32le(v int) []byte {
return []byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)}
}
func putU16le(v int) []byte {
return []byte{byte(v), byte(v >> 8)}
}