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)