diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go new file mode 100644 index 0000000..4f5dc0b --- /dev/null +++ b/backend/internal/api/api.go @@ -0,0 +1,159 @@ +// Package api exposes a REST API and serves the embedded React SPA. +package api + +import ( + "io/fs" + "net/http" + + "github.com/gin-gonic/gin" + "moonrelay/internal/discovery" + "moonrelay/internal/moonlight" + tsnode "moonrelay/internal/tailscale" +) + +// Server is the HTTP API + SPA server. +type Server struct { + disc *discovery.Discoverer + launcher *moonlight.Launcher + tsNode *tsnode.Node + router *gin.Engine +} + +// New creates and configures the server. +// staticFS should be the embedded React build (nil = API-only mode for dev). +func New(disc *discovery.Discoverer, launcher *moonlight.Launcher, ts *tsnode.Node, staticFS fs.FS) *Server { + gin.SetMode(gin.ReleaseMode) + r := gin.Default() + + s := &Server{ + disc: disc, + launcher: launcher, + tsNode: ts, + router: r, + } + + // CORS for local dev (browser hitting :8080 from :1420 vite dev server) + r.Use(func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + c.Next() + }) + + // REST API routes + api := r.Group("/api") + { + api.GET("/status", s.getStatus) + api.GET("/hosts", s.getHosts) + api.POST("/hosts/manual", s.addManualHost) + api.POST("/connect", s.connect) + api.POST("/pair", s.pair) + } + + // Serve the embedded React SPA for all non-API routes + if staticFS != nil { + r.NoRoute(func(c *gin.Context) { + // Try to serve the file from the embedded FS + path := c.Request.URL.Path + if path == "/" || path == "" { + path = "/index.html" + } + // Strip leading slash for fs.FS + fsPath := path[1:] + f, err := staticFS.Open(fsPath) + if err != nil { + // Fall back to index.html for SPA client-side routing + c.FileFromFS("index.html", http.FS(staticFS)) + return + } + f.Close() + c.FileFromFS(fsPath, http.FS(staticFS)) + }) + } + + return s +} + +// Handler returns the underlying http.Handler. +func (s *Server) Handler() http.Handler { + return s.router +} + +// getStatus returns VPN and launcher status. +func (s *Server) getStatus(c *gin.Context) { + vpnAddr := "" + vpnConnected := false + if s.tsNode != nil { + vpnAddr = s.tsNode.LocalAddr() + vpnConnected = vpnAddr != "" + } + c.JSON(http.StatusOK, gin.H{ + "vpn_connected": vpnConnected, + "vpn_address": vpnAddr, + "moonlight_path": s.launcher.ExecPath(), + }) +} + +// getHosts returns all discovered streaming hosts. +func (s *Server) getHosts(c *gin.Context) { + c.JSON(http.StatusOK, s.disc.Hosts()) +} + +// addManualHost adds a host by IP for tailnet peers that don't multicast mDNS. +func (s *Server) addManualHost(c *gin.Context) { + var body struct { + Name string `json:"name" binding:"required"` + IP string `json:"ip" binding:"required"` + Port int `json:"port"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if body.Port == 0 { + body.Port = 47984 // Apollo default + } + s.disc.AddManual(body.Name, body.IP, body.Port) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// connect launches a Moonlight stream to a host. +// In Docker mode this sends back an rtsp:// URI the browser can hand off. +func (s *Server) connect(c *gin.Context) { + var body struct { + IP string `json:"ip" binding:"required"` + App string `json:"app"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if body.App == "" { + body.App = "Desktop" + } + if err := s.launcher.Stream(body.IP, body.App); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// pair runs the Moonlight pair flow for a host. +func (s *Server) pair(c *gin.Context) { + var body struct { + IP string `json:"ip" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := s.launcher.Pair(body.IP); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +}