Installable as an app (PWA): manifests, offline-only service worker, icons

This commit is contained in:
Astra 2026-10-03 12:22:33 +01:00
parent b26360e8f3
commit 44708d5f3c
16 changed files with 246 additions and 0 deletions

View file

@ -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 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. 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 ## Limitations and caveats
- **Tested against a real MySQL 8.4** with SimpleAdmin 1.8.2b's migrations applied, and a fake game - **Tested against a real MySQL 8.4** with SimpleAdmin 1.8.2b's migrations applied, and a fake game

View file

@ -3,6 +3,10 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#1b1e25">
<link rel="manifest" href="/admin.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<title>Staff panel</title> <title>Staff panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -165,3 +165,6 @@ function toast(msg) {
} }
const steamProfile = (sid) => `https://steamcommunity.com/profiles/${encodeURIComponent(sid)}`; 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(() => {});

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#14161b"/>
<g transform="translate(112 112) scale(9)">
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 377 B

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#14161b"/>
<g transform="translate(64 64) scale(12)">
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#1b1e25">
<!-- Served in place of whatever page failed to load, so a refresh retries that page. -->
<meta http-equiv="refresh" content="15">
<title>Offline</title>
<!-- Self-contained: nothing else is cached, so styles.css and the fonts can't load from here. -->
<style>
html, body { margin: 0; height: 100%; }
body {
display: grid;
place-items: center;
padding: 16px;
background: #14161b;
color: #e6e8ee;
font: 16px/1.5 system-ui, sans-serif;
color-scheme: dark;
}
main {
max-width: 360px;
padding: 32px 24px;
text-align: center;
background: #1b1e25;
border: 1px solid #2e333f;
border-radius: 12px;
}
svg { width: 48px; height: 48px; }
h1 { margin: 12px 0 4px; font-size: 22px; }
p { margin: 0 0 20px; color: #8a91a0; }
a {
display: inline-block;
padding: 8px 18px;
background: #fe113d;
color: #fff;
border-radius: 8px;
font-weight: 600;
text-decoration: none;
}
</style>
</head>
<body>
<main>
<svg viewBox="0 0 32 32" aria-hidden="true">
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<h1>Can't reach the panel</h1>
<p>Check your connection. This page tries again by itself every few seconds.</p>
<a href="">Try again</a>
</main>
</body>
</html>

View file

@ -3,6 +3,10 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#1b1e25">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<title>Bans and staff</title> <title>Bans and staff</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View file

@ -3,6 +3,10 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#1b1e25">
<link rel="manifest" href="/admin.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<title>Staff sign in</title> <title>Staff sign in</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

23
internal/web/assets/sw.js Normal file
View file

@ -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)));
});

71
internal/web/pwa.go Normal file
View file

@ -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")
}
}

46
internal/web/pwa_test.go Normal file
View file

@ -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")
}

View file

@ -81,6 +81,9 @@ func (s *Server) Handler() http.Handler {
}) })
mux.HandleFunc("GET /admin", s.adminPage(static)) mux.HandleFunc("GET /admin", s.adminPage(static))
mux.Handle("GET /static/", http.StripPrefix("/static/", files)) 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/login", s.login)
mux.HandleFunc("GET /auth/callback", s.callback) mux.HandleFunc("GET /auth/callback", s.callback)