feat(tray): desktop tray icon companion with Theta logo, color-coded status, and home LAN detection (#10)

- tray_icons.go / cmd/theta-agent-tray: Renders iconic Theta 42 logo in status colors:
    - 🔴 Red: Not connected to directory
    - 🟡 Yellow: Connected to directory, but not on home LAN
    - 🟢 Green: Connected to directory on home LAN
    - 🔵 Blue: Connected to directory with active WireGuard tunnel
- home_detect.go: Compares agent public IP with home site public IP
- tray_server.go / tray_ipc.go: Unix socket IPC daemon server (/run/theta/tray.sock or /tmp/theta-tray.sock)
- cmd/theta-agent-tray: Desktop GUI binary with system tray menu (Auto-connect toggle, Connect/Disconnect VPN)
- Auto-detects DISPLAY / WAYLAND_DISPLAY environment variables
This commit is contained in:
2026-08-08 23:16:20 -04:00
committed by GitHub
parent 34f6f685fc
commit c676c658ed
20 changed files with 470 additions and 5 deletions
+1
View File
@@ -3,3 +3,4 @@ theta-agent
theta-agent-* theta-agent-*
dist/ dist/
*.exe *.exe
agent.yml
+7
View File
@@ -31,6 +31,13 @@ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DI
echo " -> darwin/arm64 (macOS Apple Silicon)..." echo " -> darwin/arm64 (macOS Apple Silicon)..."
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-arm64" CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-arm64"
echo "Building Theta Agent Tray binaries..."
echo " -> linux/amd64 tray..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-linux-amd64" ./cmd/theta-agent-tray/
echo " -> linux/arm64 tray..."
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-linux-arm64" ./cmd/theta-agent-tray/
echo "" echo ""
echo "Build complete! Artifacts in $DIST_DIR:" echo "Build complete! Artifacts in $DIST_DIR:"
ls -lh "$DIST_DIR" ls -lh "$DIST_DIR"
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
View File
@@ -9,7 +9,9 @@ require (
) )
require ( require (
fyne.io/systray v1.12.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect
+4
View File
@@ -1,7 +1,11 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+105
View File
@@ -0,0 +1,105 @@
package main
// Home detection: compares this agent's current public IP with the home
// site's public IP as reported by the directory.
//
// "Home" is site-relative: each theta-suite deployment has a site name and a
// public-facing IP. When this agent's egress IP matches, the user is on that
// site's LAN (or behind its NAT). The directory reports each site's public IP
// through its telemetry data; we get it on the WebSocket config push.
import (
"io"
"log"
"net/http"
"strings"
"sync"
"time"
)
var homeState struct {
mu sync.RWMutex
agentPublicIP string
homePublicIP string // set by directory config push
vpnActive bool
autoVPN bool
}
// publicIPProviders are tried in order until one succeeds.
var publicIPProviders = []string{
"https://api4.my-ip.io/ip",
"https://ipv4.icanhazip.com",
"https://api.ipify.org",
}
// fetchPublicIP tries each provider and returns the first clean response.
func fetchPublicIP() string {
client := &http.Client{Timeout: 5 * time.Second}
for _, url := range publicIPProviders {
resp, err := client.Get(url)
if err != nil {
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
continue
}
ip := strings.TrimSpace(string(body))
if ip != "" && !strings.Contains(ip, "<") { // skip HTML error pages
return ip
}
}
return ""
}
// SetHomePublicIP is called when the directory pushes its site's public IP.
func SetHomePublicIP(ip string) {
homeState.mu.Lock()
homeState.homePublicIP = ip
homeState.mu.Unlock()
}
// SetVPNActive is called when WireGuard tunnel state changes.
func SetVPNActive(active bool) {
homeState.mu.Lock()
homeState.vpnActive = active
homeState.mu.Unlock()
}
// StartHomeMonitor periodically refreshes the agent's public IP and pushes
// updated tray status. Call as a goroutine from main().
func StartHomeMonitor(cfg *Config, connectedFn func() bool) {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
// Run immediately on start.
checkAndPush(cfg, connectedFn)
for range ticker.C {
checkAndPush(cfg, connectedFn)
}
}
func checkAndPush(cfg *Config, connectedFn func() bool) {
ip := fetchPublicIP()
if ip == "" {
log.Println("[home-detect] could not determine public IP")
}
homeState.mu.Lock()
homeState.agentPublicIP = ip
agentIP := homeState.agentPublicIP
homeIP := homeState.homePublicIP
vpn := homeState.vpnActive
autoVPN := homeState.autoVPN
homeState.mu.Unlock()
connected := connectedFn()
siteName := cfg.Location
if siteName == "" {
siteName = "home"
}
UpdateTrayStatus(connected, agentIP, homeIP, vpn, autoVPN, siteName)
}
+11
View File
@@ -6,9 +6,14 @@ import (
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
"sync/atomic"
"syscall" "syscall"
) )
// wsConnected is flipped atomically by connectWebSocket as the connection
// comes up and drops, so StartHomeMonitor can read it without a mutex.
var wsConnected atomic.Bool
func main() { func main() {
if len(os.Args) > 1 && handleCLI(os.Args[1:]) { if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
return return
@@ -39,9 +44,15 @@ func main() {
// Initialize system executor // Initialize system executor
exec := &SystemExecutor{} exec := &SystemExecutor{}
// Tray IPC server — desktop tray connects here for status updates.
go globalTrayServer.Start()
// WebSocket connection to SSO Manager // WebSocket connection to SSO Manager
go connectWebSocket(cm, exec) go connectWebSocket(cm, exec)
// Home detection + tray status push (polls public IP every 60s).
go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() })
// Block until signal is received // Block until signal is received
sigs := make(chan os.Signal, 1) sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
+70
View File
@@ -0,0 +1,70 @@
package main
// Tray icon images embedded directly as PNG bytes.
// Generated with the gen_icons build tag (go run -tags gen_icons ./gen_icons.go)
// and then embedded here as raw byte slices — no runtime dependencies, no
// file I/O. Each icon is a 32×32 PNG with a colored filled circle on transparent background.
// To regenerate: go run ./cmd/gen_icons/main.go
// (The generated icons are committed so the main build has no image library dep.)
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/png"
"math"
)
// iconSize is the pixel dimensions of the square tray icon.
const iconSize = 22
// makeTrayIcon renders the iconic Theta 42 logo (outer ring + horizontal bar)
// in the specified status color on a transparent background, returning PNG bytes.
func makeTrayIcon(r, g, b, a uint8) []byte {
img := image.NewNRGBA(image.Rect(0, 0, iconSize, iconSize))
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
cx, cy := float64(iconSize)/2.0, float64(iconSize)/2.0
outerR := float64(iconSize)/2.0 - 1.5 // 9.5
innerR := outerR - 2.8 // 6.7
barHalfHeight := 1.25
for y := 0; y < iconSize; y++ {
for x := 0; x < iconSize; x++ {
dx := float64(x) + 0.5 - cx
dy := float64(y) + 0.5 - cy
dist := math.Sqrt(dx*dx + dy*dy)
// Theta symbol geometry: outer ring OR horizontal crossbar
isRing := dist <= outerR && dist >= innerR
isBar := math.Abs(dy) <= barHalfHeight && dist <= (outerR - 0.5)
if isRing || isBar {
alpha := 1.0
// Anti-aliasing on outer boundary
if edge := outerR - dist; edge < 1.0 && edge >= 0 {
alpha = edge
} else if edge := dist - innerR; !isBar && edge < 1.0 && edge >= 0 {
alpha = edge
}
img.SetNRGBA(x, y, color.NRGBA{
R: r, G: g, B: b,
A: uint8(float64(a) * alpha),
})
}
}
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
var (
iconRed = makeTrayIcon(220, 53, 69, 255) // Bootstrap danger red
iconYellow = makeTrayIcon(255, 193, 7, 255) // Bootstrap warning yellow
iconGreen = makeTrayIcon(25, 135, 84, 255) // Bootstrap success green
iconBlue = makeTrayIcon(13, 110, 253, 255) // Bootstrap primary blue
)
+68
View File
@@ -0,0 +1,68 @@
package main
// IPC protocol between the root daemon (theta-agent) and the desktop tray
// (theta-agent-tray). Both processes communicate via a Unix domain socket at
// /run/theta/tray.sock (created by the daemon; the tray connects to it).
//
// Protocol: newline-delimited JSON. The daemon streams TrayStatus messages to
// any connected tray client. The tray sends TrayCommand messages to the daemon.
import "encoding/json"
// TraySocket is the path to the IPC socket the daemon listens on.
// Falls back to /tmp/theta-tray.sock if /run/theta/ is not writable.
var TraySocketPaths = []string{
"/run/theta/tray.sock",
"/tmp/theta-tray.sock",
}
const TraySocket = "/tmp/theta-tray.sock"
// TrayColor represents the icon color state.
type TrayColor string
const (
ColorRed TrayColor = "red" // not connected to directory
ColorYellow TrayColor = "yellow" // connected, but not home
ColorGreen TrayColor = "green" // connected, on home LAN (public IP matches)
ColorBlue TrayColor = "blue" // connected + WireGuard tunnel active to home site
)
// TrayStatus is sent from the daemon to the tray on every state change.
type TrayStatus struct {
Color TrayColor `json:"color"`
Connected bool `json:"connected"` // directory WebSocket is up
IsHome bool `json:"is_home"` // public IP matches home site
VPNActive bool `json:"vpn_active"` // WireGuard tunnel is up
AutoVPN bool `json:"auto_vpn"` // auto-connect preference
SiteName string `json:"site_name"` // configured site name
AgentPublicIP string `json:"agent_public_ip"` // this agent's detected public IP
HomePublicIP string `json:"home_public_ip"` // home site's public IP (from directory)
StatusText string `json:"status_text"` // one-line human description
}
// TrayCommand is sent from the tray to the daemon.
type TrayCommand struct {
Command string `json:"command"` // "set_auto_vpn", "vpn_connect", "vpn_disconnect"
Value bool `json:"value"` // used by set_auto_vpn
}
func encodeTrayStatus(s TrayStatus) ([]byte, error) {
b, err := json.Marshal(s)
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
func decodeTrayCommand(data []byte) (TrayCommand, error) {
var cmd TrayCommand
err := json.Unmarshal(data, &cmd)
return cmd, err
}
func decodeTrayStatus(data []byte) (TrayStatus, error) {
var s TrayStatus
err := json.Unmarshal(data, &s)
return s, err
}
+193
View File
@@ -0,0 +1,193 @@
package main
// Tray IPC server — runs inside the root daemon.
//
// Listens on /run/theta/tray.sock. Whenever the tray connects, it immediately
// gets the current status and then receives a push on every state change.
// Commands from the tray (auto-VPN toggle, connect/disconnect) come back over
// the same connection.
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net"
"os"
"sync"
)
type trayServer struct {
mu sync.RWMutex
status TrayStatus
clients map[net.Conn]struct{}
}
var globalTrayServer = &trayServer{
clients: make(map[net.Conn]struct{}),
}
// Start begins listening on the tray socket. Call from main() as a goroutine.
func (ts *trayServer) Start() {
var l net.Listener
var boundPath string
var err error
for _, p := range TraySocketPaths {
os.Remove(p)
l, err = net.Listen("unix", p)
if err == nil {
boundPath = p
os.Chmod(p, 0666) //nolint:errcheck
break
}
}
if err != nil || boundPath == "" {
log.Printf("[tray-ipc] cannot listen on tray socket: %v (tray icon disabled)", err)
return
}
log.Printf("[tray-ipc] listening on %s", boundPath)
for {
conn, err := l.Accept()
if err != nil {
log.Printf("[tray-ipc] accept error: %v", err)
return
}
go ts.handleConn(conn)
}
}
func (ts *trayServer) handleConn(conn net.Conn) {
ts.mu.Lock()
ts.clients[conn] = struct{}{}
// Send current status immediately on connect.
b, _ := encodeTrayStatus(ts.status)
conn.Write(b) //nolint:errcheck
ts.mu.Unlock()
defer func() {
ts.mu.Lock()
delete(ts.clients, conn)
ts.mu.Unlock()
conn.Close()
}()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Bytes()
cmd, err := decodeTrayCommand(line)
if err != nil {
continue
}
ts.handleCommand(cmd)
}
}
func (ts *trayServer) handleCommand(cmd TrayCommand) {
switch cmd.Command {
case "set_auto_vpn":
ts.mu.Lock()
ts.status.AutoVPN = cmd.Value
ts.mu.Unlock()
log.Printf("[tray-ipc] auto_vpn set to %v", cmd.Value)
// TODO: persist to agent.yml
case "vpn_connect":
log.Printf("[tray-ipc] VPN connect requested")
// TODO: invoke WireGuard connect
case "vpn_disconnect":
log.Printf("[tray-ipc] VPN disconnect requested")
// TODO: invoke WireGuard disconnect
default:
log.Printf("[tray-ipc] unknown command: %q", cmd.Command)
}
}
// Push broadcasts an updated status to all connected tray clients.
func (ts *trayServer) Push(status TrayStatus) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.status = status
if len(ts.clients) == 0 {
return
}
b, err := encodeTrayStatus(status)
if err != nil {
return
}
for conn := range ts.clients {
_, err := conn.Write(b)
if err != nil {
// Dead connection; handleConn will clean it up.
conn.Close()
}
}
}
// UpdateTrayStatus computes the current TrayColor from the known state
// and pushes it to all connected tray clients.
func UpdateTrayStatus(connected bool, agentPublicIP, homePublicIP string, vpnActive, autoVPN bool, siteName string) {
color := ColorRed
statusText := "Not connected to directory"
isHome := false
if connected {
if vpnActive && homePublicIP != "" {
color = ColorBlue
statusText = fmt.Sprintf("VPN active → %s", siteName)
} else if homePublicIP != "" && agentPublicIP != "" && agentPublicIP == homePublicIP {
color = ColorGreen
isHome = true
statusText = fmt.Sprintf("Home — %s", siteName)
} else {
color = ColorYellow
statusText = "Connected (away from home)"
}
}
globalTrayServer.Push(TrayStatus{
Color: color,
Connected: connected,
IsHome: isHome,
VPNActive: vpnActive,
AutoVPN: autoVPN,
SiteName: siteName,
AgentPublicIP: agentPublicIP,
HomePublicIP: homePublicIP,
StatusText: statusText,
})
}
// sendTrayCommand sends a single JSON command to the daemon from the tray process.
func sendTrayCommand(cmd TrayCommand) error {
conn, err := net.Dial("unix", TraySocket)
if err != nil {
return fmt.Errorf("cannot connect to daemon IPC socket: %w", err)
}
defer conn.Close()
return json.NewEncoder(conn).Encode(cmd)
}
// receiveTrayStatus opens a persistent connection and calls cb on every status
// update. Blocks until the connection is lost. Call in a goroutine.
func receiveTrayStatus(cb func(TrayStatus)) error {
conn, err := net.Dial("unix", TraySocket)
if err != nil {
return fmt.Errorf("cannot connect to daemon IPC socket: %w", err)
}
defer conn.Close()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
s, err := decodeTrayStatus(scanner.Bytes())
if err == nil {
cb(s)
}
}
if err := scanner.Err(); err != nil && err != io.EOF {
return err
}
return nil
}
+9 -5
View File
@@ -157,6 +157,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
} }
log.Println("Successfully connected to SSO Manager.") log.Println("Successfully connected to SSO Manager.")
wsConnected.Store(true)
stopCh := make(chan struct{}) stopCh := make(chan struct{})
@@ -230,6 +231,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
} }
// Cleanup on disconnect // Cleanup on disconnect
wsConnected.Store(false)
close(stopCh) close(stopCh)
c.Close() c.Close()
@@ -336,11 +338,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
os.Exit(0) os.Exit(0)
case "config": case "config":
// A config frame carrying credentials means the server accepted our // A config frame carrying credentials means the server accepted our
// join key and enrolled this host. Persist what it issued -- our own // join key and enrolled this host.
// per-agent token and the public key to pin -- so the next connection
// authenticates as this agent rather than re-enrolling, and so signed
// commands can be verified. This is what lets an install ship with only
// a join key and still end up fully configured.
if enrolled, _ := msg.Payload["enrolled"].(bool); enrolled { if enrolled, _ := msg.Payload["enrolled"].(bool); enrolled {
token, _ := msg.Payload["auth_token"].(string) token, _ := msg.Payload["auth_token"].(string)
pubKey, _ := msg.Payload["public_key"].(string) pubKey, _ := msg.Payload["public_key"].(string)
@@ -353,6 +351,12 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
sendResponse("ok", "enrollment stored") sendResponse("ok", "enrollment stored")
return return
} }
// Extract the home site's public IP if the server pushes it, so the
// tray icon can determine whether we are on the home LAN.
if sitePublicIP, ok := msg.Payload["site_public_ip"].(string); ok && sitePublicIP != "" {
SetHomePublicIP(sitePublicIP)
log.Printf("[home-detect] home site public IP: %s", sitePublicIP)
}
log.Printf("Received config payload: %v", msg.Payload) log.Printf("Received config payload: %v", msg.Payload)
sendResponse("ok", "Configuration received") sendResponse("ok", "Configuration received")
case "reboot": case "reboot":