diff --git a/docs/editions.md b/docs/editions.md index c3478d1..ed2c1a8 100644 --- a/docs/editions.md +++ b/docs/editions.md @@ -26,10 +26,7 @@ No second image and no rune of its own, deliberately. The edition is for an operator who wants an instance that genuinely has no task routes — the routes are absent from the router, not hidden in the interface. It is -**not** how urd is normally reached. That is being built as a second face of one -instance, with its own entry point and its own web manifest, so that both can be -installed as separate apps while sharing one database, one login and one set of -links. Until that lands, this page describes the edition only. +**not** how urd is normally reached. That is the second door, below. Two details that surprise people who look inside: @@ -41,6 +38,64 @@ Two details that surprise people who look inside: and never reads the manifest, so renaming paths would have left iOS with the wrong mark. +## The second door: two apps, one instance + +urd is normally reached at **`/urd`** on an ordinary verdande. One instance, one +database, one login, one set of links between notes and tasks — and two things you +can install. + +| | verdande | urd | +|---|---|---| +| entry | `/` | `/urd` | +| manifest | `/manifest.webmanifest` | `/urd.webmanifest` | +| manifest `id` | `/` | `/urd` | +| tab and mark | verdande, ᚹ | urd, ð | +| database, account, login | the same | the same | + +An operating system does not install a URL, it installs a **manifest**, and the +identity of an installed app is that manifest's `id`. Two manifests on one origin +with different ids are two apps; two URLs sharing one manifest are one app with a +bookmark. That is the whole mechanism. + +The shell is served per door rather than patched by JavaScript after the page +loads, because iOS reads the page's `` when somebody taps Add to Home Screen +— and Safari looks for `apple-touch-icon` **by name**, having historically ignored +the manifest's icons altogether. + +urd's manifest is built from verdande's rather than written beside it, so the +fields the two genuinely share — the colours, the display mode, the language — +come from one place and cannot drift. Six keys are changed: `id`, `start_url`, +`scope`, the two names and the icons. An icon in the built manifest that urd has no +file for stops the server rather than being passed through, because the symptom +would otherwise be urd's install prompt quietly showing Verdande's mark. + +### The face is a preference, not a gate + +Entering at `/urd` makes that **window** wear urd's face: its mark, its name, its +tab. It is kept strictly apart from the notes edition in the code, and the +distinction is the one thing worth reading twice: + +| | what it means | what follows | +|---|---|---| +| `notesOnly` | the server has no task routes | a **guard** — the interface must not fetch them, they answer 404 | +| `urdFace` | this window came in through the urd door | a **preference** — the routes are there, the data is yours, only the name differs | + +Conflate them and the urd face starts hiding data rather than chrome: a notebook +would stop showing its tasks because somebody chose a different entrance. A test +asserts the opposite directly — the urd window still gets 200 from +`/api/v1/tasks`. + +The face lives in `sessionStorage`, not `localStorage`, and that is what lets the +two apps be two apps: they share an origin, so they share localStorage, cookies and +the service worker. `sessionStorage` is per window, and an installed PWA is its own +window. It survives a reload, which it must — after the first navigation the +address is `/noter` and nobody passes through `/urd` again. + +!!! note "One thing the two doors cannot have separately" + Theme, typeface and text size are shared, because they are in the localStorage + the origin shares. That was a deliberate choice: splitting them per face would + mean migrating everybody's stored settings for a difference few people want. + ## Moving between the two, measured A `/data` directory moves between the editions in **both directions, losslessly**. diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 53fe0ed..506be82 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -86,6 +86,12 @@ type Server struct { // Icon paths this edition serves from another file. Empty in the full edition. assets map[string]string + // The second door: /urd and its own web manifest. Built on every instance, + // whatever the edition, because it is how the notes face is installed as its + // own app rather than a property of the instance. See shell.go. + urdShell []byte + urdManifest []byte + router chi.Router } @@ -135,6 +141,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server }) } + // The second door, on every edition. A notes instance already calls itself urd + // at the root; the door is still built there so the two addresses behave the + // same, and so a single test does not have to know which edition it is in. + if web != nil { + urdShell, urdManifest, err := urdDoorAssets(web) + if err != nil { + log.Error("the urd door could not be built; /urd will not be its own app", "err", err) + } else { + s.urdShell, s.urdManifest = urdShell, urdManifest + } + } + if s.shell != nil { s.csp = contentSecurityPolicy(scriptHashesIn(s.shell)) } else { @@ -797,6 +815,15 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) { name = "index.html" } + // The second door, before anything else touches the filesystem: /urd is not a + // file, so the fallback below would hand back the other door's shell. + if b := s.door(name); b != nil { + w.Header().Set("Content-Type", contentTypeFor(name)) + w.Header().Set("Cache-Control", "no-cache") + http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b)) + return + } + // This edition's own icon, where it has one. Before the Open rather than after, // unlike the shell below: these are real files, so there is no fallback to get // in the way, and resolveNotesAssets has already checked each one exists. diff --git a/internal/httpapi/shell.go b/internal/httpapi/shell.go index fc5ae54..ceb7277 100644 --- a/internal/httpapi/shell.go +++ b/internal/httpapi/shell.go @@ -2,8 +2,10 @@ package httpapi import ( "bytes" + "encoding/json" "fmt" "io/fs" + "strings" ) // The product name is in the built frontend as a literal in four places, because @@ -132,7 +134,12 @@ func (s *Server) renamed(name string) []byte { // 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" { + // Any .webmanifest, not the one name — there are two of them now, and the + // second one was served as HTML for about four minutes because this read + // `name == "manifest.webmanifest"`. Nothing would have reported it: a manifest + // with the wrong type is ignored silently, and the only symptom is an install + // prompt that does not appear. + if strings.HasSuffix(name, ".webmanifest") { return "application/manifest+json" } return "text/html; charset=utf-8" @@ -175,3 +182,160 @@ func resolveNotesAssets(web fs.FS, missing func(name, alias string)) map[string] } return out } + +// --------------------------------------------------------------------------- +// The second door. +// +// One instance, one database, one login — and two things you can install. urd is +// reached at /urd, which serves the same single-page app under its own identity: +// its own title, its own mark, and its own web manifest. +// +// The manifest is the part that matters and the part that is easy to get wrong. +// An operating system does not install a URL, it installs a *manifest*, and the +// identity of an installed app is the manifest's `id`. Two manifests on one origin +// with different ids are two installable apps; two URLs sharing one manifest are +// one app with a bookmark. +// +// And the shell has to be served per door rather than patched by JavaScript, +// because iOS reads the page's when somebody taps Add to Home Screen — +// Safari looks for apple-touch-icon BY NAME and has historically ignored the +// manifest's icons entirely. A head rewritten after load is a head that may or may +// not be the one iOS read. +// --------------------------------------------------------------------------- + +// urdDoorPath is the entry point, and it is also the manifest's id. One constant, +// because those two being the same string is the whole mechanism. +const urdDoorPath = "/urd" + +// urdShellRebrands rewrites the four things in the shell's that decide what +// this door is: what the tab says, what a crawler reads, which manifest the browser +// fetches, and which icons it finds without the manifest. +// +// Note what is NOT in here, for the same reason as in shellRebrands above: the +// three `verdande:` localStorage keys in the inline theme script. They are storage +// rather than a name, the rest of the app reads them, and — now more than before — +// the two doors share an origin and therefore share that storage. Renaming them +// here would give the two apps different themes by accident and lose everybody's +// setting on the way. +func urdShellRebrands() []rebrand { + return []rebrand{ + {"verdande", "urd"}, + { + ``, + ``, + }, + { + ``, + ``, + }, + { + ``, + ``, + }, + { + ``, + ``, + }, + } +} + +// urdManifest builds the second door's manifest from the one the frontend build +// produced, rather than writing a second one by hand. +// +// Transformed as JSON rather than by string replacement, which the shell above +// does: a manifest needs keys the built one does not have — `id` and `start_url` +// — so there is nothing to anchor a replacement on, and a hand-written copy would +// drift on the fields the two genuinely share (the colours, the display mode, the +// language). +// +// `id` is what makes this a second app rather than a bookmark. `scope` is spelled +// out even though "/" is what the spec would default to from a start_url of +// "/urd": an app whose scope did not cover the whole origin would open its own +// links in a browser tab the first time somebody navigated out of /urd, and that +// is a rule nobody should have to remember while reading this. +func urdManifest(raw []byte) ([]byte, error) { + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("the built manifest is not JSON: %w", err) + } + + m["id"] = urdDoorPath + m["start_url"] = urdDoorPath + m["scope"] = "/" + m["name"] = "urd" + m["short_name"] = "urd" + m["description"] = notesManifestDescription + + icons, ok := m["icons"].([]any) + if !ok || len(icons) == 0 { + return nil, fmt.Errorf("the built manifest has no icons to rewrite") + } + for i, entry := range icons { + icon, ok := entry.(map[string]any) + if !ok { + return nil, fmt.Errorf("icon %d is not an object", i) + } + src, _ := icon["src"].(string) + // Every icon must be one this door has a file for. Refusing an unknown one + // is the point: an icon added to the manifest and not to this map would + // otherwise leave urd's install prompt showing Verdande's mark, which is + // cosmetic enough that nobody files it. + swapped, ok := map[string]string{ + "/icon.svg": "/urd-icon.svg", + "/icon-192.png": "/urd-icon-192.png", + "/icon-512.png": "/urd-icon-512.png", + }[src] + if !ok { + return nil, fmt.Errorf("icon %d is %q, which urd has no file for", i, src) + } + icon["src"] = swapped + } + + out, err := json.MarshalIndent(m, "", " ") + if err != nil { + return nil, err + } + return append(out, '\n'), nil +} + +// urdDoorAssets builds the second door from the files the frontend build produced. +// +// Both or neither, and loudly: a door whose shell points at /urd.webmanifest while +// that manifest is not being served is an install prompt that silently falls back +// to the other app's identity. +func urdDoorAssets(web fs.FS) (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, urdShellRebrands()) + 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 = urdManifest(raw) + if err != nil { + return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err) + } + return shell, manifest, nil +} + +// door returns the bytes for the second door's two paths, or nil. +// +// Keyed on the requested path rather than on the resolved file, unlike `renamed` +// below: /urd is not a file and never will be, so it has to be answered before the +// fallback to index.html gets to it — which would serve the other door's shell +// under this door's address, with no error anywhere. +func (s *Server) door(name string) []byte { + switch name { + case "urd": + return s.urdShell + case "urd.webmanifest": + return s.urdManifest + } + return nil +} diff --git a/internal/httpapi/shell_test.go b/internal/httpapi/shell_test.go index 52be2ea..62d6da8 100644 --- a/internal/httpapi/shell_test.go +++ b/internal/httpapi/shell_test.go @@ -1,6 +1,7 @@ package httpapi import ( + "encoding/json" "io" "io/fs" "log/slog" @@ -45,6 +46,8 @@ const builtShell = ` + + verdande + + +urd