## Mærket Urds oplagte mærke var ᚢ uruz — bogstavet navnet begynder med i den ældre futhark, præcis som Verdandes er ᚹ wunjo. Det blev tegnet sådan først. Men uruz er allerede et af husets egne produkter, og dets egen `scripts/gen-icons.ts` siger i sin kommentar at den "draws the Uruz rune (ᚢ) in rune-gold" med "a tall left vertical, a shoulder across the top, and a shorter" — altså samme glyf, samme konstruktion, kun en anden farve. To af samme persons apps side om side på en hjemmeskærm, hvilket er nøjagtig det sted mærket skal kunne skelnes. ᚦ thurisaz blev forkastet et niveau nede af samme grund: en stav med en trekant til højre, og det er ᚹ også. Ved 16px er de én form. Så bogstavet frem for runen: ð, ethet i Urðr. Centreret ved at tælle malede pixels frem for ud fra path-tallene, fordi en centreret kasse ikke er en centreret glyf: verdande ᚹ blæk x 53–108 midte 80.5 · y 18–131 midte 74.5 urd ð blæk x 39–110 midte 74.5 · y 15–134 midte 74.5 Verdandes +5.5 i x er dens egen bevidste forskydning — en tung stav ved siden af et let flag skal sidde til højre for midten for at se centreret ud. ð er symmetrisk nok til at undvære den, og de to er enige i y. Læsbarhed, målt: ð læses fra 32px og op. Ved 16px er det en klat, og det er ᚹ også — en favicon er ikke det, der skiller to installerede apps i en dock. `productRune` hedder nu `productMark`, fordi ð ikke er en rune. ## Determinisme SvelteKits `kit.version.name` er som standard `Date.now()`. Millisekundet landede i version.json, i index.html og i service-workeren, ændrede indholdshashen på hver chunk der refererer dem, og dermed deres filnavne. To bygninger af samme commit havde forskellige filer i sig. Dockerfilen har i et år sagt at `-trimpath` og en tom buildid "keep the output reproducible". Flagene gjorde deres arbejde; frontenden i binæren bar et tidsstempel. Sætningen var falsk, og ingen læste den. Fastgjort til VERSION, som udgivelsen allerede sender ind, og `dev` når intet sætter den. Målt ved at bygge to gange og diffe træet, før og efter. Prisen er at et lokalt `npm run build` ikke længere ligner en ny version for service-workeren — hvilket kun betyder noget på en udrullet instans, hvor udgivelsen sender det rigtige mærkat. Det gør også CI's nye påstand mulig: de to images deler alle lag, så urd bærer samme binær som verdande frem for en genbygning der tilfældigvis matcher. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
7.2 KiB
Go
178 lines
7.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io/fs"
|
|
)
|
|
|
|
// The product name is in the built frontend as a literal in four places, because
|
|
// all four are read before any JavaScript runs: the window title and the meta
|
|
// description in the HTML shell, and the two names in the web manifest that decide
|
|
// what an installed PWA is called on a home screen.
|
|
//
|
|
// They cannot be filled in by the app at runtime. The shell is parsed before the
|
|
// first script executes, the manifest is fetched by the browser rather than by us,
|
|
// and the edition is not known until /auth/setup-state has answered — so a frontend
|
|
// that renamed itself after loading would show the sibling's name first and correct
|
|
// itself a moment later, on every load, in the tab and on the install prompt.
|
|
//
|
|
// So the server does it, once at startup, on the way out. One frontend build, two
|
|
// names: the build is identical in both editions and the bytes served differ.
|
|
//
|
|
// Each replacement is REQUIRED and the anchors are exact. A rewrite that quietly
|
|
// matched nothing is the whole failure mode here — it leaves an Urd instance
|
|
// calling itself verdande, which is cosmetic enough that nobody files it and
|
|
// nothing else notices.
|
|
type rebrand struct {
|
|
old, new string
|
|
}
|
|
|
|
// shellRebrands returns the replacements for index.html.
|
|
//
|
|
// Note what is NOT in here: `localStorage.getItem('verdande:theme')` and its two
|
|
// neighbours in the inline theme script. Those keys are storage, not a name — the
|
|
// rest of the app reads the same three, so renaming them here and nowhere else
|
|
// would set the theme from one key and read it from another, and the only symptom
|
|
// is the white flash that script exists to prevent. This is why the replacements
|
|
// are written out as whole anchored strings rather than as a replace of the word
|
|
// "verdande".
|
|
func shellRebrands(slug, description string) []rebrand {
|
|
return []rebrand{
|
|
{"<title>verdande</title>", "<title>" + slug + "</title>"},
|
|
{
|
|
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
|
|
`<meta name="description" content="` + description + `" />`,
|
|
},
|
|
}
|
|
}
|
|
|
|
// manifestRebrands returns the replacements for manifest.webmanifest. The
|
|
// manifest's own description is a sentence rather than the shell's "name — sentence"
|
|
// form, so it is passed separately rather than derived.
|
|
func manifestRebrands(slug, description string) []rebrand {
|
|
return []rebrand{
|
|
{`"name": "verdande"`, `"name": "` + slug + `"`},
|
|
{`"short_name": "verdande"`, `"short_name": "` + slug + `"`},
|
|
{`"description": "Opgaver og projekter, delt."`, `"description": "` + description + `"`},
|
|
}
|
|
}
|
|
|
|
// applyRebrands replaces every pair exactly once and fails on the first anchor it
|
|
// cannot find, naming it. Failing loudly rather than returning what it managed is
|
|
// the point: a partial rename is a page whose tab and whose install prompt disagree
|
|
// about which program this is.
|
|
func applyRebrands(raw []byte, pairs []rebrand) ([]byte, error) {
|
|
out := raw
|
|
for _, p := range pairs {
|
|
old := []byte(p.old)
|
|
if bytes.Count(out, old) != 1 {
|
|
return nil, fmt.Errorf("found %d occurrences of %q, want exactly 1", bytes.Count(out, old), p.old)
|
|
}
|
|
out = bytes.Replace(out, old, []byte(p.new), 1)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// rebrandedAssets reads index.html and manifest.webmanifest out of the built
|
|
// frontend and returns them renamed for this edition.
|
|
//
|
|
// Both or neither: an instance serving a renamed shell beside the sibling's
|
|
// manifest installs under the wrong name on a phone, which is the half nobody looks
|
|
// at until it is on their home screen.
|
|
func rebrandedAssets(web fs.FS, slug, shellDesc, manifestDesc string) (shell, manifest []byte, err error) {
|
|
raw, err := fs.ReadFile(web, "index.html")
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("index.html: %w", err)
|
|
}
|
|
shell, err = applyRebrands(raw, shellRebrands(slug, shellDesc))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("index.html: %w", err)
|
|
}
|
|
|
|
raw, err = fs.ReadFile(web, "manifest.webmanifest")
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
|
}
|
|
manifest, err = applyRebrands(raw, manifestRebrands(slug, manifestDesc))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
|
}
|
|
return shell, manifest, nil
|
|
}
|
|
|
|
// The descriptions for the notes edition. Kept beside the replacements they feed
|
|
// rather than in the locale files, because these two strings are read by a browser
|
|
// and a home screen before any locale has been chosen — the shell is in Danish for
|
|
// the same reason the built app.html is.
|
|
const (
|
|
notesShellDescription = "urd — noter, delt."
|
|
notesManifestDescription = "Noter, delt."
|
|
)
|
|
|
|
// renamed returns the renamed bytes for `name`, or nil to serve the built file.
|
|
//
|
|
// Nil for everything in the full edition, which is what keeps that path exactly as
|
|
// it was: one comparison per request against a nil slice.
|
|
func (s *Server) renamed(name string) []byte {
|
|
switch name {
|
|
case "index.html":
|
|
return s.shell
|
|
case "manifest.webmanifest":
|
|
return s.manifest
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// contentTypeFor names the type for the two assets served from memory.
|
|
//
|
|
// http.ServeContent sniffs from the extension, and it gets .html right and
|
|
// .webmanifest wrong — Go's table does not have it, so the sniffer reads the first
|
|
// bytes, sees JSON-ish text and answers text/plain. A manifest served as text/plain
|
|
// is ignored by the browser without an error anywhere: the install prompt simply
|
|
// does not appear.
|
|
func contentTypeFor(name string) string {
|
|
if name == "manifest.webmanifest" {
|
|
return "application/manifest+json"
|
|
}
|
|
return "text/html; charset=utf-8"
|
|
}
|
|
|
|
// notesAssetAliases maps the icon paths the shell and the manifest already point
|
|
// at to urd's own files. Both sets are in the binary; the server decides.
|
|
//
|
|
// Aliased rather than renamed in the manifest, and the reason is the third entry.
|
|
// Safari ignores a manifest's icons entirely and looks for `apple-touch-icon.png`
|
|
// BY NAME — so pointing the manifest at urd-icon-192.png would have left iOS with
|
|
// Verdande's mark, or, if that file were renamed too, with a screenshot of the page
|
|
// as its home-screen icon. Keeping the paths and swapping the bytes behind them
|
|
// fixes all four at once, including the favicon in `app.html`, which is not in the
|
|
// manifest either.
|
|
var notesAssetAliases = map[string]string{
|
|
"icon.svg": "urd-icon.svg",
|
|
"icon-192.png": "urd-icon-192.png",
|
|
"icon-512.png": "urd-icon-512.png",
|
|
"apple-touch-icon.png": "urd-apple-touch-icon.png",
|
|
}
|
|
|
|
// resolveNotesAssets returns the aliases that have a file behind them, and reports
|
|
// the ones that do not.
|
|
//
|
|
// Resolved once at startup rather than per request, so a missing file is one loud
|
|
// line in the log instead of a silent fallback on every page load. The fallback is
|
|
// still to Verdande's icon rather than to nothing: an icon is the one asset whose
|
|
// absence turns into a broken-image placeholder on the Updates screen — which is
|
|
// exactly how four releases of nolimit-views shipped with no icon — so serving the
|
|
// wrong mark beats serving none.
|
|
func resolveNotesAssets(web fs.FS, missing func(name, alias string)) map[string]string {
|
|
out := map[string]string{}
|
|
for name, alias := range notesAssetAliases {
|
|
if _, err := fs.Stat(web, alias); err != nil {
|
|
missing(name, alias)
|
|
continue
|
|
}
|
|
out[name] = alias
|
|
}
|
|
return out
|
|
}
|