mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 14:13:03 +03:00
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:
57
docs/pwa-installability-verification.md
Normal file
57
docs/pwa-installability-verification.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
BIN
frontend/public/icons/3x-ui-16.png
Normal file
BIN
frontend/public/icons/3x-ui-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/icons/3x-ui-192.png
Normal file
BIN
frontend/public/icons/3x-ui-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/public/icons/3x-ui-24.png
Normal file
BIN
frontend/public/icons/3x-ui-24.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
frontend/public/icons/3x-ui-32.png
Normal file
BIN
frontend/public/icons/3x-ui-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
frontend/public/icons/3x-ui-512.png
Normal file
BIN
frontend/public/icons/3x-ui-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
BIN
frontend/public/icons/3x-ui-64.png
Normal file
BIN
frontend/public/icons/3x-ui-64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
41
frontend/public/manifest.webmanifest
Normal file
41
frontend/public/manifest.webmanifest
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
frontend/public/pwa-register.js
Normal file
14
frontend/public/pwa-register.js
Normal file
@@ -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(() => {});
|
||||
})();
|
||||
9
frontend/public/service-worker.js
Normal file
9
frontend/public/service-worker.js
Normal file
@@ -0,0 +1,9 @@
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', () => {});
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
internal/web/controller/pwa.go
Normal file
60
internal/web/controller/pwa.go
Normal 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
internal/web/controller/pwa_test.go
Normal file
95
internal/web/controller/pwa_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user