Merge pull request #99 from kristianwind/urd-faar-sin-egen-doer
feat(urd): den anden dør — /urd kan installeres som sin egen app
This commit is contained in:
+59
-4
@@ -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
|
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
|
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
|
**not** how urd is normally reached. That is the second door, below.
|
||||||
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.
|
|
||||||
|
|
||||||
Two details that surprise people who look inside:
|
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
|
and never reads the manifest, so renaming paths would have left iOS with the
|
||||||
wrong mark.
|
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 `<head>` 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
|
## Moving between the two, measured
|
||||||
|
|
||||||
A `/data` directory moves between the editions in **both directions, losslessly**.
|
A `/data` directory moves between the editions in **both directions, losslessly**.
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ type Server struct {
|
|||||||
// Icon paths this edition serves from another file. Empty in the full edition.
|
// Icon paths this edition serves from another file. Empty in the full edition.
|
||||||
assets map[string]string
|
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
|
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 {
|
if s.shell != nil {
|
||||||
s.csp = contentSecurityPolicy(scriptHashesIn(s.shell))
|
s.csp = contentSecurityPolicy(scriptHashesIn(s.shell))
|
||||||
} else {
|
} else {
|
||||||
@@ -797,6 +815,15 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
|||||||
name = "index.html"
|
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,
|
// 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
|
// 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.
|
// in the way, and resolveNotesAssets has already checked each one exists.
|
||||||
|
|||||||
+165
-1
@@ -2,8 +2,10 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The product name is in the built frontend as a literal in four places, because
|
// 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
|
// is ignored by the browser without an error anywhere: the install prompt simply
|
||||||
// does not appear.
|
// does not appear.
|
||||||
func contentTypeFor(name string) string {
|
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 "application/manifest+json"
|
||||||
}
|
}
|
||||||
return "text/html; charset=utf-8"
|
return "text/html; charset=utf-8"
|
||||||
@@ -175,3 +182,160 @@ func resolveNotesAssets(web fs.FS, missing func(name, alias string)) map[string]
|
|||||||
}
|
}
|
||||||
return out
|
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 <head> 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 <head> 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{
|
||||||
|
{"<title>verdande</title>", "<title>urd</title>"},
|
||||||
|
{
|
||||||
|
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
|
||||||
|
`<meta name="description" content="` + notesShellDescription + `" />`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`<link rel="manifest" href="/manifest.webmanifest" />`,
|
||||||
|
`<link rel="manifest" href="/urd.webmanifest" />`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`<link rel="icon" href="/icon.svg" type="image/svg+xml" />`,
|
||||||
|
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`<link rel="apple-touch-icon" href="/apple-touch-icon.png" />`,
|
||||||
|
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -45,6 +46,8 @@ const builtShell = `<!doctype html>
|
|||||||
<head>
|
<head>
|
||||||
<meta name="description" content="verdande — opgaver og projekter, delt." />
|
<meta name="description" content="verdande — opgaver og projekter, delt." />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<title>verdande</title>
|
<title>verdande</title>
|
||||||
<script>
|
<script>
|
||||||
try {
|
try {
|
||||||
@@ -62,7 +65,17 @@ const builtManifest = `{
|
|||||||
"name": "verdande",
|
"name": "verdande",
|
||||||
"short_name": "verdande",
|
"short_name": "verdande",
|
||||||
"description": "Opgaver og projekter, delt.",
|
"description": "Opgaver og projekter, delt.",
|
||||||
"start_url": "/"
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#111312",
|
||||||
|
"theme_color": "#111312",
|
||||||
|
"lang": "da",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -194,10 +207,19 @@ func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
src := string(raw)
|
src := string(raw)
|
||||||
|
|
||||||
for _, pair := range shellRebrands("urd", "d") {
|
// Both sets: the edition's rename and the second door's. The door was added
|
||||||
if n := strings.Count(src, pair.old); n != 1 {
|
// after this test and its anchors were NOT covered, which is how its fixture
|
||||||
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — "+
|
// came to be missing two <link> lines the real file has — caught only because
|
||||||
"the notes edition renames the shell by replacing it", n, pair.old)
|
// applyRebrands refuses an anchor it cannot find.
|
||||||
|
for what, pairs := range map[string][]rebrand{
|
||||||
|
"the notes edition's rename": shellRebrands("urd", "d"),
|
||||||
|
"the urd door": urdShellRebrands(),
|
||||||
|
} {
|
||||||
|
for _, pair := range pairs {
|
||||||
|
if n := strings.Count(src, pair.old); n != 1 {
|
||||||
|
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — %s replaces it",
|
||||||
|
n, pair.old, what)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// And the keys the rename must not touch, asserted here too: app.html is where
|
// And the keys the rename must not touch, asserted here too: app.html is where
|
||||||
@@ -206,7 +228,8 @@ func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
|
|||||||
if !strings.Contains(src, `localStorage.getItem('verdande:theme')`) {
|
if !strings.Contains(src, `localStorage.getItem('verdande:theme')`) {
|
||||||
t.Error("web/src/app.html no longer reads verdande:theme; check that the app reads the same key")
|
t.Error("web/src/app.html no longer reads verdande:theme; check that the app reads the same key")
|
||||||
}
|
}
|
||||||
t.Logf("app.html: %d bytes, %d anchors found", len(raw), len(shellRebrands("urd", "d")))
|
t.Logf("app.html: %d bytes, %d edition anchors, %d door anchors",
|
||||||
|
len(raw), len(shellRebrands("urd", "d")), len(urdShellRebrands()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTheFullEditionServesTheBuiltFilesUnchanged is the control. Everything above
|
// TestTheFullEditionServesTheBuiltFilesUnchanged is the control. Everything above
|
||||||
@@ -314,3 +337,106 @@ func TestAMissingMarkIsReportedAndFallsBack(t *testing.T) {
|
|||||||
t.Error("one missing icon took the other three with it")
|
t.Error("one missing icon took the other three with it")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTheUrdDoorIsItsOwnApp.
|
||||||
|
//
|
||||||
|
// An operating system does not install a URL, it installs a manifest, and the
|
||||||
|
// identity of an installed app is that manifest's `id`. So this asserts the two
|
||||||
|
// things that decide whether /urd becomes a second icon on a home screen or just a
|
||||||
|
// bookmark into the first one — and it asserts them as named values, because an
|
||||||
|
// empty manifest, verdande's manifest and urd's all parse as JSON.
|
||||||
|
func TestTheUrdDoorIsItsOwnApp(t *testing.T) {
|
||||||
|
srv := serverWithWeb(t, "full", webWithIcons())
|
||||||
|
|
||||||
|
if srv.door("urd") == nil || srv.door("urd.webmanifest") == nil {
|
||||||
|
t.Fatal("the full edition does not serve the urd door; it is a second face, not an edition")
|
||||||
|
}
|
||||||
|
|
||||||
|
shell := string(srv.door("urd"))
|
||||||
|
for _, want := range []string{
|
||||||
|
"<title>urd</title>",
|
||||||
|
`<link rel="manifest" href="/urd.webmanifest" />`,
|
||||||
|
// iOS reads this one by name and has historically ignored the manifest's
|
||||||
|
// icons entirely, which is why the shell is served per door rather than
|
||||||
|
// patched by script after load.
|
||||||
|
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
|
||||||
|
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(shell, want) {
|
||||||
|
t.Errorf("the urd door's shell does not contain %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The same trap as the edition shell: the two doors share an origin and
|
||||||
|
// therefore share localStorage, so renaming a storage key here would give the
|
||||||
|
// two apps different themes by accident.
|
||||||
|
if !strings.Contains(shell, `localStorage.getItem('verdande:theme')`) {
|
||||||
|
t.Error("the urd door's shell lost a storage key; the two doors share one origin and one localStorage")
|
||||||
|
}
|
||||||
|
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(srv.door("urd.webmanifest"), &m); err != nil {
|
||||||
|
t.Fatalf("the urd manifest is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
for key, want := range map[string]any{
|
||||||
|
// id is the install identity. Without it, two manifests on one origin are
|
||||||
|
// one app with two start urls.
|
||||||
|
"id": "/urd",
|
||||||
|
"start_url": "/urd",
|
||||||
|
"scope": "/",
|
||||||
|
"name": "urd",
|
||||||
|
"short_name": "urd",
|
||||||
|
} {
|
||||||
|
if m[key] != want {
|
||||||
|
t.Errorf("the urd manifest has %s = %v, want %v", key, m[key], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And the shared half, to prove it was transformed rather than written out: a
|
||||||
|
// hand-written copy would drift on exactly these.
|
||||||
|
var built map[string]any
|
||||||
|
raw, _ := fs.ReadFile(webWithIcons(), "manifest.webmanifest")
|
||||||
|
_ = json.Unmarshal(raw, &built)
|
||||||
|
for _, key := range []string{"display", "background_color", "theme_color", "lang"} {
|
||||||
|
if _, ok := built[key]; ok && m[key] != built[key] {
|
||||||
|
t.Errorf("the urd manifest lost the built one's %s: %v vs %v", key, m[key], built[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
icons, _ := m["icons"].([]any)
|
||||||
|
if len(icons) == 0 {
|
||||||
|
t.Fatal("the urd manifest has no icons")
|
||||||
|
}
|
||||||
|
for i, entry := range icons {
|
||||||
|
src, _ := entry.(map[string]any)["src"].(string)
|
||||||
|
if !strings.HasPrefix(src, "/urd-") {
|
||||||
|
t.Errorf("icon %d is %q — the urd manifest points at verdande's mark", i, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("urd door: shell %d bytes, manifest %d bytes, %d icons", len(srv.door("urd")), len(srv.door("urd.webmanifest")), len(icons))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnUnknownIconStopsTheUrdManifest is the direction that matters: an icon
|
||||||
|
// added to the built manifest and not to urd's map would otherwise leave urd's
|
||||||
|
// install prompt showing Verdande's mark, which nobody reports.
|
||||||
|
func TestAnUnknownIconStopsTheUrdManifest(t *testing.T) {
|
||||||
|
raw := []byte(`{"name":"verdande","icons":[{"src":"/icon-1024.png","sizes":"1024x1024"}]}`)
|
||||||
|
if _, err := urdManifest(raw); err == nil {
|
||||||
|
t.Error("urdManifest accepted an icon it has no file for")
|
||||||
|
} else if !strings.Contains(err.Error(), "/icon-1024.png") {
|
||||||
|
t.Errorf("the error does not name the icon: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTheWebmanifestTypeCoversBothDoors. A manifest served as text/html is ignored
|
||||||
|
// by the browser with no error anywhere; the only symptom is that the install
|
||||||
|
// prompt does not appear. This read `name == "manifest.webmanifest"` when the
|
||||||
|
// second door was added.
|
||||||
|
func TestTheWebmanifestTypeCoversBothDoors(t *testing.T) {
|
||||||
|
for _, name := range []string{"manifest.webmanifest", "urd.webmanifest"} {
|
||||||
|
if got := contentTypeFor(name); got != "application/manifest+json" {
|
||||||
|
t.Errorf("%s is served as %q", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := contentTypeFor("index.html"); got != "text/html; charset=utf-8" {
|
||||||
|
t.Errorf("index.html is served as %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5515,3 +5515,108 @@ test('the full edition still calls itself verdande', async ({ page }) => {
|
|||||||
expect(icon).toContain('M26 15l17');
|
expect(icon).toContain('M26 15l17');
|
||||||
expect(icon).not.toContain('M44 36L27 10');
|
expect(icon).not.toContain('M44 36L27 10');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The second door.
|
||||||
|
*
|
||||||
|
* One instance, one database, one login — and two things that can be installed.
|
||||||
|
* This runs in the FULL edition on purpose: urd is a second face of an ordinary
|
||||||
|
* verdande, not a property of an instance, so if it only worked where
|
||||||
|
* VERDANDE_EDITION=notes it would not be the thing that was asked for.
|
||||||
|
*
|
||||||
|
* Measured on the bytes the server sends. An operating system installs a manifest,
|
||||||
|
* not a URL, so these assertions are about the manifest's `id` and the shell's
|
||||||
|
* <head> — the two things that decide whether /urd becomes a second icon on a home
|
||||||
|
* screen or a bookmark into the first app.
|
||||||
|
*/
|
||||||
|
test('urd is a second installable app on the same instance', async ({ page }) => {
|
||||||
|
const shell = await (await page.request.get('/urd')).text();
|
||||||
|
expect(shell, 'the tab, before any script runs').toContain('<title>urd</title>');
|
||||||
|
expect(shell, 'which manifest the browser fetches').toContain(
|
||||||
|
'<link rel="manifest" href="/urd.webmanifest" />'
|
||||||
|
);
|
||||||
|
// iOS looks for this one BY NAME and has historically ignored the manifest's
|
||||||
|
// icons, which is why the shell is served per door rather than patched by
|
||||||
|
// script after the page loads.
|
||||||
|
expect(shell, 'what iOS puts on a home screen').toContain(
|
||||||
|
'<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />'
|
||||||
|
);
|
||||||
|
// And the root is untouched: two doors, not a rename.
|
||||||
|
const root = await (await page.request.get('/')).text();
|
||||||
|
expect(root).toContain('<title>verdande</title>');
|
||||||
|
expect(root).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
|
||||||
|
|
||||||
|
const res = await page.request.get('/urd.webmanifest');
|
||||||
|
// A manifest served as text/html is ignored with no error anywhere; the only
|
||||||
|
// symptom is an install prompt that never appears.
|
||||||
|
expect(res.headers()['content-type']).toContain('application/manifest+json');
|
||||||
|
|
||||||
|
const urd = await res.json();
|
||||||
|
// `id` is the install identity. Without it, two manifests on one origin are one
|
||||||
|
// app with two start urls.
|
||||||
|
expect(urd.id).toBe('/urd');
|
||||||
|
expect(urd.start_url).toBe('/urd');
|
||||||
|
// Not "/urd": a scope that did not cover the origin would open the app's own
|
||||||
|
// links in a browser tab the first time somebody navigated out of /urd.
|
||||||
|
expect(urd.scope).toBe('/');
|
||||||
|
expect(urd.name).toBe('urd');
|
||||||
|
for (const icon of urd.icons) {
|
||||||
|
expect(icon.src, 'the install prompt must not show verdande’s mark').toMatch(/^\/urd-/);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdande = await (await page.request.get('/manifest.webmanifest')).json();
|
||||||
|
expect(verdande.name).toBe('verdande');
|
||||||
|
expect(verdande.id ?? '/').not.toBe('/urd');
|
||||||
|
// The shared fields come from one source, so a colour changed in the build
|
||||||
|
// reaches both doors.
|
||||||
|
expect(urd.theme_color).toBe(verdande.theme_color);
|
||||||
|
expect(urd.background_color).toBe(verdande.background_color);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entering through the urd door lands on the notes', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/urd');
|
||||||
|
|
||||||
|
// Asserted on what is drawn rather than on the URL: the Go binary serves the
|
||||||
|
// shell for anything it does not recognise, so a route that no longer exists
|
||||||
|
// still answers 200 here.
|
||||||
|
await expect(page.getByRole('heading', { name: /Noter/ }).first()).toBeVisible();
|
||||||
|
// replaceState, so the phone's back gesture does not return to a page whose
|
||||||
|
// only job is to redirect.
|
||||||
|
await expect(page).toHaveURL(/\/noter$/);
|
||||||
|
|
||||||
|
// And the window now wears urd's face: its mark, its name, its tab. This is
|
||||||
|
// chrome only — the task routes are all still there and still answer, which is
|
||||||
|
// the whole reason this is one instance.
|
||||||
|
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
|
||||||
|
await expect(brand.locator('.name')).toHaveText('urd');
|
||||||
|
await expect(brand.locator('.rune')).toHaveText('ð');
|
||||||
|
await expect(page).toHaveTitle(/· urd$/);
|
||||||
|
|
||||||
|
// Nothing is withheld: the same window can still read a task route. A face that
|
||||||
|
// hid data would be the separate instance wearing a costume.
|
||||||
|
const tasks = await page.request.get('/api/v1/tasks');
|
||||||
|
expect(tasks.status(), 'the task routes are still served to the urd window').toBe(200);
|
||||||
|
|
||||||
|
// And the face survives a reload — sessionStorage, because the two doors share
|
||||||
|
// an origin. Nobody passes through /urd again after the first navigation.
|
||||||
|
await page.reload();
|
||||||
|
await expect(brand.locator('.name')).toHaveText('urd');
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the verdande door is unaffected by the urd one', async ({ page }) => {
|
||||||
|
// The control, and it is the half that stops the face from being a rename: a
|
||||||
|
// window that never went through /urd must be verdande, in the same browser,
|
||||||
|
// on the same origin, with the other app's sessionStorage sitting beside it.
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
|
||||||
|
await expect(brand.locator('.name')).toHaveText('verdande');
|
||||||
|
await expect(brand.locator('.rune')).toHaveText('\u16B9');
|
||||||
|
await expect(page).toHaveTitle(/· verdande$/);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -64,6 +64,38 @@ class AppState {
|
|||||||
return this.edition === 'notes';
|
return this.edition === 'notes';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvilken dør dette vindue kom ind ad.
|
||||||
|
*
|
||||||
|
* HOLDT ADSKILT FRA `notesOnly` med vilje, og det er den vigtigste linje i
|
||||||
|
* filen her. De to ligner hinanden og betyder ikke det samme:
|
||||||
|
*
|
||||||
|
* notesOnly — serveren HAR ingen opgaveruter. En vagt: fladen må ikke hente
|
||||||
|
* dem, for de svarer 404.
|
||||||
|
* urdFace — personen kom ind ad urd-døren. En præference: ruterne er der,
|
||||||
|
* dataene er deres egne, og der er intet at holde tilbage.
|
||||||
|
*
|
||||||
|
* Slås de sammen, begynder urd-ansigtet at skjule data frem for pynt — en
|
||||||
|
* notesbog ville holde op med at vise sine opgaver, selvom de ligger i samme
|
||||||
|
* database og personen bare valgte en anden indgang.
|
||||||
|
*
|
||||||
|
* Læst fra sessionStorage frem for localStorage, fordi de to apps deler origin
|
||||||
|
* og dermed deler localStorage. sessionStorage er per vindue, og en installeret
|
||||||
|
* PWA er sit eget vindue.
|
||||||
|
*/
|
||||||
|
urdFace = $state(false);
|
||||||
|
|
||||||
|
enterUrd() {
|
||||||
|
this.urdFace = true;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('verdande:door', 'urd');
|
||||||
|
} catch (e) {
|
||||||
|
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
|
||||||
|
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
|
||||||
|
// mister det.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navnet instansen kalder sig selv — `Urd` i en sætning, `urd` i et mærke.
|
* Navnet instansen kalder sig selv — `Urd` i en sætning, `urd` i et mærke.
|
||||||
*
|
*
|
||||||
@@ -77,12 +109,24 @@ class AppState {
|
|||||||
* (`internal/httpapi/shell.go`). Gjorde vi det her, ville hver indlæsning
|
* (`internal/httpapi/shell.go`). Gjorde vi det her, ville hver indlæsning
|
||||||
* vise søsterens navn et øjeblik og så rette sig selv.
|
* vise søsterens navn et øjeblik og så rette sig selv.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Om DETTE vindue viser urd — enten fordi instansen ikke har opgaveruter, eller
|
||||||
|
* fordi personen kom ind ad urd-døren.
|
||||||
|
*
|
||||||
|
* De to grunde er forskellige og må ikke slås sammen andre steder end her: det
|
||||||
|
* her er navnet og mærket, altså pynt. `notesOnly` alene er vagten, der afgør,
|
||||||
|
* om der overhovedet er en rute at hente fra.
|
||||||
|
*/
|
||||||
|
get showsUrd() {
|
||||||
|
return this.notesOnly || this.urdFace;
|
||||||
|
}
|
||||||
|
|
||||||
get productName() {
|
get productName() {
|
||||||
return this.notesOnly ? 'Urd' : 'Verdande';
|
return this.showsUrd ? 'Urd' : 'Verdande';
|
||||||
}
|
}
|
||||||
|
|
||||||
get productSlug() {
|
get productSlug() {
|
||||||
return this.notesOnly ? 'urd' : 'verdande';
|
return this.showsUrd ? 'urd' : 'verdande';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +143,7 @@ class AppState {
|
|||||||
* Så bogstavet frem for runen. Hele historien står i web/static/urd-icon.svg.
|
* Så bogstavet frem for runen. Hele historien står i web/static/urd-icon.svg.
|
||||||
*/
|
*/
|
||||||
get productMark() {
|
get productMark() {
|
||||||
return this.notesOnly ? 'ð' : '\u16B9';
|
return this.showsUrd ? 'ð' : '\u16B9';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Transient messages: a failed save, a rolled-back change. */
|
/** Transient messages: a failed save, a rolled-back change. */
|
||||||
@@ -168,6 +212,15 @@ class AppState {
|
|||||||
async load() {
|
async load() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
|
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
|
||||||
|
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
|
||||||
|
// linje ville ansigtet falde af ved hvert F5.
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem('verdande:door') === 'urd') this.urdFace = true;
|
||||||
|
} catch (e) {
|
||||||
|
// Privat browsing nægter. Så holder ansigtet vinduet ud og ikke længere.
|
||||||
|
}
|
||||||
|
|
||||||
// Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke
|
// Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke
|
||||||
// tage udgaven med sig — login-skærmen er også en flade i en udgave — og
|
// tage udgaven med sig — login-skærmen er også en flade i en udgave — og
|
||||||
// en instans, der slet ikke svarer, melder `me` ordentligt nedenfor.
|
// en instans, der slet ikke svarer, melder `me` ordentligt nedenfor.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Urds dør.
|
||||||
|
*
|
||||||
|
* Serveren svarer på /urd med sin egen skal — egen titel, eget mærke og et link
|
||||||
|
* til sit eget manifest — og det er dét, styresystemet installerer. Siden her er
|
||||||
|
* hvad appen gør, når den så er startet: husker hvilken dør den kom ind ad, og
|
||||||
|
* går videre til noterne.
|
||||||
|
*
|
||||||
|
* `sessionStorage` og ikke `localStorage`, og det er hele grunden til at de to
|
||||||
|
* apps kan være to apps: de deler origin, så de deler localStorage, cookies og
|
||||||
|
* service worker. sessionStorage er per vindue — og en installeret PWA er sit
|
||||||
|
* eget vindue — så urd-appen har sit ansigt og verdande-appen sit, uden at
|
||||||
|
* træde på hinanden. Det overlever også en genindlæsning, hvilket det skal:
|
||||||
|
* efter et par klik står adressen på /noter, og en F5 dér går ikke gennem /urd
|
||||||
|
* igen.
|
||||||
|
*
|
||||||
|
* `replaceState`, så telefonens tilbage-gestus ikke fører tilbage til en side,
|
||||||
|
* der kun omdirigerer.
|
||||||
|
*/
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
app.enterUrd();
|
||||||
|
goto('/noter', { replaceState: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Bevidst tom. Omdirigeringen sker i samme hug som tegningen, så alt her ville
|
||||||
|
være et glimt af noget, ingen nåede at læse. -->
|
||||||
|
<svelte:head><title>urd</title></svelte:head>
|
||||||
Reference in New Issue
Block a user