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{ + {"