feat(web): add network-only PWA installability (#6190)

* feat(web): add network-only PWA installability

Serve the manifest, registration script, network-only service worker, and icons under the runtime web base path so panels remain installable at arbitrary configured URLs.

This does not add offline caching or change panel, API, database, or Xray behavior.

* chore(docs): remove development planning notes

Keep the pull request focused on the PWA implementation, tests, and user-facing verification documentation.

* feat(web): adopt the 3X logo PWA icon set from #1865

Replace the two placeholder SVG icons with the six-size PNG set
(16/24/32/64/192/512) contributed by @Incognito-Coder in PR #1865.
The PNGs have transparent rounded corners, so the manifest entries
drop the maskable purpose claim and rely on the default any.

---------

Co-authored-by: korsun009 <277924786+korsun009@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
korsun009
2026-08-18 16:33:20 +03:00
committed by GitHub
parent 3f1dd4bf5a
commit 3a2f9b48da
16 changed files with 343 additions and 0 deletions
+23
View File
@@ -71,6 +71,28 @@ func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
return json.Marshal(doc)
}
func normalizeWebBasePath(basePath string) string {
if basePath == "" {
return "/"
}
if !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath
}
if !strings.HasSuffix(basePath, "/") {
basePath += "/"
}
return basePath
}
func pwaHeadInjection(basePath, pageName string) []byte {
if pageName != "index.html" && pageName != "login.html" {
return nil
}
basePath = normalizeWebBasePath(basePath)
return []byte(`<link rel="manifest" href="` + htmlpkg.EscapeString(basePath+"manifest.webmanifest") + `"><script data-cfasync="false" defer src="` + htmlpkg.EscapeString(basePath+"pwa-register.js") + `"></script>`)
}
func serveDistPage(c *gin.Context, name string) {
body, err := fs.ReadFile(distFS, "dist/"+name)
if err != nil {
@@ -120,6 +142,7 @@ func serveDistPage(c *gin.Context, name string) {
inject := []byte(script)
inject = append(inject, csrfMeta...)
inject = append(inject, basePathMeta...)
inject = append(inject, pwaHeadInjection(basePath, name)...)
inject = append(inject, []byte(`</head>`)...)
out := bytes.Replace(body, []byte("</head>"), inject, 1)
+31
View File
@@ -2,6 +2,7 @@ package controller
import (
"encoding/json"
"strings"
"testing"
)
@@ -40,3 +41,33 @@ func TestWithServerBasePathInvalidJSON(t *testing.T) {
t.Errorf("expected error on invalid spec, got nil")
}
}
func TestPWAHeadInjectionUsesRuntimeBasePath(t *testing.T) {
tests := []struct {
name string
basePath string
wantPath string
}{
{name: "root", basePath: "/", wantPath: "/manifest.webmanifest"},
{name: "secret path", basePath: "panel-secret", wantPath: "/panel-secret/manifest.webmanifest"},
{name: "trailing slash", basePath: "/panel-secret/", wantPath: "/panel-secret/manifest.webmanifest"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
head := string(pwaHeadInjection(test.basePath, "login.html"))
if !strings.Contains(head, `href="`+test.wantPath+`"`) {
t.Fatalf("manifest URL = %q, want %q", head, test.wantPath)
}
if !strings.Contains(head, `src="`+strings.Replace(test.wantPath, "manifest.webmanifest", "pwa-register.js", 1)+`"`) {
t.Fatalf("registration URL = %q", head)
}
})
}
}
func TestPWAHeadInjectionSkipsSubscriptionPage(t *testing.T) {
if got := pwaHeadInjection("/panel-secret/", "subpage.html"); got != nil {
t.Fatalf("subpage injection = %q, want nil", got)
}
}
+60
View File
@@ -0,0 +1,60 @@
package controller
import (
"io/fs"
"net/http"
"github.com/gin-gonic/gin"
)
type pwaAsset struct {
path string
contentType string
}
var pwaAssets = map[string]pwaAsset{
"manifest.webmanifest": {path: "dist/manifest.webmanifest", contentType: "application/manifest+json; charset=utf-8"},
"pwa-register.js": {path: "dist/pwa-register.js", contentType: "application/javascript; charset=utf-8"},
"service-worker.js": {path: "dist/service-worker.js", contentType: "application/javascript; charset=utf-8"},
"icons/3x-ui-16.png": {path: "dist/icons/3x-ui-16.png", contentType: "image/png"},
"icons/3x-ui-24.png": {path: "dist/icons/3x-ui-24.png", contentType: "image/png"},
"icons/3x-ui-32.png": {path: "dist/icons/3x-ui-32.png", contentType: "image/png"},
"icons/3x-ui-64.png": {path: "dist/icons/3x-ui-64.png", contentType: "image/png"},
"icons/3x-ui-192.png": {path: "dist/icons/3x-ui-192.png", contentType: "image/png"},
"icons/3x-ui-512.png": {path: "dist/icons/3x-ui-512.png", contentType: "image/png"},
}
func servePWAAsset(c *gin.Context, assetName string) {
asset, ok := pwaAssets[assetName]
if !ok {
c.AbortWithStatus(http.StatusNotFound)
return
}
body, err := fs.ReadFile(distFS, asset.path)
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Data(http.StatusOK, asset.contentType, body)
}
func ServePWAManifest(c *gin.Context) {
servePWAAsset(c, "manifest.webmanifest")
}
func ServePWARegister(c *gin.Context) {
servePWAAsset(c, "pwa-register.js")
}
func ServePWAServiceWorker(c *gin.Context) {
servePWAAsset(c, "service-worker.js")
}
func ServePWAIcon(c *gin.Context) {
servePWAAsset(c, "icons/"+c.Param("name"))
}
+95
View File
@@ -0,0 +1,95 @@
package controller
import (
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"github.com/gin-gonic/gin"
)
func TestServePWAAssets(t *testing.T) {
oldDistFS := distFS
distFS = fstest.MapFS{
"dist/manifest.webmanifest": &fstest.MapFile{Data: []byte(`{"name":"3x-ui"}`)},
"dist/pwa-register.js": &fstest.MapFile{Data: []byte("register")},
"dist/service-worker.js": &fstest.MapFile{Data: []byte("worker")},
"dist/icons/3x-ui-192.png": &fstest.MapFile{Data: []byte("icon-192")},
"dist/icons/3x-ui-512.png": &fstest.MapFile{Data: []byte("icon-512")},
}
t.Cleanup(func() { distFS = oldDistFS })
tests := []struct {
name string
handler gin.HandlerFunc
contentType string
body string
}{
{name: "manifest", handler: ServePWAManifest, contentType: "application/manifest+json; charset=utf-8", body: `{"name":"3x-ui"}`},
{name: "registration", handler: ServePWARegister, contentType: "application/javascript; charset=utf-8", body: "register"},
{name: "worker", handler: ServePWAServiceWorker, contentType: "application/javascript; charset=utf-8", body: "worker"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
test.handler(context)
if response.Code != 200 {
t.Fatalf("status = %d, want 200", response.Code)
}
if response.Header().Get("Content-Type") != test.contentType {
t.Errorf("content type = %q, want %q", response.Header().Get("Content-Type"), test.contentType)
}
if response.Header().Get("Cache-Control") != "no-cache, no-store, must-revalidate" {
t.Errorf("cache control = %q", response.Header().Get("Cache-Control"))
}
if strings.TrimSpace(response.Body.String()) != test.body {
t.Errorf("body = %q, want %q", response.Body.String(), test.body)
}
})
}
}
func TestServePWAIconServesPNG(t *testing.T) {
oldDistFS := distFS
distFS = fstest.MapFS{
"dist/icons/3x-ui-192.png": &fstest.MapFile{Data: []byte("icon-192")},
}
t.Cleanup(func() { distFS = oldDistFS })
gin.SetMode(gin.TestMode)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Params = gin.Params{{Key: "name", Value: "3x-ui-192.png"}}
ServePWAIcon(context)
if response.Code != 200 {
t.Fatalf("status = %d, want 200", response.Code)
}
if got := response.Header().Get("Content-Type"); got != "image/png" {
t.Errorf("content type = %q, want %q", got, "image/png")
}
if response.Body.String() != "icon-192" {
t.Errorf("body = %q, want %q", response.Body.String(), "icon-192")
}
}
func TestServePWAIconRejectsUnknownName(t *testing.T) {
oldDistFS := distFS
distFS = fstest.MapFS{}
t.Cleanup(func() { distFS = oldDistFS })
gin.SetMode(gin.TestMode)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Params = gin.Params{{Key: "name", Value: "../../etc/passwd"}}
ServePWAIcon(context)
if response.Code != 404 {
t.Fatalf("status = %d, want 404", response.Code)
}
}
+4
View File
@@ -244,6 +244,10 @@ func (s *Server) initRouter() (*gin.Engine, error) {
controller.SetDistFS(distFS)
g := engine.Group(basePath)
g.GET("/manifest.webmanifest", controller.ServePWAManifest)
g.GET("/pwa-register.js", controller.ServePWARegister)
g.GET("/service-worker.js", controller.ServePWAServiceWorker)
g.GET("/icons/:name", controller.ServePWAIcon)
s.index = controller.NewIndexController(g)
s.panel = controller.NewXUIController(g)