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

@ -3,6 +3,10 @@
<head>
<meta charset="utf-8">
<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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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)}`;
// 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>
<meta charset="utf-8">
<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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View file

@ -3,6 +3,10 @@
<head>
<meta charset="utf-8">
<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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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.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)