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:
@@ -3,3 +3,4 @@ theta-agent
|
|||||||
theta-agent-*
|
theta-agent-*
|
||||||
dist/
|
dist/
|
||||||
*.exe
|
*.exe
|
||||||
|
agent.yml
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user