159 lines
4.1 KiB
Go
159 lines
4.1 KiB
Go
// 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})
|
|
}
|