package ipc import ( "bufio" "encoding/json" "errors" "fmt" "net" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/bjarneo/cliamp/applog" "github.com/bjarneo/cliamp/internal/playback" ) // Dispatcher is how the server sends commands to the TUI. // In main.go, this is wired to prog.Send(). type Dispatcher interface { Send(msg any) } // Server listens on a Unix socket and dispatches IPC commands. type Server struct { listener net.Listener sockPath string disp Dispatcher plugins PluginDispatcher done chan struct{} wg sync.WaitGroup connMu sync.Mutex conns map[net.Conn]struct{} // live connections, closed on shutdown } // addConn registers a live connection. It returns false if the server is // already shutting down, in which case the caller must close the connection // and return. The done check shares connMu with closeConns so a connection // accepted during shutdown is always closed by exactly one of them. func (s *Server) addConn(c net.Conn) bool { s.connMu.Lock() defer s.connMu.Unlock() select { case <-s.done: return false default: } s.conns[c] = struct{}{} return true } func (s *Server) removeConn(c net.Conn) { s.connMu.Lock() delete(s.conns, c) s.connMu.Unlock() } func (s *Server) closeConns() { s.connMu.Lock() for c := range s.conns { c.Close() } s.connMu.Unlock() } // SetPluginDispatcher wires in the Lua plugin manager after the server starts. // Plugin dispatch is optional — without it, plugin subcommands return an error. func (s *Server) SetPluginDispatcher(p PluginDispatcher) { s.plugins = p } // NewServer creates and starts the IPC server. It cleans up stale sockets // before binding. The socket is created with 0600 permissions (owner only). func NewServer(sockPath string, disp Dispatcher) (*Server, error) { if err := cleanStaleSocket(sockPath); err != nil { return nil, err } // Ensure the parent directory exists. if err := os.MkdirAll(filepath.Dir(sockPath), 0700); err != nil { return nil, fmt.Errorf("ipc: mkdir: %w", err) } ln, err := listenSocket(sockPath) if err != nil { return nil, fmt.Errorf("ipc: listen: %w", err) } // Restrict socket permissions to owner only. if err := os.Chmod(sockPath, 0600); err != nil { ln.Close() os.Remove(sockPath) return nil, fmt.Errorf("ipc: chmod: %w", err) } // Write PID file. pidPath := sockPath + ".pid" if err := os.WriteFile(pidPath, []byte(strconv.Itoa(os.Getpid())), 0600); err != nil { ln.Close() os.Remove(sockPath) return nil, fmt.Errorf("ipc: write pid: %w", err) } s := &Server{ listener: ln, sockPath: sockPath, disp: disp, done: make(chan struct{}), conns: make(map[net.Conn]struct{}), } s.wg.Add(1) go s.acceptLoop() return s, nil } // Close shuts down the server, removes socket and PID file. func (s *Server) Close() error { close(s.done) err := s.listener.Close() // Close in-flight connections so their handleConn read loops unblock // immediately rather than waiting out the per-request read deadline. s.closeConns() s.wg.Wait() os.Remove(s.sockPath) os.Remove(s.sockPath + ".pid") return err } // acceptLoop accepts incoming connections until the server is closed. func (s *Server) acceptLoop() { defer s.wg.Done() for { conn, err := s.listener.Accept() if err != nil { select { case <-s.done: return default: } // A closed listener is permanent — stop instead of spinning. if errors.Is(err, net.ErrClosed) { return } // Other errors may be transient (e.g. EMFILE); log and back off // rather than silently retrying. applog.Warn("ipc: accept: %v", err) time.Sleep(100 * time.Millisecond) continue } s.wg.Add(1) go s.handleConn(conn) } } // handleConn reads newline-delimited JSON requests from a single connection, // dispatches them, and writes JSON responses. func (s *Server) handleConn(conn net.Conn) { defer s.wg.Done() defer conn.Close() if !s.addConn(conn) { return // server shutting down } defer s.removeConn(conn) scanner := bufio.NewScanner(conn) for { // Per-request deadline so long-lived streaming clients (e.g. vis bands // polling) aren't killed at a fixed wall clock, but idle clients still // time out. conn.SetReadDeadline(time.Now().Add(60 * time.Second)) if !scanner.Scan() { return } line := scanner.Bytes() if len(line) == 0 { continue } var req Request if err := json.Unmarshal(line, &req); err != nil { writeResponse(conn, Response{OK: false, Error: "invalid JSON: " + err.Error()}) continue } resp := s.dispatch(req) conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) writeResponse(conn, resp) } } // dispatch handles a single parsed request. func (s *Server) dispatch(req Request) Response { switch strings.ToLower(req.Cmd) { case "play": s.disp.Send(PlayMsg{}) return Response{OK: true} case "pause": s.disp.Send(PauseMsg{}) return Response{OK: true} case "toggle": s.disp.Send(playback.PlayPauseMsg{}) return Response{OK: true} case "stop": s.disp.Send(playback.StopMsg{}) return Response{OK: true} case "next": s.disp.Send(playback.NextMsg{}) return Response{OK: true} case "prev": s.disp.Send(playback.PrevMsg{}) return Response{OK: true} case "volume": s.disp.Send(VolumeMsg{DB: req.Value}) return Response{OK: true} case "seek": s.disp.Send(SeekMsg{Offset: time.Duration(req.Value * float64(time.Second))}) return Response{OK: true} case "load": if req.Playlist == "" { return Response{OK: false, Error: "load requires a playlist name"} } reply := make(chan Response, 1) s.disp.Send(LoadMsg{Playlist: req.Playlist, Reply: reply}) return waitReply(reply, s.done, "load", 3*time.Second) case "queue": if req.Path == "" { return Response{OK: false, Error: "queue requires a path"} } s.disp.Send(QueueMsg{Path: req.Path}) return Response{OK: true} case "theme": if req.Name == "" { return Response{OK: false, Error: "theme requires a name"} } reply := make(chan Response, 1) s.disp.Send(ThemeMsg{Name: req.Name, Reply: reply}) return waitReply(reply, s.done, "theme", 3*time.Second) case "vis": if req.Name == "" { return Response{OK: false, Error: "vis requires a mode name"} } reply := make(chan Response, 1) s.disp.Send(VisMsg{Name: req.Name, Reply: reply}) return waitReply(reply, s.done, "vis", 3*time.Second) case "shuffle": reply := make(chan Response, 1) s.disp.Send(ShuffleMsg{Name: req.Name, Reply: reply}) return waitReply(reply, s.done, "shuffle", 3*time.Second) case "repeat": reply := make(chan Response, 1) s.disp.Send(RepeatMsg{Name: req.Name, Reply: reply}) return waitReply(reply, s.done, "repeat", 3*time.Second) case "mono": reply := make(chan Response, 1) s.disp.Send(MonoMsg{Name: req.Name, Reply: reply}) return waitReply(reply, s.done, "mono", 3*time.Second) case "speed": if req.Value <= 0 { return Response{OK: false, Error: "speed must be positive"} } reply := make(chan Response, 1) s.disp.Send(SpeedMsg{Speed: req.Value, Reply: reply}) return waitReply(reply, s.done, "speed", 3*time.Second) case "eq": reply := make(chan Response, 1) s.disp.Send(EQMsg{Name: req.Name, Band: req.Band, Value: req.Value, Reply: reply}) return waitReply(reply, s.done, "eq", 3*time.Second) case "device": if req.Name == "" { return Response{OK: false, Error: "device requires a name (or 'list')"} } reply := make(chan Response, 1) s.disp.Send(DeviceMsg{Name: req.Name, Reply: reply}) return waitReply(reply, s.done, "device", 3*time.Second) case "status": return s.handleStatus() case "bands": reply := make(chan Response, 1) s.disp.Send(BandsRequestMsg{Reply: reply}) return waitReply(reply, s.done, "bands", 1*time.Second) case "plugin.call": if s.plugins == nil { return Response{OK: false, Error: "plugins not enabled"} } if req.Name == "" || req.Sub == "" { return Response{OK: false, Error: "plugin.call requires plugin name and subcommand"} } out, err := s.plugins.EmitCommand(req.Name, req.Sub, req.Args) if err != nil { return Response{OK: false, Error: err.Error()} } return Response{OK: true, Output: out} case "plugin.commands": if s.plugins == nil { return Response{OK: false, Error: "plugins not enabled"} } return Response{OK: true, Items: s.plugins.CommandList()} default: return Response{OK: false, Error: "unknown command: " + req.Cmd} } } // waitReply waits up to timeout for a response on the reply channel, returning // a "