| 1 | diff --git a/internal/webui/badge-96.png b/internal/webui/badge-96.png |
| 2 | new file mode 100644 |
| 3 | index 0000000..a8ff413 |
| 4 | Binary files /dev/null and b/internal/webui/badge-96.png differ |
| 5 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 6 | index d0cfb0b..e3bc926 100644 |
| 7 | --- a/internal/webui/components.js |
| 8 | +++ b/internal/webui/components.js |
| 9 | @@ -26,6 +26,9 @@ |
| 10 | // <nostr-compose kind="…" coordinate="30617:…" [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]> |
| 11 | // Signs a NIP-34 issue, pull request, NIP-22 reply or status event and |
| 12 | // posts it to the relay. |
| 13 | +// <push-toggle> |
| 14 | +// Opts this browser into relay notifications; asks for permission only |
| 15 | +// when pressed and registers the device with a signed request. |
| 16 | // <file-upload> and <file-workspace-root> live in file-workspace.js; |
| 17 | // <private-services> lives in private-services.js. |
| 18 | (() => { |
| 19 | @@ -402,6 +405,57 @@ |
| 20 | // JsonView renders any JSON value. Arrays of objects become tables, objects |
| 21 | // become definition lists, scalar arrays become lists, and deep nesting |
| 22 | // falls back to compact JSON so a response never explodes the page. |
| 23 | + // PushToggle opts this browser into relay notifications. Nothing happens |
| 24 | + // until the button is pressed: permission is requested then, and the |
| 25 | + // device subscription is registered with a signed request. |
| 26 | + class PushToggle extends HTMLElement { |
| 27 | + connectedCallback() { |
| 28 | + if (this.bound) return; |
| 29 | + this.bound = true; |
| 30 | + this.innerHTML = '<p><button type="button">Enable on this device</button></p><output role="status"></output>'; |
| 31 | + this.button = this.querySelector("button"); |
| 32 | + this.output = this.querySelector("output"); |
| 33 | + if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) { |
| 34 | + this.button.disabled = true; |
| 35 | + this.say("This browser does not support notifications."); |
| 36 | + return; |
| 37 | + } |
| 38 | + this.button.addEventListener("click", () => this.toggle().catch(err => this.say("error: " + err.message, true))); |
| 39 | + this.refresh().catch(() => {}); |
| 40 | + } |
| 41 | + say(text, error) { this.output.textContent = text; if (error) this.output.dataset.error = ""; else delete this.output.dataset.error; } |
| 42 | + async subscription() { return (await navigator.serviceWorker.ready).pushManager.getSubscription(); } |
| 43 | + async refresh() { |
| 44 | + const current = await this.subscription(); |
| 45 | + this.enabled = Boolean(current) && localStorage.getItem("tiny.push") === current.endpoint; |
| 46 | + this.button.textContent = this.enabled ? "Disable on this device" : "Enable on this device"; |
| 47 | + if (!this.enabled && Notification.permission === "denied") this.say("Notifications are blocked in the browser settings."); |
| 48 | + } |
| 49 | + async toggle() { |
| 50 | + this.button.disabled = true; |
| 51 | + try { |
| 52 | + if (this.enabled) { |
| 53 | + const current = await this.subscription(); |
| 54 | + if (current) { |
| 55 | + await tiny.signedFetch("/push/unsubscribe", "POST", JSON.stringify(current.toJSON()), {contentType: "application/json"}); |
| 56 | + await current.unsubscribe(); |
| 57 | + } |
| 58 | + localStorage.removeItem("tiny.push"); |
| 59 | + this.say("Notifications are off on this device."); |
| 60 | + } else { |
| 61 | + if ((await Notification.requestPermission()) !== "granted") throw Error("Permission was not granted."); |
| 62 | + const {key} = await (await fetch(tiny.localPath("/push/key"), {credentials: "same-origin"})).json(); |
| 63 | + const registration = await navigator.serviceWorker.ready; |
| 64 | + const current = (await registration.pushManager.getSubscription()) || (await registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: tiny.util.fromB64url(key)})); |
| 65 | + await tiny.signedFetch("/push/subscribe", "POST", JSON.stringify(current.toJSON()), {contentType: "application/json"}); |
| 66 | + localStorage.setItem("tiny.push", current.endpoint); |
| 67 | + this.say("Notifications are on for this device."); |
| 68 | + } |
| 69 | + await this.refresh(); |
| 70 | + } finally { this.button.disabled = false; } |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | class JsonView extends HTMLElement { |
| 75 | connectedCallback() { |
| 76 | if (this.rendered) return; |
| 77 | @@ -755,6 +809,7 @@ |
| 78 | customElements.define("file-tools", FileTools); |
| 79 | customElements.define("publish-list", PublishList); |
| 80 | customElements.define("nostr-compose", NostrCompose); |
| 81 | + customElements.define("push-toggle", PushToggle); |
| 82 | customElements.define("nostr-key", NostrKey); |
| 83 | customElements.define("json-view", JsonView); |
| 84 | |
| 85 | diff --git a/internal/webui/file-workspace.js b/internal/webui/file-workspace.js |
| 86 | index d7729bd..53550e3 100644 |
| 87 | --- a/internal/webui/file-workspace.js |
| 88 | +++ b/internal/webui/file-workspace.js |
| 89 | @@ -175,6 +175,37 @@ |
| 90 | }); |
| 91 | this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort()); |
| 92 | this.querySelector("[data-retry]").addEventListener("click", () => this.run().catch(() => {})); |
| 93 | + this.intake().catch(error => this.say(error.message, true)); |
| 94 | + } |
| 95 | + // intake collects files parked by the service worker for a share from |
| 96 | + // another app. They wait here until the person presses Upload. |
| 97 | + async intake() { |
| 98 | + const id = new URLSearchParams(location.hash.slice(1)).get("share"); |
| 99 | + if (!id || !/^[0-9a-f-]{36}$/.test(id) || !globalThis.caches) return; |
| 100 | + const cache = await caches.open("tiny-share"); |
| 101 | + const meta = await (await cache.match("/share/" + id + "/meta"))?.json(); |
| 102 | + if (!meta) return; |
| 103 | + const files = []; |
| 104 | + for (let index = 0; index < (meta.count || 0); index++) { |
| 105 | + const response = await cache.match("/share/" + id + "/" + index); |
| 106 | + if (!response) continue; |
| 107 | + const blob = await response.blob(); |
| 108 | + files.push(new File([blob], decodeURIComponent(response.headers.get("x-name") || "shared"), {type: response.headers.get("content-type") || blob.type})); |
| 109 | + } |
| 110 | + for (const key of await cache.keys()) if (new URL(key.url).pathname.startsWith("/share/" + id + "/")) await cache.delete(key); |
| 111 | + history.replaceState?.(null, "", location.pathname + location.search); |
| 112 | + if (files.length) { |
| 113 | + this.choose(files); |
| 114 | + this.say(files.length + " shared file" + (files.length === 1 ? "" : "s") + " ready. Press Upload."); |
| 115 | + return; |
| 116 | + } |
| 117 | + const link = meta.url || (meta.text || "").match(/https?:\/\/\S+/)?.[0]; |
| 118 | + const mirror = link && document.querySelector("file-mirror input[name=url]"); |
| 119 | + if (mirror) { |
| 120 | + mirror.value = link; |
| 121 | + mirror.closest("details")?.setAttribute("open", ""); |
| 122 | + this.say("Shared link ready to import."); |
| 123 | + } |
| 124 | } |
| 125 | // choose records a selection. A folder is recognized from the relative |
| 126 | // paths that folder pickers and folder drops attach to their files. |
| 127 | diff --git a/internal/webui/manifest.webmanifest b/internal/webui/manifest.webmanifest |
| 128 | index 378fb8e..36d884c 100644 |
| 129 | --- a/internal/webui/manifest.webmanifest |
| 130 | +++ b/internal/webui/manifest.webmanifest |
| 131 | @@ -12,6 +12,13 @@ |
| 132 | "theme_color": "THEME", |
| 133 | "categories": ["social", "utilities"], |
| 134 | "prefer_related_applications": false, |
| 135 | + "share_target": { |
| 136 | + "action": "BASE/share", |
| 137 | + "method": "POST", |
| 138 | + "enctype": "multipart/form-data", |
| 139 | + "params": {"title": "title", "text": "text", "url": "url", "files": [{"name": "files", "accept": ["*/*"]}]} |
| 140 | + }, |
| 141 | + "protocol_handlers": [{"protocol": "web+nostr", "url": "BASE/open?target=%s"}], |
| 142 | "icons": [ |
| 143 | {"src": "BASE/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"}, |
| 144 | {"src": "BASE/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"}, |
| 145 | diff --git a/internal/webui/open.go b/internal/webui/open.go |
| 146 | new file mode 100644 |
| 147 | index 0000000..11259c8 |
| 148 | --- /dev/null |
| 149 | +++ b/internal/webui/open.go |
| 150 | @@ -0,0 +1,139 @@ |
| 151 | +package webui |
| 152 | + |
| 153 | +import ( |
| 154 | + "encoding/binary" |
| 155 | + "encoding/hex" |
| 156 | + "net/http" |
| 157 | + "net/url" |
| 158 | + "regexp" |
| 159 | + "strconv" |
| 160 | + "strings" |
| 161 | +) |
| 162 | + |
| 163 | +var hexID = regexp.MustCompile(`^[0-9a-f]{64}$`) |
| 164 | + |
| 165 | +// openLink handles web+nostr links registered through the installed app. |
| 166 | +// It decodes the NIP-19 target and redirects to the page that shows it. |
| 167 | +func (a *App) openLink(writer http.ResponseWriter, request *http.Request) { |
| 168 | + path, ok := nostrTargetPath(request.URL.Query().Get("target")) |
| 169 | + if !ok { |
| 170 | + http.Error(writer, "unsupported nostr link", http.StatusBadRequest) |
| 171 | + return |
| 172 | + } |
| 173 | + http.Redirect(writer, request, requestPrefix(request)+path, http.StatusSeeOther) |
| 174 | +} |
| 175 | + |
| 176 | +// nostrTargetPath maps a nostr: or web+nostr: target to a relay page. |
| 177 | +func nostrTargetPath(target string) (string, bool) { |
| 178 | + value := strings.TrimSpace(target) |
| 179 | + for _, prefix := range []string{"web+nostr:", "nostr:"} { |
| 180 | + if strings.HasPrefix(strings.ToLower(value), prefix) { |
| 181 | + value = value[len(prefix):] |
| 182 | + break |
| 183 | + } |
| 184 | + } |
| 185 | + value = strings.Trim(value, "/") |
| 186 | + if hexID.MatchString(value) { |
| 187 | + return "/e/" + value, true |
| 188 | + } |
| 189 | + hrp, data, ok := bech32Decode(strings.ToLower(value)) |
| 190 | + if !ok { |
| 191 | + return "", false |
| 192 | + } |
| 193 | + switch hrp { |
| 194 | + case "npub": |
| 195 | + if len(data) != 32 { |
| 196 | + return "", false |
| 197 | + } |
| 198 | + return "/search?author=" + hex.EncodeToString(data), true |
| 199 | + case "note": |
| 200 | + if len(data) != 32 { |
| 201 | + return "", false |
| 202 | + } |
| 203 | + return "/e/" + hex.EncodeToString(data), true |
| 204 | + case "nprofile", "nevent", "naddr": |
| 205 | + fields := tlvFields(data) |
| 206 | + switch hrp { |
| 207 | + case "nprofile": |
| 208 | + if len(fields[0]) != 32 { |
| 209 | + return "", false |
| 210 | + } |
| 211 | + return "/search?author=" + hex.EncodeToString(fields[0]), true |
| 212 | + case "nevent": |
| 213 | + if len(fields[0]) != 32 { |
| 214 | + return "", false |
| 215 | + } |
| 216 | + return "/e/" + hex.EncodeToString(fields[0]), true |
| 217 | + default: |
| 218 | + if len(fields[2]) != 32 || len(fields[3]) != 4 { |
| 219 | + return "", false |
| 220 | + } |
| 221 | + kind := binary.BigEndian.Uint32(fields[3]) |
| 222 | + return "/a/" + strconv.FormatUint(uint64(kind), 10) + ":" + hex.EncodeToString(fields[2]) + ":" + url.PathEscape(string(fields[0])), true |
| 223 | + } |
| 224 | + } |
| 225 | + return "", false |
| 226 | +} |
| 227 | + |
| 228 | +// tlvFields returns the first value of each NIP-19 TLV type. |
| 229 | +func tlvFields(data []byte) map[byte][]byte { |
| 230 | + fields := map[byte][]byte{} |
| 231 | + for len(data) >= 2 { |
| 232 | + kind, length := data[0], int(data[1]) |
| 233 | + if len(data) < 2+length { |
| 234 | + break |
| 235 | + } |
| 236 | + if _, seen := fields[kind]; !seen { |
| 237 | + fields[kind] = data[2 : 2+length] |
| 238 | + } |
| 239 | + data = data[2+length:] |
| 240 | + } |
| 241 | + return fields |
| 242 | +} |
| 243 | + |
| 244 | +// bech32Decode verifies a bech32 string and returns its 8-bit payload. |
| 245 | +func bech32Decode(value string) (string, []byte, bool) { |
| 246 | + separator := strings.LastIndexByte(value, '1') |
| 247 | + if separator < 1 || separator+7 > len(value) || len(value) > 5000 { |
| 248 | + return "", nil, false |
| 249 | + } |
| 250 | + hrp, encoded := value[:separator], value[separator+1:] |
| 251 | + values := make([]byte, len(encoded)) |
| 252 | + for i, char := range encoded { |
| 253 | + position := strings.IndexRune(bech32Charset, char) |
| 254 | + if position < 0 { |
| 255 | + return "", nil, false |
| 256 | + } |
| 257 | + values[i] = byte(position) |
| 258 | + } |
| 259 | + if bech32Polymod(append(bech32Expand(hrp), values...)) != 1 { |
| 260 | + return "", nil, false |
| 261 | + } |
| 262 | + data := values[:len(values)-6] |
| 263 | + accumulator, bits := 0, 0 |
| 264 | + var result []byte |
| 265 | + for _, v := range data { |
| 266 | + accumulator = accumulator<<5 | int(v) |
| 267 | + bits += 5 |
| 268 | + if bits >= 8 { |
| 269 | + bits -= 8 |
| 270 | + result = append(result, byte(accumulator>>bits&255)) |
| 271 | + } |
| 272 | + } |
| 273 | + if bits >= 5 || accumulator<<(8-bits)&255 != 0 { |
| 274 | + return "", nil, false |
| 275 | + } |
| 276 | + return hrp, result, true |
| 277 | +} |
| 278 | + |
| 279 | +func bech32Expand(hrp string) []byte { |
| 280 | + expanded := make([]byte, 0, len(hrp)*2+1) |
| 281 | + for _, c := range hrp { |
| 282 | + expanded = append(expanded, byte(c)>>5) |
| 283 | + } |
| 284 | + expanded = append(expanded, 0) |
| 285 | + for _, c := range hrp { |
| 286 | + expanded = append(expanded, byte(c)&31) |
| 287 | + } |
| 288 | + return expanded |
| 289 | +} |
| 290 | diff --git a/internal/webui/open_test.go b/internal/webui/open_test.go |
| 291 | new file mode 100644 |
| 292 | index 0000000..a8209ec |
| 293 | --- /dev/null |
| 294 | +++ b/internal/webui/open_test.go |
| 295 | @@ -0,0 +1,35 @@ |
| 296 | +package webui |
| 297 | + |
| 298 | +import ( |
| 299 | + "encoding/binary" |
| 300 | + "encoding/hex" |
| 301 | + "testing" |
| 302 | +) |
| 303 | + |
| 304 | +func TestNostrTargetPathDecodesNIP19Links(t *testing.T) { |
| 305 | + pubkey := "5ac640e5df8f7945381c31f435288ba3f587fbd68efb27abaf941792f9c36369" |
| 306 | + raw, _ := hex.DecodeString(pubkey) |
| 307 | + id := "0f8fc45a6e3cd2d022cf68f3ea0f413665a6d92c3d783e3ab6c2bc324432b5af" |
| 308 | + idRaw, _ := hex.DecodeString(id) |
| 309 | + kind := make([]byte, 4) |
| 310 | + binary.BigEndian.PutUint32(kind, 30023) |
| 311 | + naddr := append(append(append([]byte{0, 5}, []byte("hello")...), append([]byte{2, 32}, raw...)...), append([]byte{3, 4}, kind...)...) |
| 312 | + cases := map[string]string{ |
| 313 | + "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2": "/search?author=" + pubkey, |
| 314 | + "nostr:" + bech32("note", idRaw): "/e/" + id, |
| 315 | + bech32("nevent", append([]byte{0, 32}, idRaw...)): "/e/" + id, |
| 316 | + "web+nostr://" + bech32("nprofile", append([]byte{0, 32}, raw...)): "/search?author=" + pubkey, |
| 317 | + bech32("naddr", naddr): "/a/30023:" + pubkey + ":hello", |
| 318 | + id: "/e/" + id, |
| 319 | + } |
| 320 | + for target, want := range cases { |
| 321 | + if got, ok := nostrTargetPath(target); !ok || got != want { |
| 322 | + t.Fatalf("%s: got %q ok=%v, want %q", target, got, ok, want) |
| 323 | + } |
| 324 | + } |
| 325 | + for _, bad := range []string{"", "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc3", "nostr:javascript:alert(1)", "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs4g4gaj"} { |
| 326 | + if _, ok := nostrTargetPath(bad); ok { |
| 327 | + t.Fatalf("%q accepted", bad) |
| 328 | + } |
| 329 | + } |
| 330 | +} |
| 331 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 332 | index 32f6adb..6184b33 100644 |
| 333 | --- a/internal/webui/page.html |
| 334 | +++ b/internal/webui/page.html |
| 335 | @@ -114,7 +114,8 @@ |
| 336 | {{if .Actor}}<section><h4>Upload</h4><file-upload></file-upload><p><small>Encrypt keeps the contents and key in your browser. Keep the complete share link.</small></p><details><summary>Import from URL</summary><file-mirror action="/mirror"></file-mirror></details><noscript><p>Uploads need JavaScript and a connected signer.</p></noscript></section>{{end}}{{end}} |
| 337 | |
| 338 | {{define "panel-feed"}}<section><h4>Filters</h4><form method="get" action="/{{.Tab}}"><label for="filter-kinds">kinds</label><select id="filter-kinds" name="kinds"><option value="">notes and articles</option><option value="1"{{if eq (.Query.Get "kinds") "1"}} selected{{end}}>1 notes</option><option value="30023"{{if eq (.Query.Get "kinds") "30023"}} selected{{end}}>30023 articles</option><option value="7"{{if eq (.Query.Get "kinds") "7"}} selected{{end}}>7 reactions</option><option value="all"{{if eq (.Query.Get "kinds") "all"}} selected{{end}}>everything</option></select><label for="filter-author">author</label><input id="filter-author" name="author" placeholder="64 hex pubkey" value="{{.Query.Get "author"}}"><label for="filter-since">since</label><input id="filter-since" name="since" type="date" value="{{.Query.Get "since"}}">{{if eq .Tab "search"}}<label for="filter-q">search</label><input id="filter-q" name="q" value="{{.Query.Get "q"}}">{{end}}<button>apply</button></form></section> |
| 339 | -{{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}{{end}} |
| 340 | +{{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}} |
| 341 | +{{if and .Actor (eq .Tab "inbox")}}<section><h4>Notifications</h4><push-toggle></push-toggle><p><small>Relay notices for this key, on this device only.</small></p><noscript><p>Enabling notifications needs JavaScript.</p></noscript></section>{{end}}{{end}} |
| 342 | |
| 343 | {{define "panel-repo"}}{{$r := asMap .Event}}{{with index $r "repository"}}{{$r = asMap .}}{{end}}<section><h4>About</h4><table><tr><th>owner</th><td>{{template "nostrKey" (str (index $r "owner"))}}</td></tr><tr><th>visibility</th><td>{{if index $r "private"}}private{{else}}public{{end}}</td></tr>{{with index $r "commit"}}<tr><th>head</th><td><code title="{{.}}">{{shortID .}}</code></td></tr>{{end}}</table></section> |
| 344 | <section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section> |
| 345 | diff --git a/internal/webui/sw.js b/internal/webui/sw.js |
| 346 | index 52c91d7..d412cd6 100644 |
| 347 | --- a/internal/webui/sw.js |
| 348 | +++ b/internal/webui/sw.js |
| 349 | @@ -10,9 +10,13 @@ self.addEventListener("activate", event => { |
| 350 | }); |
| 351 | self.addEventListener("fetch", event => { |
| 352 | const request = event.request; |
| 353 | - if (request.method !== "GET") return; |
| 354 | const url = new URL(request.url); |
| 355 | if (url.origin !== location.origin) return; |
| 356 | + if (request.method === "POST" && /\/share$/.test(url.pathname)) { |
| 357 | + event.respondWith(receiveShare(request, url)); |
| 358 | + return; |
| 359 | + } |
| 360 | + if (request.method !== "GET") return; |
| 361 | const keep = request.mode === "navigate" || shellAsset(url); |
| 362 | if (!keep) return; |
| 363 | event.respondWith(fetch(request).then(response => { |
| 364 | @@ -23,3 +27,36 @@ self.addEventListener("fetch", event => { |
| 365 | return response; |
| 366 | }).catch(async () => (await caches.match(request)) || (request.mode === "navigate" ? caches.match(new Request(url.origin + url.pathname.replace(/\/[^/]*$/, "/"))) : undefined))); |
| 367 | }); |
| 368 | + |
| 369 | +// Shares from other apps arrive as a form post. The files are parked in a |
| 370 | +// cache under a fresh id and the Files page picks them up from its fragment, |
| 371 | +// so nothing is uploaded until the person presses Upload there. |
| 372 | +const receiveShare = async (request, url) => { |
| 373 | + const form = await request.formData(); |
| 374 | + const id = crypto.randomUUID(); |
| 375 | + const cache = await caches.open("tiny-share"); |
| 376 | + const files = form.getAll("files").filter(item => typeof item === "object" && "arrayBuffer" in item); |
| 377 | + await Promise.all(files.map((file, index) => cache.put(new Request(url.origin + "/share/" + id + "/" + index), new Response(file, {headers: {"content-type": file.type || "application/octet-stream", "x-name": encodeURIComponent(file.name || "shared")}})))); |
| 378 | + await cache.put(new Request(url.origin + "/share/" + id + "/meta"), Response.json({title: form.get("title") || "", text: form.get("text") || "", url: form.get("url") || "", count: files.length})); |
| 379 | + return Response.redirect(url.pathname.replace(/\/share$/, "/files") + "#share=" + id, 303); |
| 380 | +}; |
| 381 | + |
| 382 | +// Notifications are opt-in per device; a push only arrives after the person |
| 383 | +// enabled them on this browser. The payload is the relay's short summary. |
| 384 | +self.addEventListener("push", event => { |
| 385 | + let data = {}; |
| 386 | + try { data = event.data?.json() || {}; } catch { data = {body: event.data?.text() || ""}; } |
| 387 | + const base = new URL(self.registration.scope); |
| 388 | + event.waitUntil(self.registration.showNotification(data.title || "tiny", { |
| 389 | + body: data.body || "", tag: data.tag || "tiny", icon: new URL("icon-192.png", base).href, badge: new URL("badge-96.png", base).href, data: {url: data.url || base.href} |
| 390 | + })); |
| 391 | +}); |
| 392 | +self.addEventListener("notificationclick", event => { |
| 393 | + event.notification.close(); |
| 394 | + const target = event.notification.data?.url || self.registration.scope; |
| 395 | + event.waitUntil(self.clients.matchAll({type: "window", includeUncontrolled: true}).then(async list => { |
| 396 | + const open = list.find(client => client.url.startsWith(self.registration.scope)); |
| 397 | + if (open) { const focused = await open.navigate(target); return focused?.focus(); } |
| 398 | + return self.clients.openWindow(target); |
| 399 | + })); |
| 400 | +}); |
| 401 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 402 | index 8e4f849..e130967 100644 |
| 403 | --- a/internal/webui/templates.go |
| 404 | +++ b/internal/webui/templates.go |
| 405 | @@ -71,6 +71,9 @@ var iconMaskablePNG []byte |
| 406 | //go:embed apple-touch-icon.png |
| 407 | var appleTouchIconPNG []byte |
| 408 | |
| 409 | +//go:embed badge-96.png |
| 410 | +var badgePNG []byte |
| 411 | + |
| 412 | //go:embed sw.js |
| 413 | var serviceWorkerJS []byte |
| 414 | |
| 415 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 416 | index ce250d2..57bf9a8 100644 |
| 417 | --- a/internal/webui/webui.go |
| 418 | +++ b/internal/webui/webui.go |
| 419 | @@ -151,6 +151,12 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { |
| 420 | a.browse(writer, request) |
| 421 | return |
| 422 | } |
| 423 | + if request.URL.Path == "/share" { |
| 424 | + // The service worker receives shares before they reach the network. |
| 425 | + // Without it, send the person to the Files page to upload directly. |
| 426 | + http.Redirect(writer, request, requestPrefix(request)+"/files", http.StatusSeeOther) |
| 427 | + return |
| 428 | + } |
| 429 | if request.Method != http.MethodGet && request.Method != http.MethodHead { |
| 430 | http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) |
| 431 | return |
| 432 | @@ -199,7 +205,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { |
| 433 | _, _ = writer.Write(serviceWorkerJS) |
| 434 | return |
| 435 | } |
| 436 | - 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 { |
| 437 | + if png, ok := map[string][]byte{"/icon-192.png": icon192PNG, "/icon-512.png": icon512PNG, "/icon-maskable-512.png": iconMaskablePNG, "/apple-touch-icon.png": appleTouchIconPNG, "/badge-96.png": badgePNG}[request.URL.Path]; ok { |
| 438 | writer.Header().Set("content-type", "image/png") |
| 439 | writer.Header().Set("cache-control", "public, max-age=86400") |
| 440 | _, _ = writer.Write(png) |
| 441 | @@ -214,6 +220,10 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { |
| 442 | } |
| 443 | return |
| 444 | } |
| 445 | + if request.URL.Path == "/open" { |
| 446 | + a.openLink(writer, request) |
| 447 | + return |
| 448 | + } |
| 449 | if request.URL.Path == "/manifest.webmanifest" { |
| 450 | a.manifest(writer, request) |
| 451 | return |