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" "strings" "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, serverURL string) { color := ColorRed statusText := "Not connected to directory" isHome := false if connected { // Server URL is local (localhost, 127.0.0.1, LAN IP, or .local) isLocalServer := strings.Contains(serverURL, "localhost") || strings.Contains(serverURL, "127.0.0.1") || strings.Contains(serverURL, ".local") || strings.Contains(serverURL, "192.168.") || strings.Contains(serverURL, "10.") // On Home LAN if: // 1) Both agent & home public IPs are known and match, OR // 2) Connecting to a local/LAN SSO server, OR // 3) homePublicIP is not yet set by directory (default to local home) if (homePublicIP != "" && agentPublicIP != "" && agentPublicIP == homePublicIP) || isLocalServer || homePublicIP == "" { isHome = true } if vpnActive { color = ColorBlue statusText = fmt.Sprintf("VPN active → %s", siteName) } else if isHome { color = ColorGreen 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 }