- commit
- aad6cc915b4103587f8731820e51f4dbee8bb409
- parent
- 88d0641c934981c0a13bde054eb84989ab60007c
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 21 | ++++++++++++++++++-- |
| M | internal/ | 21 | +++++++++++++++++++- |
| M | internal/ | 22 | ++++++++++---------- |
| M | internal/ | 5 | ++++- |
| M | internal/ | 6 | ++++-- |
| M | internal/ | 2 | +- |
| M | internal/ | 32 | +++++++++++++++++++++++++----- |
| M | internal/ | 24 | +++++++++++++++++++++++ |
| M | internal/ | 15 | ++++++++++++++ |
| M | internal/ | 16 | +++++++++++++-- |
@@ -49,7 +49,7 @@ func TestBrowseRoutesUseObjectContractsAndRenderData(t *testing.T) {
4949 {"/repo?owner=alice&repo=notes&view=file", "browserepo", "one"},5050 {"/files", "browsefiles", "deadbeef"},5151 {"/file?hash=deadbeef", "browsefile", "blob"},52- {"/manage/status", "browsestatus", "storage"},52+ {"/manage/status", "browsestatus", "manage this relay"},5353 }5454 for _, test := range tests {5555 recorder := httptest.NewRecorder()@@ -79,11 +79,17 @@ func TestConnectPublishesGRASPAndDeliveryKinds(t *testing.T) {
7979 recorder := httptest.NewRecorder()8080 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/connect", nil))8181 body := recorder.Body.String()82- for _, marker := range []string{`<publish-list kind="10317" tag="g" scheme="ws">`, `<publish-list kind="10063" tag="server">`, `name="lines"`, `customElements.define("publish-list"`, `tiny.signedFetch("/events", "POST"`} {82+ for _, marker := range []string{`<publish-list kind="10317" tag="g" scheme="ws">`, `<publish-list kind="10063" tag="server">`, `name="lines"`, `src="/scripts/components.js?v=`} {8383 if !strings.Contains(body, marker) {8484 t.Fatalf("connect page missing %q", marker)8585 }8686 }87+ components := servedScript(t, app, "/scripts/components.js")88+ for _, marker := range []string{`customElements.define("publish-list"`, `tiny.signedFetch("/events", "POST"`} {89+ if !strings.Contains(components, marker) {90+ t.Fatalf("components script missing %q", marker)91+ }92+ }8793 }8894 8995 func TestBrowseRendersTypedGitRelayPage(t *testing.T) {@@ -141,3 +147,14 @@ func (b *typedBrowseBackend) Query(_ context.Context, method string, _ []json.Ra
141147 }142148 return b.browseBackend.Query(context.Background(), method, nil, "")143149 }150+151+// servedScript fetches one of the cached browser scripts through the app.152+func servedScript(t *testing.T, app *App, path string) string {153+ t.Helper()154+ recorder := httptest.NewRecorder()155+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))156+ if recorder.Code != http.StatusOK {157+ t.Fatalf("%s: status=%d", path, recorder.Code)158+ }159+ return recorder.Body.String()160+}@@ -220,6 +220,9 @@
220220 connectedCallback() {221221 if (this.bound) return;222222 this.bound = true;223+ // The encryption and messaging modules load on demand; the controls224+ // render right away and decryption waits for them.225+ this.ready = Promise.resolve(tiny.require?.("files")).catch(() => {});223226 this.innerHTML = '<h3>Share</h3><p><button type="button" data-copy>Copy Blossom URI</button> <button type="button" data-link>Copy share link</button> <share-link></share-link></p><form data-share><label>Send to <input name="recipient" inputmode="text" placeholder="npub or hex pubkey"></label><button>Send privately</button></form><output role="status"></output>';224227 this.output = this.querySelector("output");225228 this.querySelector("[data-copy]").addEventListener("click", () => this.copy(this.blossomURI()));@@ -229,7 +232,7 @@
229232 const params = new URLSearchParams(location.hash.slice(1));230233 this.querySelector("[data-share]").hidden = !(params.get("key") && params.get("iv"));231234 this.updateFileControls();232- this.decryptFromFragment();235+ this.ready.then(() => this.decryptFromFragment());233236 }234237 235238 hash() { return this.getAttribute("hash") || ""; }@@ -279,6 +282,7 @@
279282 280283 async share(form) {281284 if (this.sharing) return;285+ await this.ready;282286 const recipient = form.elements.namedItem("recipient").value.trim();283287 if (!recipient) { this.say("Enter a public key or npub.", true); return; }284288 const params = new URLSearchParams(location.hash.slice(1));@@ -820,6 +824,20 @@
820824 }821825 }822826 827+ // ensureModules loads the feature bundle for any custom element on the828+ // page whose definition has not arrived, such as after an in-place829+ // navigation to the Files or Connect pages.830+ const bundlesFor = {"file-upload": "files", "file-workspace-root": "files", "private-services": "private"};831+ const ensureModules = () => {832+ if (!tiny.require || typeof customElements.get !== "function") return;833+ const wanted = new Set();834+ for (const [tag, bundle] of Object.entries(bundlesFor)) {835+ if (!customElements.get(tag) && document.querySelector(tag)) wanted.add(bundle);836+ }837+ wanted.forEach(bundle => tiny.require(bundle).catch(() => {}));838+ };839+ ensureModules();840+823841 customElements.define("rpc-form", RpcForm);824842 customElements.define("connect-card", ConnectCard);825843 customElements.define("connect-list", ConnectList);@@ -902,6 +920,7 @@
902920 if (focus && typeof focus.focus === "function") focus.focus({preventScroll: true});903921 else document.querySelector("#content")?.focus({preventScroll: true});904922 document.dispatchEvent(new CustomEvent("tiny:navigation", {detail: {url: url.href}}));923+ ensureModules();905924 decorate();906925 decorateForms();907926 document.dispatchEvent(new CustomEvent("fx:process", {detail: {url: url.href}, bubbles: false}));@@ -1,5 +1,5 @@
11 {{define "page"}}2-<html lang="en" data-js="no">2+<html lang="en" data-js="no" data-scripts="{{scriptsVersion}}">33 <head>44 <meta charset="utf-8">55 <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">@@ -62,16 +62,16 @@
6262 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>6363 </tr></tbody></table>6464 <footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> » {{prompt .Path .Query}}</span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrKey" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="/signin">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span></td></tr></table></footer>65-<script>{{script "tiny.js"}}</script>66-<script>{{script "bridge.js"}}</script>67-<script>{{script "blossom-encryption.js"}}</script>68-<script>{{script "blossom-manifests.js"}}</script>69-<script>{{script "blossom-upload.js"}}</script>70-<script>{{script "file-messages.js"}}</script>71-<script>{{script "file-workspace.js"}}</script>72-<script>{{script "components.js"}}</script>73-<script>{{script "private-services.js"}}</script>74-</body>65+<script src="{{scriptURL "tiny.js"}}"></script>66+<script src="{{scriptURL "bridge.js"}}"></script>67+{{if or (eq .Tab "files") (eq .Tab "file")}}<script src="{{scriptURL "blossom-encryption.js"}}"></script>68+<script src="{{scriptURL "blossom-manifests.js"}}"></script>69+<script src="{{scriptURL "blossom-upload.js"}}"></script>70+<script src="{{scriptURL "file-messages.js"}}"></script>71+<script src="{{scriptURL "file-workspace.js"}}"></script>72+{{end}}<script src="{{scriptURL "components.js"}}"></script>73+{{if eq .Tab "connect"}}<script src="{{scriptURL "private-services.js"}}"></script>74+{{end}}</body>7575 </html>7676 {{end}}7777 @@ -327,11 +327,14 @@ func TestAccountPageOffersRelayListsAndHomeShowsConnectCards(t *testing.T) {
327327 recorder := httptest.NewRecorder()328328 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil))329329 body := recorder.Body.String()330- for _, want := range []string{`<relay-lists relay="ws://relay.example" server="http://relay.example" pubkey="` + backend.policy.Owner + `">`, `data-kind="10002" data-tag="r"`, `data-kind="10050"`, `data-kind="10007"`, `data-kind="10063" data-tag="server"`, `customElements.define("relay-lists"`} {330+ for _, want := range []string{`<relay-lists relay="ws://relay.example" server="http://relay.example" pubkey="` + backend.policy.Owner + `">`, `data-kind="10002" data-tag="r"`, `data-kind="10050"`, `data-kind="10007"`, `data-kind="10063" data-tag="server"`} {331331 if !strings.Contains(body, want) {332332 t.Errorf("account page missing %q", want)333333 }334334 }335+ if !strings.Contains(servedScript(t, app, "/scripts/components.js"), `customElements.define("relay-lists"`) {336+ t.Error("components script missing relay-lists")337+ }335338 recorder = httptest.NewRecorder()336339 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))337340 body = recorder.Body.String()@@ -36,7 +36,8 @@ img{max-width:100%}
3636 #prompt{color:var(--muted)}#prompt b{color:var(--ink);font-weight:500}3737 #who{color:var(--muted)}#who b,#who nostr-key{color:var(--ink)}3838 #who button{border:0;background:none;padding:0;color:var(--accent);font:inherit}#who button:hover{text-decoration:underline}39-#rail-tools{margin-top:12px}39+#railbox{box-sizing:border-box;padding-bottom:44px}40+#rail-tools{position:absolute;right:0;bottom:0;margin:0;padding:10px 2ch 10px 2.5ch}4041 share-link{display:inline-block}share-link:empty{display:none}4142 #theme{border:0;background:none;padding:0;color:var(--muted);font:12px var(--mono)}#theme:hover{color:var(--ink);text-decoration:underline}4243 @@ -201,7 +202,8 @@ html,body{overflow:auto}
201202 #layout,#layout>tbody,#layout>tbody>tr,#layout>tbody>tr>td{display:block;width:auto;height:auto}202203 #layout{padding:40px 0 28px}203204 #railbox,#content,#panel{position:static;overflow:visible}204-#layout>tbody>tr>td#rail{position:fixed;top:40px;bottom:28px;left:0;width:240px;height:auto;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:2;overflow:auto}205+#layout>tbody>tr>td#rail{position:fixed;top:40px;bottom:28px;left:0;width:240px;height:auto;box-sizing:border-box;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:2;overflow:hidden auto}206+#railbox{position:relative;min-height:100%}205207 #topbar:has(#nav-menu[open]) + #layout>tbody>tr>td#rail{transform:none;visibility:visible;box-shadow:8px 0 24px rgba(0,0,0,.15)}206208 #layout>tbody>tr>td#aside{width:auto;border-left:0;border-top:1px solid var(--line)}207209 #content>section{padding:14px 2.5ch 20px}@@ -3,7 +3,7 @@
33 // page loads and the shell scripts are kept as the offline fallback; relay44 // data requested by scripts is never cached.55 const CACHE = "tiny-shell-v1";6-const shellAsset = url => /\/(fixi\.js|signer\.js|webmcp\.js|sw\.js|icon[^/]*\.(svg|png)|apple-touch-icon\.png|manifest\.webmanifest)$/.test(url.pathname);6+const shellAsset = url => /\/(fixi\.js|signer\.js|webmcp\.js|sw\.js|scripts\/[\w.-]+\.js|icon[^/]*\.(svg|png)|badge-96\.png|apple-touch-icon\.png|manifest\.webmanifest)$/.test(url.pathname);77 self.addEventListener("install", () => self.skipWaiting());88 self.addEventListener("activate", event => {99 event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()));@@ -1,11 +1,14 @@
11 package webui22 33 import (4+ "crypto/sha256"45 "embed"6+ "encoding/hex"57 "encoding/json"68 "fmt"79 "html/template"810 "net/url"11+ "sort"912 "strings"1013 "time"1114 )@@ -145,17 +148,36 @@ func repoView(query url.Values) string {
145148 return "tree"146149 }147150 151+// scripts are served at /scripts/<name> and cached by the service worker.152+// One version stamp covers them all, so a change to any file refreshes every153+// cached copy together.154+var scripts = map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS}155+156+var scriptsVersion = func() string {157+ names := make([]string, 0, len(scripts))158+ for name := range scripts {159+ names = append(names, name)160+ }161+ sort.Strings(names)162+ digest := sha256.New()163+ for _, name := range names {164+ digest.Write([]byte(name))165+ digest.Write([]byte{0})166+ digest.Write([]byte(scripts[name]))167+ }168+ return hex.EncodeToString(digest.Sum(nil))[:12]169+}()170+148171 func parseTemplates() (*template.Template, error) {149- scripts := map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS}150172 funcs := template.FuncMap{151173 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },152- "script": func(name string) (template.JS, error) {153- source, ok := scripts[name]154- if !ok {174+ "scriptURL": func(name string) (string, error) {175+ if _, ok := scripts[name]; !ok {155176 return "", fmt.Errorf("unknown script %q", name)156177 }157- return template.JS(source), nil178+ return "/scripts/" + name + "?v=" + scriptsVersion, nil158179 },180+ "scriptsVersion": func() string { return scriptsVersion },159181 "json": func(value any) string {160182 encoded, err := json.Marshal(value)161183 if err != nil {@@ -68,6 +68,30 @@
6868 };6969 tiny.nip44Encrypt = (pubkey, text, signer) => nip44("Encrypt", pubkey, text, signer);7070 tiny.nip44Decrypt = (pubkey, text, signer) => nip44("Decrypt", pubkey, text, signer);71+ // Feature bundles load on the pages that use them and again after an72+ // in-place navigation lands on such a page. Each script loads once.73+ tiny.bundles = {74+ files: ["blossom-encryption.js", "blossom-manifests.js", "blossom-upload.js", "file-messages.js", "file-workspace.js"],75+ private: ["private-services.js"]76+ };77+ const loaded = new Map();78+ const loadScript = name => {79+ if (!loaded.has(name)) {80+ const version = document.documentElement.dataset.scripts || "";81+ const present = [...(document.scripts || [])].some(script => (script.src || "").includes("/scripts/" + name));82+ loaded.set(name, present ? Promise.resolve() : new Promise((resolve, reject) => {83+ const script = document.createElement("script");84+ script.src = (tiny.localPath ? tiny.localPath("/scripts/" + name) : "/scripts/" + name) + (version ? "?v=" + version : "");85+ script.onload = () => resolve();86+ script.onerror = () => { loaded.delete(name); reject(Error("Could not load " + name)); };87+ document.head.append(script);88+ }));89+ }90+ return loaded.get(name);91+ };92+ tiny.require = async bundle => {93+ for (const name of tiny.bundles[bundle] || [bundle]) await loadScript(name);94+ };7195 // Feature modules add to these groups; guards can read them before load.7296 tiny.blossom = tiny.blossom || {};7397 tiny.files = tiny.files || {};@@ -189,6 +189,21 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
189189 a.invitePage(writer, request)190190 return191191 }192+ if strings.HasPrefix(request.URL.Path, "/scripts/") {193+ source, ok := scripts[strings.TrimPrefix(request.URL.Path, "/scripts/")]194+ if !ok {195+ http.NotFound(writer, request)196+ return197+ }198+ writer.Header().Set("content-type", "application/javascript; charset=utf-8")199+ if request.URL.Query().Get("v") == scriptsVersion {200+ writer.Header().Set("cache-control", "public, max-age=31536000, immutable")201+ } else {202+ writer.Header().Set("cache-control", "no-cache")203+ }204+ _, _ = writer.Write([]byte(source))205+ return206+ }192207 if request.URL.Path == "/signer.js" {193208 writer.Header().Set("content-type", "application/javascript; charset=utf-8")194209 _, _ = writer.Write([]byte(signerJS))@@ -229,11 +229,14 @@ func TestSignerBundleAndDedicatedJourneysArePresent(t *testing.T) {
229229 recorder := httptest.NewRecorder()230230 app.ServeHTTP(recorder, request)231231 body := recorder.Body.String()232- for _, want := range []string{"bunker://", "nostrconnect", "signer-qr", "27235", "/signer.js"} {232+ for _, want := range []string{"bunker://", "nostrconnect", "signer-qr", "/signer.js", `src="/scripts/bridge.js?v=`} {233233 if !strings.Contains(body, want) {234234 t.Errorf("connect page missing %q", want)235235 }236236 }237+ if !strings.Contains(servedScript(t, app, "/scripts/bridge.js"), "27235") {238+ t.Error("bridge script missing the NIP-98 kind")239+ }237240 signerRequest := httptest.NewRequest(http.MethodGet, "/signer.js", nil)238241 signerResponse := httptest.NewRecorder()239242 app.ServeHTTP(signerResponse, signerRequest)@@ -343,11 +346,20 @@ func TestTenantPrefixRewritesUIEndpoints(t *testing.T) {
343346 recorder := httptest.NewRecorder()344347 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/sync", nil))345348 body := recorder.Body.String()346- for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/manage/jobs/status"`, `localPath(action || "/manage/rpc")`, `signedSession("/session")`, `replace(/^http/,"ws")+root`} {349+ for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/manage/jobs/status"`, `src="/r/alice/scripts/bridge.js?v=`, `src="/r/alice/scripts/components.js?v=`} {347350 if !strings.Contains(body, want) {348351 t.Errorf("tenant prefix missing %q", want)349352 }350353 }354+ bridge := servedScript(t, app, "/scripts/bridge.js")355+ for _, want := range []string{`signedSession("/session")`, `replace(/^http/,"ws")+root`} {356+ if !strings.Contains(bridge, want) {357+ t.Errorf("bridge script missing %q", want)358+ }359+ }360+ if !strings.Contains(servedScript(t, app, "/scripts/components.js"), `localPath(action || "/manage/rpc")`) {361+ t.Error("components script missing the prefixed management endpoint")362+ }351363 }352364 353365 func TestTenantPrefixHeaderSurvivesDaemonPathStripping(t *testing.T) {