12d55a4454
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.
394 lines
11 KiB
Go
394 lines
11 KiB
Go
//go:build !server
|
|
// +build !server
|
|
|
|
// theta-agent-tray — desktop system tray companion for the theta-agent daemon.
|
|
//
|
|
// Auto-detects a graphical session (DISPLAY or WAYLAND_DISPLAY) and exits
|
|
// silently if neither is present (so it's safe to install on all hosts and
|
|
// only activates on desktops).
|
|
//
|
|
// Communicates with the running theta-agent daemon via the Unix socket at
|
|
// /run/theta/tray.sock (set up by the daemon). The daemon streams JSON status
|
|
// updates; the tray sends JSON commands back.
|
|
//
|
|
// Build:
|
|
// go build -o dist/theta-agent-tray ./cmd/theta-agent-tray
|
|
//
|
|
// Linux: requires libappindicator or the GTK3 systray dbus protocol.
|
|
// The fyne.io/systray library handles the platform details.
|
|
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"time"
|
|
|
|
"fyne.io/systray"
|
|
)
|
|
|
|
// ── IPC types (duplicated from the main agent package; tray is its own binary) ──
|
|
|
|
// traySocketPaths returns the daemon IPC socket paths for this platform, in
|
|
// the same order the daemon tries to bind them. Windows has no /run or /tmp and
|
|
// the daemon runs as a SYSTEM service, so both sides use the shared
|
|
// %ProgramData%\Theta42\tray.sock (installer creates the dir with a
|
|
// Users-writable ACL); Linux keeps the original pair.
|
|
func traySocketPaths() []string {
|
|
if runtime.GOOS == "windows" {
|
|
pd := os.Getenv("ProgramData")
|
|
if pd == "" {
|
|
pd = `C:\ProgramData`
|
|
}
|
|
return []string{filepath.Join(pd, "Theta42", "tray.sock")}
|
|
}
|
|
return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"}
|
|
}
|
|
|
|
type TrayColor string
|
|
|
|
const (
|
|
ColorRed TrayColor = "red"
|
|
ColorYellow TrayColor = "yellow"
|
|
ColorGreen TrayColor = "green"
|
|
ColorBlue TrayColor = "blue"
|
|
)
|
|
|
|
type TrayStatus struct {
|
|
Color TrayColor `json:"color"`
|
|
Connected bool `json:"connected"`
|
|
IsHome bool `json:"is_home"`
|
|
VPNActive bool `json:"vpn_active"`
|
|
AutoVPN bool `json:"auto_vpn"`
|
|
SiteName string `json:"site_name"`
|
|
AgentPublicIP string `json:"agent_public_ip"`
|
|
HomePublicIP string `json:"home_public_ip"`
|
|
StatusText string `json:"status_text"`
|
|
}
|
|
|
|
type TrayCommand struct {
|
|
Command string `json:"command"`
|
|
Value bool `json:"value"`
|
|
}
|
|
|
|
|
|
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
|
|
func main() {
|
|
// Silent exit if no graphical session is available. Only meaningful on
|
|
// X11/Wayland: Windows never sets these variables and the tray is simply
|
|
// always a valid thing to run there.
|
|
if runtime.GOOS != "windows" && os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
|
|
log.Println("theta-agent-tray: no graphical session detected (DISPLAY/WAYLAND_DISPLAY not set), exiting")
|
|
os.Exit(0)
|
|
}
|
|
|
|
systray.Run(onReady, onExit)
|
|
}
|
|
|
|
func onExit() {
|
|
log.Println("theta-agent-tray: exiting")
|
|
}
|
|
|
|
// ── Menu items ────────────────────────────────────────────────────────────────
|
|
|
|
var (
|
|
mStatus *systray.MenuItem
|
|
mAutoVPN *systray.MenuItem
|
|
mVPNToggle *systray.MenuItem
|
|
mSeparator *systray.MenuItem
|
|
mOpenConfig *systray.MenuItem
|
|
mReinit *systray.MenuItem
|
|
mQuit *systray.MenuItem
|
|
|
|
currentStatus TrayStatus
|
|
ipcConn net.Conn
|
|
)
|
|
|
|
func onReady() {
|
|
// Initial icon — red until we hear from the daemon.
|
|
systray.SetIcon(toWindowsIcon(iconRed))
|
|
systray.SetTitle("Theta Agent")
|
|
systray.SetTooltip("Theta Agent — connecting…")
|
|
|
|
// ── Menu ──
|
|
mStatus = systray.AddMenuItem("Connecting to directory…", "Current connection status")
|
|
mStatus.Disable()
|
|
systray.AddSeparator()
|
|
mAutoVPN = systray.AddMenuItemCheckbox("Auto-connect VPN when away", "Automatically connect to home via WireGuard when not on the home LAN", false)
|
|
mVPNToggle = systray.AddMenuItem("Connect VPN", "Manually connect or disconnect the WireGuard tunnel")
|
|
systray.AddSeparator()
|
|
mOpenConfig = systray.AddMenuItem("Open Config", "Open agent.yml in the default editor")
|
|
mReinit = systray.AddMenuItem("Clear enrollment…", "Blank auth_token/public_key so the agent re-enrolls on reconnect")
|
|
mQuit = systray.AddMenuItem("Quit Tray", "Exit the tray icon (daemon keeps running)")
|
|
|
|
// ── IPC loop — connect with retry ──
|
|
go connectWithRetry()
|
|
|
|
// ── Menu event loop ──
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-mAutoVPN.ClickedCh:
|
|
newVal := !currentStatus.AutoVPN
|
|
currentStatus.AutoVPN = newVal
|
|
if newVal {
|
|
mAutoVPN.Check()
|
|
} else {
|
|
mAutoVPN.Uncheck()
|
|
}
|
|
sendCmd(TrayCommand{Command: "set_auto_vpn", Value: newVal})
|
|
|
|
case <-mVPNToggle.ClickedCh:
|
|
if currentStatus.VPNActive {
|
|
sendCmd(TrayCommand{Command: "vpn_disconnect"})
|
|
} else {
|
|
sendCmd(TrayCommand{Command: "vpn_connect"})
|
|
}
|
|
|
|
case <-mOpenConfig.ClickedCh:
|
|
sendCmd(TrayCommand{Command: "open_config"})
|
|
|
|
case <-mReinit.ClickedCh:
|
|
sendCmd(TrayCommand{Command: "reinit"})
|
|
|
|
case <-mQuit.ClickedCh:
|
|
systray.Quit()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// connectWithRetry repeatedly tries to connect to the daemon socket, waiting 5s
|
|
// between attempts. On connect it streams status updates until the connection
|
|
// drops, then retries.
|
|
func connectWithRetry() {
|
|
socketPaths := traySocketPaths()
|
|
for {
|
|
var conn net.Conn
|
|
var err error
|
|
for _, p := range socketPaths {
|
|
conn, err = net.Dial("unix", p)
|
|
if err == nil {
|
|
break
|
|
}
|
|
}
|
|
if err != nil {
|
|
log.Printf("theta-agent-tray: daemon socket not available (%v), retrying in 5s…", err)
|
|
updateUI(TrayStatus{Color: ColorRed, StatusText: "Daemon not running"})
|
|
time.Sleep(5 * time.Second)
|
|
continue
|
|
}
|
|
ipcConn = conn
|
|
log.Println("theta-agent-tray: connected to daemon IPC socket")
|
|
streamStatus(conn)
|
|
conn.Close()
|
|
ipcConn = nil
|
|
log.Println("theta-agent-tray: lost daemon connection, retrying in 5s…")
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
}
|
|
|
|
func streamStatus(conn net.Conn) {
|
|
scanner := bufio.NewScanner(conn)
|
|
for scanner.Scan() {
|
|
var s TrayStatus
|
|
if err := json.Unmarshal(scanner.Bytes(), &s); err == nil {
|
|
updateUI(s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateUI(s TrayStatus) {
|
|
currentStatus = s
|
|
|
|
// 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:
|
|
icon = iconRed
|
|
case ColorYellow:
|
|
icon = iconYellow
|
|
case ColorGreen:
|
|
icon = iconGreen
|
|
case ColorBlue:
|
|
icon = iconBlue
|
|
}
|
|
systray.SetIcon(toWindowsIcon(icon))
|
|
|
|
// Tooltip.
|
|
tooltip := s.StatusText
|
|
if s.AgentPublicIP != "" {
|
|
tooltip += fmt.Sprintf(" (IP: %s)", s.AgentPublicIP)
|
|
}
|
|
systray.SetTooltip("Theta Agent — " + tooltip)
|
|
|
|
// Status menu item.
|
|
mStatus.SetTitle(s.StatusText)
|
|
|
|
// Auto-VPN checkbox.
|
|
if s.AutoVPN {
|
|
mAutoVPN.Check()
|
|
} else {
|
|
mAutoVPN.Uncheck()
|
|
}
|
|
|
|
// VPN toggle label.
|
|
if s.IsHome {
|
|
// On home LAN — hide VPN toggle (not needed).
|
|
mVPNToggle.Hide()
|
|
} else {
|
|
mVPNToggle.Show()
|
|
if s.VPNActive {
|
|
mVPNToggle.SetTitle("Disconnect VPN")
|
|
} else {
|
|
mVPNToggle.SetTitle("Connect VPN")
|
|
}
|
|
}
|
|
}
|
|
|
|
func sendCmd(cmd TrayCommand) {
|
|
if ipcConn == nil {
|
|
log.Println("theta-agent-tray: no daemon connection, cannot send command")
|
|
return
|
|
}
|
|
b, _ := json.Marshal(cmd)
|
|
b = append(b, '\n')
|
|
_, err := ipcConn.Write(b)
|
|
if err != nil {
|
|
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)
|
|
}
|
|
|
|
// iconSizes are the sizes embedded in the Windows ICO. The 256px source PNG
|
|
// is a multiple of every entry, so each is an exact box-filter downscale.
|
|
var iconSizes = []int{16, 24, 32, 48, 64, 128, 256}
|
|
|
|
// pngToIco decodes a PNG and re-encodes it as classic BMP (XOR + AND mask)
|
|
// entries at every icon size from 16 to 256 — the format LoadImage has always
|
|
// supported. The source PNG is rendered at 256px (a multiple of every target
|
|
// size), so scaleBox is an exact box filter: no nearest-neighbour jaggies.
|
|
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 := iconSizes
|
|
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(scaleBox(src, 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...)
|
|
}
|
|
|
|
// 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 — which 256 is
|
|
// for every icon size above — so no interpolation blur is introduced.
|
|
func scaleBox(src image.Image, 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
|
|
}
|
|
|
|
// 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)}
|
|
}
|