// 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}) }