diff --git a/README.md b/README.md index f2f3cf6..c2be735 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,25 @@ connect, so a rename on the same IP loses the old name. `sql/players-ips-name-hi on its player page and in name search. SimpleAdmin needs no changes. Run it once against the SimpleAdmin database; the file has the revert. Names are still only recorded at connect. +### Installing as an app + +The site is a PWA, so Chrome on Android (and desktop Chrome or Edge) offers to install it: menu > +**Add to Home screen** / **Install app**. The public site and the staff panel install as separate +apps (`/manifest.webmanifest` opens on `/`, `/admin.webmanifest` on `/admin`); install the staff one +from the staff panel or its sign-in page. Installing needs the site on HTTPS. The service worker +(`assets/sw.js`, served at `/sw.js`) caches only an offline page, never data. + +The icons are rendered from `assets/icon.svg` and `assets/icon-maskable.svg` (the brand mark kept +inside the maskable safe zone). After changing either: + +``` +cd internal/web/assets +rsvg-convert -w 192 -h 192 icon.svg -o icon-192.png +rsvg-convert -w 512 -h 512 icon.svg -o icon-512.png +rsvg-convert -w 512 -h 512 icon-maskable.svg -o icon-maskable-512.png +rsvg-convert -w 180 -h 180 icon-maskable.svg -o apple-touch-icon.png +``` + ## Limitations and caveats - **Tested against a real MySQL 8.4** with SimpleAdmin 1.8.2b's migrations applied, and a fake game diff --git a/internal/web/assets/admin.html b/internal/web/assets/admin.html index af7cca7..47c7af4 100644 --- a/internal/web/assets/admin.html +++ b/internal/web/assets/admin.html @@ -3,6 +3,10 @@ + + + + Staff panel diff --git a/internal/web/assets/apple-touch-icon.png b/internal/web/assets/apple-touch-icon.png new file mode 100644 index 0000000..9a1aca5 Binary files /dev/null and b/internal/web/assets/apple-touch-icon.png differ diff --git a/internal/web/assets/common.js b/internal/web/assets/common.js index 6621747..c4a99ac 100644 --- a/internal/web/assets/common.js +++ b/internal/web/assets/common.js @@ -165,3 +165,6 @@ function toast(msg) { } const steamProfile = (sid) => `https://steamcommunity.com/profiles/${encodeURIComponent(sid)}`; + +// The service worker makes the site installable as an app (see sw.js). +if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js").catch(() => {}); diff --git a/internal/web/assets/icon-192.png b/internal/web/assets/icon-192.png new file mode 100644 index 0000000..b6efbdb Binary files /dev/null and b/internal/web/assets/icon-192.png differ diff --git a/internal/web/assets/icon-512.png b/internal/web/assets/icon-512.png new file mode 100644 index 0000000..cae138e Binary files /dev/null and b/internal/web/assets/icon-512.png differ diff --git a/internal/web/assets/icon-maskable-512.png b/internal/web/assets/icon-maskable-512.png new file mode 100644 index 0000000..d476d02 Binary files /dev/null and b/internal/web/assets/icon-maskable-512.png differ diff --git a/internal/web/assets/icon-maskable.svg b/internal/web/assets/icon-maskable.svg new file mode 100644 index 0000000..44f75c4 --- /dev/null +++ b/internal/web/assets/icon-maskable.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/internal/web/assets/icon.svg b/internal/web/assets/icon.svg new file mode 100644 index 0000000..ded5b52 --- /dev/null +++ b/internal/web/assets/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/internal/web/assets/offline.html b/internal/web/assets/offline.html new file mode 100644 index 0000000..81ff1f1 --- /dev/null +++ b/internal/web/assets/offline.html @@ -0,0 +1,55 @@ + + + + + + + + + Offline + + + + +
+ +

Can't reach the panel

+

Check your connection. This page tries again by itself every few seconds.

+ Try again +
+ + diff --git a/internal/web/assets/public.html b/internal/web/assets/public.html index 18d7d3c..feaecb7 100644 --- a/internal/web/assets/public.html +++ b/internal/web/assets/public.html @@ -3,6 +3,10 @@ + + + + Bans and staff diff --git a/internal/web/assets/signin.html b/internal/web/assets/signin.html index 68638dd..7064d64 100644 --- a/internal/web/assets/signin.html +++ b/internal/web/assets/signin.html @@ -3,6 +3,10 @@ + + + + Staff sign in diff --git a/internal/web/assets/sw.js b/internal/web/assets/sw.js new file mode 100644 index 0000000..47a780f --- /dev/null +++ b/internal/web/assets/sw.js @@ -0,0 +1,23 @@ +// The service worker is what lets the site be installed as an app. It caches nothing but the +// offline page: every request still goes to the network, so nobody sees stale bans or ranks. + +const CACHE = "offline-v1"; +const OFFLINE = "/static/offline.html"; + +self.addEventListener("install", (e) => { + e.waitUntil(caches.open(CACHE).then((c) => c.add(OFFLINE)).then(() => self.skipWaiting())); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()), + ); +}); + +// Only page loads get the offline page; API calls fail as they always have, and the pages say so. +self.addEventListener("fetch", (e) => { + if (e.request.mode !== "navigate") return; + e.respondWith(fetch(e.request).catch(() => caches.match(OFFLINE))); +}); diff --git a/internal/web/pwa.go b/internal/web/pwa.go new file mode 100644 index 0000000..5dbd877 --- /dev/null +++ b/internal/web/pwa.go @@ -0,0 +1,71 @@ +package web + +import ( + "encoding/json" + "io/fs" + "net/http" +) + +// The site installs as an app (a PWA): a web app manifest, plus the service worker in +// assets/sw.js. The public site and the staff panel are separate apps, so each opens on its own +// page; both cover the whole site, since signing in goes through /auth. + +type manifest struct { + ID string `json:"id"` + Name string `json:"name"` + ShortName string `json:"short_name"` + Description string `json:"description"` + StartURL string `json:"start_url"` + Scope string `json:"scope"` + Display string `json:"display"` + BackgroundColor string `json:"background_color"` + ThemeColor string `json:"theme_color"` + Icons []manifestIcon `json:"icons"` +} + +type manifestIcon struct { + Src string `json:"src"` + Sizes string `json:"sizes"` + Type string `json:"type"` + Purpose string `json:"purpose,omitempty"` +} + +func (s *Server) manifest(staff bool) http.HandlerFunc { + m := manifest{ + ID: "/", + Name: s.site.Title, + ShortName: s.site.Title, + Description: "Live server, bans and staff.", + StartURL: "/", + Scope: "/", + Display: "standalone", + BackgroundColor: "#14161b", + ThemeColor: "#1b1e25", + Icons: []manifestIcon{ + {Src: "/static/icon-192.png", Sizes: "192x192", Type: "image/png"}, + {Src: "/static/icon-512.png", Sizes: "512x512", Type: "image/png"}, + {Src: "/static/icon-maskable-512.png", Sizes: "512x512", Type: "image/png", Purpose: "maskable"}, + }, + } + if staff { + m.ID = "/admin" + m.Name = s.site.Title + " staff panel" + m.ShortName = s.site.Title + " staff" + m.Description = "Bans, mutes, ranks and the live server, for staff." + m.StartURL = "/admin" + } + body, _ := json.Marshal(m) + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/manifest+json") + w.Header().Set("Cache-Control", "no-cache") + w.Write(body) + } +} + +// serviceWorker serves sw.js from the root, since a worker only controls pages under its own path. +func serviceWorker(static fs.FS) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") + http.ServeFileFS(w, r, static, "sw.js") + } +} diff --git a/internal/web/pwa_test.go b/internal/web/pwa_test.go new file mode 100644 index 0000000..bc923af --- /dev/null +++ b/internal/web/pwa_test.go @@ -0,0 +1,46 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPWA(t *testing.T) { + h := (&Server{site: defaultSite()}).Handler() + get := func(path string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status %d", path, rec.Code) + } + return rec + } + + for path, start := range map[string]string{"/manifest.webmanifest": "/", "/admin.webmanifest": "/admin"} { + rec := get(path) + if ct := rec.Header().Get("Content-Type"); ct != "application/manifest+json" { + t.Errorf("%s: content type %q", path, ct) + } + var m manifest + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("%s: %v", path, err) + } + if m.StartURL != start || m.ID != start || m.Scope != "/" || m.Display != "standalone" { + t.Errorf("%s: %+v", path, m) + } + // Every icon must exist, or Chrome won't offer to install. + for _, icon := range m.Icons { + if ct := get(icon.Src).Header().Get("Content-Type"); ct != icon.Type { + t.Errorf("%s: %s is %q, manifest says %q", path, icon.Src, ct, icon.Type) + } + } + } + + if ct := get("/sw.js").Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { + t.Errorf("/sw.js: content type %q", ct) + } + get("/static/offline.html") +} diff --git a/internal/web/server.go b/internal/web/server.go index abb0beb..827e8d3 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -81,6 +81,9 @@ func (s *Server) Handler() http.Handler { }) mux.HandleFunc("GET /admin", s.adminPage(static)) mux.Handle("GET /static/", http.StripPrefix("/static/", files)) + mux.HandleFunc("GET /manifest.webmanifest", s.manifest(false)) + mux.HandleFunc("GET /admin.webmanifest", s.manifest(true)) + mux.HandleFunc("GET /sw.js", serviceWorker(static)) mux.HandleFunc("GET /auth/login", s.login) mux.HandleFunc("GET /auth/callback", s.callback)