package web import ( "context" "errors" "fmt" "net" "net/http" "strconv" "time" "github.com/yuanyuanxiang/SimpleRemoter/server/go/logger" ) // Server serves the web remote desktop UI: the embedded index.html, xterm.js // static assets, and the PWA manifest. WebSocket signaling, device list and // screen streaming will be wired up in later phases. type Server struct { port int log *logger.Logger srv *http.Server } // New creates an HTTP server bound to the given port. port=0 disables the server. func New(port int, log *logger.Logger) *Server { return &Server{port: port, log: log} } // Start launches the server in a goroutine and returns immediately. // If port is 0, returns nil without starting anything. func (s *Server) Start() error { if s.port == 0 { s.log.Info("HTTP server disabled (-http-port=0)") return nil } mux := http.NewServeMux() mux.HandleFunc("/", s.handleIndex) mux.HandleFunc("/health", s.handleHealth) mux.HandleFunc("/manifest.json", s.handleManifest) mux.HandleFunc("/static/xterm.js", staticHandler(xtermJS, "application/javascript; charset=utf-8")) mux.HandleFunc("/static/xterm.css", staticHandler(xtermCSS, "text/css; charset=utf-8")) mux.HandleFunc("/static/xterm-fit.js", staticHandler(xtermFitJS, "application/javascript; charset=utf-8")) s.srv = &http.Server{ Addr: ":" + strconv.Itoa(s.port), Handler: mux, ReadHeaderTimeout: 10 * time.Second, } // Bind synchronously so port-in-use / permission errors propagate to the // caller instead of being lost inside the goroutine after a misleading // "started" log line. ln, err := net.Listen("tcp", s.srv.Addr) if err != nil { return fmt.Errorf("listen on :%d: %w", s.port, err) } s.log.Info("HTTP server started on :%d", s.port) go func() { if err := s.srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { s.log.Error("HTTP server stopped: %v", err) } }() return nil } // Stop gracefully shuts the server down. func (s *Server) Stop() { if s.srv == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = s.srv.Shutdown(ctx) } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" && r.URL.Path != "/index.html" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(IndexHTML) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"ok"}`)) } // PWA manifest. Referenced by in index.html. // Static JSON, no template needed. const manifestJSON = `{ "name": "SimpleRemoter", "short_name": "Remoter", "start_url": "/", "display": "standalone", "orientation": "any", "background_color": "#1a1a2e", "theme_color": "#1a1a2e" }` func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/manifest+json") w.Header().Set("Cache-Control", "public, max-age=86400") _, _ = w.Write([]byte(manifestJSON)) } // staticHandler returns an http.HandlerFunc that serves a fixed byte slice // with the given content-type. Used for embedded third-party assets (xterm.js etc.) // that change infrequently — 1-day browser cache is fine. func staticHandler(body []byte, contentType string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "public, max-age=86400") _, _ = w.Write(body) } }