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)