From 44708d5f3cbff4cf7a897fdb8f78bf02ee673ce8 Mon Sep 17 00:00:00 2001 From: Astra Date: Sat, 3 Oct 2026 12:22:33 +0100 Subject: [PATCH] Installable as an app (PWA): manifests, offline-only service worker, icons --- README.md | 19 ++++++ internal/web/assets/admin.html | 4 ++ internal/web/assets/apple-touch-icon.png | Bin 0 -> 3168 bytes internal/web/assets/common.js | 3 + internal/web/assets/icon-192.png | Bin 0 -> 5958 bytes internal/web/assets/icon-512.png | Bin 0 -> 16748 bytes internal/web/assets/icon-maskable-512.png | Bin 0 -> 8890 bytes internal/web/assets/icon-maskable.svg | 7 +++ internal/web/assets/icon.svg | 7 +++ internal/web/assets/offline.html | 55 +++++++++++++++++ internal/web/assets/public.html | 4 ++ internal/web/assets/signin.html | 4 ++ internal/web/assets/sw.js | 23 +++++++ internal/web/pwa.go | 71 ++++++++++++++++++++++ internal/web/pwa_test.go | 46 ++++++++++++++ internal/web/server.go | 3 + 16 files changed, 246 insertions(+) create mode 100644 internal/web/assets/apple-touch-icon.png create mode 100644 internal/web/assets/icon-192.png create mode 100644 internal/web/assets/icon-512.png create mode 100644 internal/web/assets/icon-maskable-512.png create mode 100644 internal/web/assets/icon-maskable.svg create mode 100644 internal/web/assets/icon.svg create mode 100644 internal/web/assets/offline.html create mode 100644 internal/web/assets/sw.js create mode 100644 internal/web/pwa.go create mode 100644 internal/web/pwa_test.go 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 0000000000000000000000000000000000000000..9a1aca5a665ccfdcf7f560173a8f846ed904e0b6 GIT binary patch literal 3168 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b6efbdb9acc4f4b03ca4a94e5e2ac377d1ee66dc GIT binary patch literal 5958 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 C2!!|m literal 0 HcmV?d00001 diff --git a/internal/web/assets/icon-512.png b/internal/web/assets/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..cae138e69ea300e33798e013a7de12218176a0e9 GIT binary patch literal 16748 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 IYd(tn4^~IScK`qY literal 0 HcmV?d00001 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)