| 1 | diff --git a/internal/webui/apple-touch-icon.png b/internal/webui/apple-touch-icon.png |
| 2 | new file mode 100644 |
| 3 | index 0000000..8065eee |
| 4 | Binary files /dev/null and b/internal/webui/apple-touch-icon.png differ |
| 5 | diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js |
| 6 | index 404f781..13a5ed7 100644 |
| 7 | --- a/internal/webui/bridge.js |
| 8 | +++ b/internal/webui/bridge.js |
| 9 | @@ -66,8 +66,11 @@ |
| 10 | // in step with the rail colour of whichever theme is active. |
| 11 | const paintChrome = () => { |
| 12 | const rail = getComputedStyle(root_).getPropertyValue("--rail").trim(); |
| 13 | - const themeMeta = document.querySelector('meta[name="theme-color"]'); |
| 14 | - if (themeMeta && rail) themeMeta.content = rail; |
| 15 | + if (rail) document.querySelectorAll('meta[name="theme-color"]').forEach(meta => { meta.content = rail; }); |
| 16 | + // The manifest is fetched at install and on later update checks, so |
| 17 | + // point it at the variant whose launch colours match the active theme. |
| 18 | + const manifest = document.querySelector('link[rel="manifest"]'); |
| 19 | + if (manifest) manifest.href = localPath("/manifest.webmanifest") + (currentTheme() === "dark" ? "?theme=dark" : ""); |
| 20 | }; |
| 21 | paintChrome(); |
| 22 | matchMedia("(prefers-color-scheme: dark)").addEventListener("change", paintChrome); |
| 23 | diff --git a/internal/webui/icon-192.png b/internal/webui/icon-192.png |
| 24 | new file mode 100644 |
| 25 | index 0000000..f4303f0 |
| 26 | Binary files /dev/null and b/internal/webui/icon-192.png differ |
| 27 | diff --git a/internal/webui/icon-512.png b/internal/webui/icon-512.png |
| 28 | new file mode 100644 |
| 29 | index 0000000..feaaed1 |
| 30 | Binary files /dev/null and b/internal/webui/icon-512.png differ |
| 31 | diff --git a/internal/webui/icon-maskable-512.png b/internal/webui/icon-maskable-512.png |
| 32 | new file mode 100644 |
| 33 | index 0000000..c685eb7 |
| 34 | Binary files /dev/null and b/internal/webui/icon-maskable-512.png differ |
| 35 | diff --git a/internal/webui/icon-maskable.svg b/internal/webui/icon-maskable.svg |
| 36 | new file mode 100644 |
| 37 | index 0000000..876ca08 |
| 38 | --- /dev/null |
| 39 | +++ b/internal/webui/icon-maskable.svg |
| 40 | @@ -0,0 +1 @@ |
| 41 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="#f4f2ec"/><path d="M19 43 L32 20 L45 43 Z" fill="#2f6b3a"/><rect x="22" y="45.5" width="20" height="3" fill="#8a5a1f"/></svg> |
| 42 | diff --git a/internal/webui/manifest.webmanifest b/internal/webui/manifest.webmanifest |
| 43 | index 264d641..378fb8e 100644 |
| 44 | --- a/internal/webui/manifest.webmanifest |
| 45 | +++ b/internal/webui/manifest.webmanifest |
| 46 | @@ -1,14 +1,27 @@ |
| 47 | { |
| 48 | + "id": "BASE/", |
| 49 | "name": "NAME", |
| 50 | "short_name": "NAME", |
| 51 | "description": "A tiny Nostr relay", |
| 52 | "start_url": "BASE/", |
| 53 | "scope": "BASE/", |
| 54 | "display": "standalone", |
| 55 | - "background_color": "#fbfaf7", |
| 56 | - "theme_color": "#f4f2ec", |
| 57 | + "display_override": ["standalone", "minimal-ui"], |
| 58 | + "launch_handler": {"client_mode": "navigate-existing"}, |
| 59 | + "background_color": "BACKGROUND", |
| 60 | + "theme_color": "THEME", |
| 61 | + "categories": ["social", "utilities"], |
| 62 | + "prefer_related_applications": false, |
| 63 | "icons": [ |
| 64 | + {"src": "BASE/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"}, |
| 65 | + {"src": "BASE/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"}, |
| 66 | + {"src": "BASE/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}, |
| 67 | {"src": "BASE/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"}, |
| 68 | {"src": "BASE/icon-mono.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "monochrome"} |
| 69 | + ], |
| 70 | + "shortcuts": [ |
| 71 | + {"name": "Files", "url": "BASE/files", "icons": [{"src": "BASE/icon-192.png", "sizes": "192x192"}]}, |
| 72 | + {"name": "Repositories", "url": "BASE/repos", "icons": [{"src": "BASE/icon-192.png", "sizes": "192x192"}]}, |
| 73 | + {"name": "Manage", "url": "BASE/manage", "icons": [{"src": "BASE/icon-192.png", "sizes": "192x192"}]} |
| 74 | ] |
| 75 | } |
| 76 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 77 | index e818619..32f6adb 100644 |
| 78 | --- a/internal/webui/page.html |
| 79 | +++ b/internal/webui/page.html |
| 80 | @@ -2,12 +2,18 @@ |
| 81 | <html lang="en" data-js="no"> |
| 82 | <head> |
| 83 | <meta charset="utf-8"> |
| 84 | -<meta name="viewport" content="width=device-width,initial-scale=1"> |
| 85 | +<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> |
| 86 | <meta name="color-scheme" content="light dark"> |
| 87 | -<meta name="theme-color" content="#f4f2ec"> |
| 88 | +<meta name="theme-color" content="#f4f2ec" media="(prefers-color-scheme: light)"> |
| 89 | +<meta name="theme-color" content="#1e1d1a" media="(prefers-color-scheme: dark)"> |
| 90 | +<meta name="mobile-web-app-capable" content="yes"> |
| 91 | +<meta name="apple-mobile-web-app-capable" content="yes"> |
| 92 | +<meta name="apple-mobile-web-app-status-bar-style" content="default"> |
| 93 | +<meta name="apple-mobile-web-app-title" content="{{.Slug}}"> |
| 94 | <title>{{.Title}}</title> |
| 95 | <link rel="manifest" href="/manifest.webmanifest"> |
| 96 | <link rel="icon" href="/icon.svg" type="image/svg+xml"> |
| 97 | +<link rel="apple-touch-icon" href="/apple-touch-icon.png"> |
| 98 | <style>{{stylesheet}}</style> |
| 99 | <script src="/fixi.js"></script> |
| 100 | <script src="/signer.js"></script> |
| 101 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 102 | index 2fd2589..a21d9b3 100644 |
| 103 | --- a/internal/webui/style.css |
| 104 | +++ b/internal/webui/style.css |
| 105 | @@ -24,13 +24,13 @@ img{max-width:100%} |
| 106 | |
| 107 | /* Shell: a table with three columns; each column scrolls on its own. */ |
| 108 | #topbar{display:none} |
| 109 | -#layout{border-collapse:collapse;table-layout:fixed;width:100%;height:calc(100vh - 28px)} |
| 110 | +#layout{border-collapse:collapse;table-layout:fixed;width:100%;height:calc(100vh - 28px - env(safe-area-inset-bottom,0px))} |
| 111 | #layout>tbody>tr>td{vertical-align:top;padding:0;position:relative;height:100%} |
| 112 | #railbox,#content,#panel{position:absolute;top:0;right:0;bottom:0;left:0;overflow:auto;overscroll-behavior:contain} |
| 113 | #rail{width:240px;background:var(--rail);border-right:1px solid var(--line)} |
| 114 | #aside{width:280px;border-left:1px solid var(--line)} |
| 115 | #content>section{padding:14px 22px 30px} |
| 116 | -#footer{position:fixed;left:0;right:0;bottom:0;height:28px;border-top:1px solid var(--line);background:var(--rail);font:13px var(--mono);padding:0 2ch 0 2.5ch;box-sizing:border-box} |
| 117 | +#footer{position:fixed;left:0;right:0;bottom:0;height:calc(28px + env(safe-area-inset-bottom,0px));border-top:1px solid var(--line);background:var(--rail);font:13px var(--mono);padding:0 calc(2ch + env(safe-area-inset-right,0px)) env(safe-area-inset-bottom,0px) calc(2.5ch + env(safe-area-inset-left,0px));box-sizing:border-box} |
| 118 | #footer table{width:100%;border-collapse:collapse;height:27px}#footer td{border:0;padding:0;vertical-align:middle} |
| 119 | #footer td:last-child{text-align:right} |
| 120 | #prompt{color:var(--muted)}#prompt b{color:var(--ink);font-weight:500} |
| 121 | diff --git a/internal/webui/sw.js b/internal/webui/sw.js |
| 122 | index 6baf324..52c91d7 100644 |
| 123 | --- a/internal/webui/sw.js |
| 124 | +++ b/internal/webui/sw.js |
| 125 | @@ -1,9 +1,25 @@ |
| 126 | // Service worker: makes the relay installable and keeps navigation working |
| 127 | -// when the network fails by serving the last shell seen. Relay data is never |
| 128 | -// cached; every request goes to the network first. |
| 129 | +// when the network fails. Every request goes to the network first. Successful |
| 130 | +// page loads and the shell scripts are kept as the offline fallback; relay |
| 131 | +// data requested by scripts is never cached. |
| 132 | +const CACHE = "tiny-shell-v1"; |
| 133 | +const shellAsset = url => /\/(fixi\.js|signer\.js|webmcp\.js|sw\.js|icon[^/]*\.(svg|png)|apple-touch-icon\.png|manifest\.webmanifest)$/.test(url.pathname); |
| 134 | self.addEventListener("install", () => self.skipWaiting()); |
| 135 | -self.addEventListener("activate", event => event.waitUntil(self.clients.claim())); |
| 136 | +self.addEventListener("activate", event => { |
| 137 | + event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())); |
| 138 | +}); |
| 139 | self.addEventListener("fetch", event => { |
| 140 | - if (event.request.method !== "GET") return; |
| 141 | - event.respondWith(fetch(event.request).catch(() => caches.match(event.request))); |
| 142 | + const request = event.request; |
| 143 | + if (request.method !== "GET") return; |
| 144 | + const url = new URL(request.url); |
| 145 | + if (url.origin !== location.origin) return; |
| 146 | + const keep = request.mode === "navigate" || shellAsset(url); |
| 147 | + if (!keep) return; |
| 148 | + event.respondWith(fetch(request).then(response => { |
| 149 | + if (response.ok && response.type === "basic") { |
| 150 | + const copy = response.clone(); |
| 151 | + caches.open(CACHE).then(cache => cache.put(request, copy)).catch(() => {}); |
| 152 | + } |
| 153 | + return response; |
| 154 | + }).catch(async () => (await caches.match(request)) || (request.mode === "navigate" ? caches.match(new Request(url.origin + url.pathname.replace(/\/[^/]*$/, "/"))) : undefined))); |
| 155 | }); |
| 156 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 157 | index 846a1f5..8e4f849 100644 |
| 158 | --- a/internal/webui/templates.go |
| 159 | +++ b/internal/webui/templates.go |
| 160 | @@ -59,6 +59,18 @@ var fileWorkspaceJS string |
| 161 | //go:embed manifest.webmanifest |
| 162 | var manifestTemplate string |
| 163 | |
| 164 | +//go:embed icon-192.png |
| 165 | +var icon192PNG []byte |
| 166 | + |
| 167 | +//go:embed icon-512.png |
| 168 | +var icon512PNG []byte |
| 169 | + |
| 170 | +//go:embed icon-maskable-512.png |
| 171 | +var iconMaskablePNG []byte |
| 172 | + |
| 173 | +//go:embed apple-touch-icon.png |
| 174 | +var appleTouchIconPNG []byte |
| 175 | + |
| 176 | //go:embed sw.js |
| 177 | var serviceWorkerJS []byte |
| 178 | |
| 179 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 180 | index dd502d7..ce250d2 100644 |
| 181 | --- a/internal/webui/webui.go |
| 182 | +++ b/internal/webui/webui.go |
| 183 | @@ -199,6 +199,12 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { |
| 184 | _, _ = writer.Write(serviceWorkerJS) |
| 185 | return |
| 186 | } |
| 187 | + if png, ok := map[string][]byte{"/icon-192.png": icon192PNG, "/icon-512.png": icon512PNG, "/icon-maskable-512.png": iconMaskablePNG, "/apple-touch-icon.png": appleTouchIconPNG}[request.URL.Path]; ok { |
| 188 | + writer.Header().Set("content-type", "image/png") |
| 189 | + writer.Header().Set("cache-control", "public, max-age=86400") |
| 190 | + _, _ = writer.Write(png) |
| 191 | + return |
| 192 | + } |
| 193 | if request.URL.Path == "/icon.svg" || request.URL.Path == "/icon-mono.svg" { |
| 194 | writer.Header().Set("content-type", "image/svg+xml; charset=utf-8") |
| 195 | if request.URL.Path == "/icon.svg" { |
| 196 | @@ -730,6 +736,15 @@ func (a *App) manifest(writer http.ResponseWriter, request *http.Request) { |
| 197 | encoded, _ := json.Marshal(name) |
| 198 | body := strings.ReplaceAll(manifestTemplate, `"NAME"`, string(encoded)) |
| 199 | body = strings.ReplaceAll(body, "BASE/", requestPrefix(request)+"/") |
| 200 | + // A manifest carries one set of launch colours. The page links the |
| 201 | + // variant that matches the active theme so the installed app's splash |
| 202 | + // screen and status bar do not flash light on a dark phone. |
| 203 | + theme, background := "#f4f2ec", "#fbfaf7" |
| 204 | + if request.URL.Query().Get("theme") == "dark" { |
| 205 | + theme, background = "#1e1d1a", "#171614" |
| 206 | + } |
| 207 | + body = strings.ReplaceAll(body, "THEME", theme) |
| 208 | + body = strings.ReplaceAll(body, "BACKGROUND", background) |
| 209 | writer.Header().Set("content-type", "application/manifest+json; charset=utf-8") |
| 210 | _, _ = writer.Write([]byte(body)) |
| 211 | } |