Android app (TWA) in android/, built with podman; serve assetlinks.json for it

This commit is contained in:
Astra 2026-10-03 12:44:11 +01:00
parent 44708d5f3c
commit 7900bf5b3e
21 changed files with 313 additions and 0 deletions

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"io/fs"
"net/http"
"strings"
)
// The site installs as an app (a PWA): a web app manifest, plus the service worker in
@ -62,6 +63,34 @@ func (s *Server) manifest(staff bool) http.HandlerFunc {
}
}
// assetLinks tells Chrome the Android app belongs to this site, so it opens the site full screen
// (a Trusted Web Activity) instead of with its URL bar showing.
func (s *Server) assetLinks(w http.ResponseWriter, r *http.Request) {
var certs []string
for _, c := range s.cfg.AndroidCerts {
if c = strings.ToUpper(strings.TrimSpace(c)); c != "" {
certs = append(certs, c)
}
}
if s.cfg.AndroidPackage == "" || len(certs) == 0 {
http.NotFound(w, r)
return
}
type target struct {
Namespace string `json:"namespace"`
PackageName string `json:"package_name"`
Fingerprints []string `json:"sha256_cert_fingerprints"`
}
type statement struct {
Relation []string `json:"relation"`
Target target `json:"target"`
}
writeJSON(w, http.StatusOK, []statement{{
Relation: []string{"delegate_permission/common.handle_all_urls"},
Target: target{Namespace: "android_app", PackageName: s.cfg.AndroidPackage, Fingerprints: certs},
}})
}
// 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) {

View file

@ -44,3 +44,19 @@ func TestPWA(t *testing.T) {
}
get("/static/offline.html")
}
func TestAssetLinks(t *testing.T) {
serve := func(cfg Config) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
(&Server{cfg: cfg, site: defaultSite()}).Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/assetlinks.json", nil))
return rec
}
if rec := serve(Config{AndroidCerts: []string{""}}); rec.Code != http.StatusNotFound {
t.Errorf("unset: status %d", rec.Code)
}
rec := serve(Config{AndroidPackage: "sh.zio.cs2", AndroidCerts: []string{" aa:bb ", "", "CC:DD"}})
want := `[{"relation":["delegate_permission/common.handle_all_urls"],"target":{"namespace":"android_app","package_name":"sh.zio.cs2","sha256_cert_fingerprints":["AA:BB","CC:DD"]}}]`
if got := strings.TrimSpace(rec.Body.String()); rec.Code != http.StatusOK || got != want {
t.Errorf("status %d, body %s", rec.Code, got)
}
}

View file

@ -31,6 +31,10 @@ type Config struct {
// Limits used when SimpleAdmin's config file isn't available.
MaxBanDuration int
MaxMuteDuration int
// The Android app (android/ in this repo), for /.well-known/assetlinks.json: its package and
// the SHA-256 fingerprints of the keys it's signed with. Empty serves no assetlinks.json.
AndroidPackage string
AndroidCerts []string
}
type Server struct {
@ -84,6 +88,7 @@ func (s *Server) Handler() http.Handler {
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 /.well-known/assetlinks.json", s.assetLinks)
mux.HandleFunc("GET /auth/login", s.login)
mux.HandleFunc("GET /auth/callback", s.callback)