diff --git a/docs/pwa-installability-verification.md b/docs/pwa-installability-verification.md new file mode 100644 index 000000000..17a8cd306 --- /dev/null +++ b/docs/pwa-installability-verification.md @@ -0,0 +1,57 @@ +# PWA installability verification + +This change adds a network-only PWA surface to the login and panel pages. It +does not cache panel data, API responses, credentials, or WebSocket traffic. + +## Local checks + +Run these commands from the repository root after installing the pinned Node +and Go toolchains: + +```text +cd frontend +npm run typecheck +npm run lint +npx vitest run --project unit +npx vitest run --project components +npm run build +cd .. +go test ./... +go build ./... +``` + +The built binary must serve these paths beneath the configured `webBasePath`: + +- `manifest.webmanifest` +- `pwa-register.js` +- `service-worker.js` +- `icons/3x-ui-16.png` +- `icons/3x-ui-24.png` +- `icons/3x-ui-32.png` +- `icons/3x-ui-64.png` +- `icons/3x-ui-192.png` +- `icons/3x-ui-512.png` + +The login and panel HTML must contain a manifest link and registration script +whose URLs begin with the same runtime base path. The manifest must contain +`display: "standalone"`, relative `start_url` and `scope`, and all six icon +entries. + +## Live rollout checks + +Before replacing a server binary, record the current x-ui binary checksum and +create a timestamped copy of the binary and `/etc/x-ui/x-ui.db`. Restart only +the `x-ui` service after the candidate is staged. Because x-ui manages Xray as +a child process, the restart can briefly interrupt VPN connections. + +After the restart, verify: + +1. `x-ui` is active and its child Xray process is running. +2. The existing panel URL serves HTML with the PWA manifest link. +3. The manifest, registration script, worker, and all six icons return `200`. +4. Login, authenticated API requests, panel navigation, logout, and the panel + WebSocket all work. +5. At least one VPN client can complete a fresh connection cycle. + +If any check fails, restore the exact binary backup, restart x-ui once, and +repeat the checks against the original build. diff --git a/frontend/README.md b/frontend/README.md index b4432c2cb..6d618135d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -70,6 +70,15 @@ react-query into separate vendor bundles to keep the per-page initial JS small. The Go binary embeds this directory at compile time and `internal/web/controller/dist.go` serves the per-page HTML. +### PWA mode + +The login and panel pages expose a minimal network-only Progressive Web App. +The manifest, service worker, registration script, and icons are embedded with +the frontend and served under the runtime `webBasePath`. The service worker +does not use Cache Storage, does not intercept requests, and does not provide +offline access; panel authentication, API calls, and WebSocket traffic remain +normal network requests. + ## Layout ``` diff --git a/frontend/public/icons/3x-ui-16.png b/frontend/public/icons/3x-ui-16.png new file mode 100644 index 000000000..aa22194c3 Binary files /dev/null and b/frontend/public/icons/3x-ui-16.png differ diff --git a/frontend/public/icons/3x-ui-192.png b/frontend/public/icons/3x-ui-192.png new file mode 100644 index 000000000..ad08566c4 Binary files /dev/null and b/frontend/public/icons/3x-ui-192.png differ diff --git a/frontend/public/icons/3x-ui-24.png b/frontend/public/icons/3x-ui-24.png new file mode 100644 index 000000000..26daebb90 Binary files /dev/null and b/frontend/public/icons/3x-ui-24.png differ diff --git a/frontend/public/icons/3x-ui-32.png b/frontend/public/icons/3x-ui-32.png new file mode 100644 index 000000000..914536a0e Binary files /dev/null and b/frontend/public/icons/3x-ui-32.png differ diff --git a/frontend/public/icons/3x-ui-512.png b/frontend/public/icons/3x-ui-512.png new file mode 100644 index 000000000..b59a339a3 Binary files /dev/null and b/frontend/public/icons/3x-ui-512.png differ diff --git a/frontend/public/icons/3x-ui-64.png b/frontend/public/icons/3x-ui-64.png new file mode 100644 index 000000000..8b3ba254b Binary files /dev/null and b/frontend/public/icons/3x-ui-64.png differ diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 000000000..81ab6802c --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,41 @@ +{ + "name": "3x-ui", + "short_name": "3x-ui", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#1677ff", + "icons": [ + { + "src": "icons/3x-ui-16.png", + "sizes": "16x16", + "type": "image/png" + }, + { + "src": "icons/3x-ui-24.png", + "sizes": "24x24", + "type": "image/png" + }, + { + "src": "icons/3x-ui-32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "icons/3x-ui-64.png", + "sizes": "64x64", + "type": "image/png" + }, + { + "src": "icons/3x-ui-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/3x-ui-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/frontend/public/pwa-register.js b/frontend/public/pwa-register.js new file mode 100644 index 000000000..e81d01af1 --- /dev/null +++ b/frontend/public/pwa-register.js @@ -0,0 +1,14 @@ +(() => { + if (!('serviceWorker' in navigator)) return; + + const script = document.currentScript; + if (!(script instanceof HTMLScriptElement)) return; + + const scriptUrl = new URL(script.src, window.location.href); + const baseUrl = new URL('./', scriptUrl); + const workerUrl = new URL('service-worker.js', baseUrl); + + navigator.serviceWorker.register(workerUrl.pathname, { + scope: baseUrl.pathname, + }).catch(() => {}); +})(); diff --git a/frontend/public/service-worker.js b/frontend/public/service-worker.js new file mode 100644 index 000000000..3bdb5c432 --- /dev/null +++ b/frontend/public/service-worker.js @@ -0,0 +1,9 @@ +self.addEventListener('install', (event) => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('fetch', () => {}); diff --git a/internal/web/controller/dist.go b/internal/web/controller/dist.go index eecc6ab43..54cb2248e 100644 --- a/internal/web/controller/dist.go +++ b/internal/web/controller/dist.go @@ -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(``) +} + 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(``)...) out := bytes.Replace(body, []byte(""), inject, 1) diff --git a/internal/web/controller/dist_test.go b/internal/web/controller/dist_test.go index ad7bc73bf..13de071c3 100644 --- a/internal/web/controller/dist_test.go +++ b/internal/web/controller/dist_test.go @@ -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) + } +} diff --git a/internal/web/controller/pwa.go b/internal/web/controller/pwa.go new file mode 100644 index 000000000..6dc04c311 --- /dev/null +++ b/internal/web/controller/pwa.go @@ -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")) +} diff --git a/internal/web/controller/pwa_test.go b/internal/web/controller/pwa_test.go new file mode 100644 index 000000000..997fd7dd3 --- /dev/null +++ b/internal/web/controller/pwa_test.go @@ -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) + } +} diff --git a/internal/web/web.go b/internal/web/web.go index 32a87c5ae..aa2811554 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -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)