tinyrelaycommit

Commit 2500b72813d5

browse files at this commit | history

1diff --git a/docs/fixi.md b/docs/fixi.md
2index 6e9bc8b..f2de929 100644
3--- a/docs/fixi.md
4+++ b/docs/fixi.md
5@@ -10,6 +10,10 @@ b70458c212d0409f8a6319c286351865d92587292ad4f9bc0dfd1c46ffcad855
6
7 `/fixi.js` is served locally with no CDN dependency. The page loads it before the existing signer bundle. The Connect page uses Fixi's `fx-action` fragment request for a read-only connection preview; `/connect/fragment` renders server-escaped table HTML, while `/connect.json` remains a JSON API. Mutation forms remain handled by the existing signer bridge because every request body must be serialized once, hashed exactly, signed as NIP-98, and submitted once. Fixi is not allowed to issue those signed mutations or retry them.
8
9-The existing signer bridge still owns NIP-07, NIP-46, Nostr Connect, Amber links, session login, and management RPC. Fixi's fragment swaps are optional progressive enhancement: links and read-only navigation continue to work if JavaScript is unavailable; signed management actions require the signer bridge JavaScript. Paxi's focus-preserving `morph` swap is used by the read-only connection fragment. Ssexi consumes the authenticated `/manage/jobs/status` stream; the handler resolves the actor before opening it, passes that actor to every `listjobs` query, emits only changed escaped HTML table fragments into the existing job table, and closes on request cancellation. The `rpc-form` element disables its buttons while a signed call is in flight and re-enables them after completion. The current backend contract remains `Query(ctx, method, params, actor)`.
10+The signer bridge handles NIP-07, NIP-46, Nostr Connect, Amber links, session login and management actions. Fixi updates page links and GET forms within the current tenant without a full reload. Filters, repository refs, browser history and page titles remain available. Downloads, external links and modified clicks retain normal browser behavior. A failed request displays an error in the footer and leaves the current page usable. Navigation and content also work without JavaScript, including on phones.
11
12-The layout remains a semantic table with fixed 960px desktop and 360px mobile variants. Fixi does not add branding or layout CSS.
13+Signed actions require JavaScript and submit once while their controls are disabled. Connect previews update in place. Job status uses an authenticated stream that sends updates when the results change.
14+
15+On phones, navigation starts closed and opens from the menu control, with or without JavaScript. Opening the menu leaves content in place. Desktop pages retain the sidebar.
16+
17+The layout remains a semantic table: a rail, a content column and a panel on desktop, stacked with a slide-in menu on phones. Fixi does not add branding or layout CSS.
18diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js
19index 15d58d3..c3fc81f 100644
20--- a/internal/webui/bridge.js
21+++ b/internal/webui/bridge.js
22@@ -13,12 +13,19 @@
23 pubkey: storagePrefix + ".identity",
24 remote: storagePrefix + ".remote"
25 };
26- const status = document.getElementById("signer-status") || document.getElementById("session-status");
27- const say = text => { if (status) status.textContent = text; };
28+ const say = text => {
29+ const status = document.getElementById("signer-status") || document.getElementById("session-status");
30+ if (status) status.textContent = text;
31+ };
32+ const errorText = err => err instanceof Error ? err.message : String(err);
33 const hex = bytes => Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
34 const sha256hex = async bytes => hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
35 const randomHex = () => { const b = new Uint8Array(32); crypto.getRandomValues(b); return hex(b); };
36 const toBytes = body => body instanceof ArrayBuffer ? new Uint8Array(body) : body instanceof Uint8Array ? body : new TextEncoder().encode(body || "");
37+ const validRelay = value => {
38+ try { const parsed = new URL(value); return (parsed.protocol === "ws:" || parsed.protocol === "wss:") && parsed.hostname !== ""; } catch { return false; }
39+ };
40+ const validBunker = pointer => pointer && /^[0-9a-f]{64}$/i.test(pointer.pubkey || "") && Array.isArray(pointer.relays) && pointer.relays.length > 0 && pointer.relays.every(validRelay);
41
42 // authorization builds a NIP-98 header for one request body. The body is
43 // hashed exactly once and the same bytes must be sent.
44@@ -52,31 +59,48 @@
45
46 // Shell: theme toggle, the phone menu, and the installable app worker.
47 const root_ = document.documentElement;
48- const themeButton = document.getElementById("theme");
49 const currentTheme = () => root_.dataset.theme || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
50 const savedTheme = localStorage.getItem("tiny.theme");
51 if (savedTheme) root_.dataset.theme = savedTheme;
52 // The installed app's title bar follows the theme-color meta, so keep it
53 // in step with the rail colour of whichever theme is active.
54- const themeMeta = document.querySelector('meta[name="theme-color"]');
55 const paintChrome = () => {
56 const rail = getComputedStyle(root_).getPropertyValue("--rail").trim();
57+ const themeMeta = document.querySelector('meta[name="theme-color"]');
58 if (themeMeta && rail) themeMeta.content = rail;
59 };
60 paintChrome();
61 matchMedia("(prefers-color-scheme: dark)").addEventListener("change", paintChrome);
62- if (themeButton) {
63- const label = () => { themeButton.textContent = currentTheme() === "dark" ? "light" : "dark"; };
64- label();
65- themeButton.addEventListener("click", () => {
66- root_.dataset.theme = currentTheme() === "dark" ? "light" : "dark";
67- localStorage.setItem("tiny.theme", root_.dataset.theme);
68+ const setMenu = open => {
69+ const menu = document.getElementById("nav-menu");
70+ if (menu) menu.open = open;
71+ };
72+ const shellBindings = new WeakSet();
73+ const bindShell = () => {
74+ const rail = document.getElementById("rail");
75+ const themeButton = document.getElementById("theme");
76+ if (rail && !shellBindings.has(rail)) {
77+ shellBindings.add(rail);
78+ rail.addEventListener("click", event => { if (event.target.closest("a")) setMenu(false); });
79+ }
80+ if (themeButton && !shellBindings.has(themeButton)) {
81+ shellBindings.add(themeButton);
82+ const label = () => { themeButton.textContent = currentTheme() === "dark" ? "light" : "dark"; };
83 label();
84- paintChrome();
85- });
86- }
87- document.getElementById("menu")?.addEventListener("click", () => { root_.dataset.menu = root_.dataset.menu === "open" ? "" : "open"; });
88- document.getElementById("rail")?.addEventListener("click", event => { if (event.target.closest("a")) root_.dataset.menu = ""; });
89+ themeButton.addEventListener("click", () => {
90+ root_.dataset.theme = currentTheme() === "dark" ? "light" : "dark";
91+ localStorage.setItem("tiny.theme", root_.dataset.theme);
92+ label();
93+ paintChrome();
94+ });
95+ }
96+ };
97+ bindShell();
98+ document.addEventListener("keydown", event => {
99+ if (event.key !== "Escape" || !document.getElementById("nav-menu")?.open) return;
100+ setMenu(false);
101+ document.getElementById("menu")?.focus();
102+ });
103 if ("serviceWorker" in navigator) navigator.serviceWorker.register(localPath("/sw.js")).catch(() => {});
104 window.tiny = {root, localPath, sha256hex, authorization, signedFetch};
105 window.tinySignedFetch = signedFetch;
106@@ -108,69 +132,96 @@
107 }
108 };
109
110- document.getElementById("session-login")?.addEventListener("click", async () => {
111- try { await signIn(); } catch (err) { say("Sign-in error: " + err.message); }
112- });
113+ // Fixi can morph the content column without reloading this script. Bind
114+ // controls by element identity so navigation can safely discover new ones.
115+ const boundControls = new WeakSet();
116+ const bindControl = (id, event, handler) => {
117+ const element = document.getElementById(id);
118+ if (!element || boundControls.has(element)) return;
119+ boundControls.add(element);
120+ element.addEventListener(event, handler);
121+ };
122+ const busy = new WeakSet();
123+ const once = (element, action) => {
124+ if (busy.has(element)) return;
125+ busy.add(element);
126+ const button = element.matches?.("button, input[type=submit]") ? element : element.querySelector?.("button, input[type=submit]");
127+ if (button) button.disabled = true;
128+ return Promise.resolve().then(action).finally(() => { busy.delete(element); if (button) button.disabled = false; });
129+ };
130
131- document.getElementById("session-logout")?.addEventListener("click", async () => {
132- try {
133- say("Signing out…");
134- const response = await fetch(localPath("/session/logout"), {method: "POST", credentials: "same-origin"});
135- if (!response.ok) throw Error(await response.text());
136- forgetRemote();
137- say("Signed out.");
138- location.reload();
139- } catch (err) {
140- say("Sign-out error: " + err.message);
141- }
142- });
143+ const bindControls = () => {
144+ bindControl("session-login", "click", event => once(event.currentTarget, async () => {
145+ try { await signIn(); } catch (err) { say("Sign-in error: " + errorText(err)); }
146+ }));
147+
148+ bindControl("session-logout", "click", event => once(event.currentTarget, async () => {
149+ try {
150+ say("Signing out…");
151+ const response = await fetch(localPath("/session/logout"), {method: "POST", credentials: "same-origin"});
152+ if (!response.ok) throw Error(await response.text());
153+ forgetRemote();
154+ say("Signed out.");
155+ location.reload();
156+ } catch (err) {
157+ say("Sign-out error: " + errorText(err));
158+ }
159+ }));
160
161- // Nostr Connect: show a QR code and wait for a phone signer such as Amber.
162- document.getElementById("nostrconnect")?.addEventListener("click", async ev => {
163- ev.preventDefault();
164- const key = window.NostrSigner.generateSecretKey();
165- const uri = window.NostrSigner.createNostrConnectURI({
166- clientPubkey: window.NostrSigner.getPublicKey(key),
167- relays: [location.origin.replace(/^http/,"ws")+root],
168- secret: randomHex(),
169- name: "tiny",
170- url: location.origin,
171- perms: ["sign_event:27235"]
172+ // Nostr Connect: show a QR code and wait for a phone signer such as Amber.
173+ bindControl("nostrconnect", "click", event => {
174+ event.preventDefault();
175+ return once(event.currentTarget, async () => {
176+ try {
177+ const key = window.NostrSigner.generateSecretKey();
178+ const uri = window.NostrSigner.createNostrConnectURI({
179+ clientPubkey: window.NostrSigner.getPublicKey(key),
180+ relays: [location.origin.replace(/^http/,"ws")+root],
181+ secret: randomHex(),
182+ name: "tiny",
183+ url: location.origin,
184+ perms: ["sign_event:27235"]
185+ });
186+ const open = document.getElementById("nostrconnect-open");
187+ if (open) { open.href = uri; open.hidden = false; }
188+ const qr = document.getElementById("signer-qr");
189+ if (qr) { qr.src = root + "/qr.svg?text=" + encodeURIComponent(uri); qr.hidden = false; }
190+ navigator.clipboard?.writeText(uri).catch(() => {});
191+ say("Scan the QR code or open the link in your signer.");
192+ window.tinyConnect = {key, uri};
193+ const signer = await window.NostrSigner.BunkerSigner.fromURI(key, uri);
194+ setSigner(signer);
195+ await rememberRemote(uri, key, signer);
196+ await signIn();
197+ } catch (err) {
198+ say("Nostr Connect error: " + errorText(err));
199+ }
200+ });
201 });
202- const open = document.getElementById("nostrconnect-open");
203- if (open) { open.href = uri; open.hidden = false; }
204- const qr = document.getElementById("signer-qr");
205- if (qr) { qr.src = root + "/qr.svg?text=" + encodeURIComponent(uri); qr.hidden = false; }
206- navigator.clipboard?.writeText(uri).catch(() => {});
207- say("Scan the QR code or open the link in your signer.");
208- window.tinyConnect = {key, uri};
209- try {
210- const signer = await window.NostrSigner.BunkerSigner.fromURI(key, uri);
211- setSigner(signer);
212- await rememberRemote(uri, key, signer);
213- await signIn();
214- } catch (err) {
215- say("Nostr Connect error: " + err.message);
216- }
217- });
218
219- // Bunker URL: connect to a remote signer the user pastes in.
220- document.getElementById("bunker")?.addEventListener("submit", async ev => {
221- ev.preventDefault();
222- const value = document.getElementById("bunker-url").value.trim();
223- if (!value) return;
224- try {
225- const secret = window.NostrSigner.generateSecretKey();
226- const pointer = await window.NostrSigner.parseBunkerInput(value);
227- const signer = window.NostrSigner.BunkerSigner.fromBunker(secret, pointer);
228- await signer.connect();
229- setSigner(signer);
230- await rememberRemote(value, secret, signer);
231- await signIn();
232- } catch (err) {
233- say("Remote signer error: " + err.message);
234- }
235- });
236+ // Bunker URL: connect to a remote signer the user pastes in.
237+ bindControl("bunker", "submit", event => {
238+ event.preventDefault();
239+ return once(event.currentTarget, async () => {
240+ const value = document.getElementById("bunker-url")?.value.trim();
241+ if (!value) return;
242+ try {
243+ const secret = window.NostrSigner.generateSecretKey();
244+ const pointer = await window.NostrSigner.parseBunkerInput(value);
245+ if (!validBunker(pointer)) throw Error("Enter a valid bunker URL with at least one relay.");
246+ const signer = window.NostrSigner.BunkerSigner.fromBunker(secret, pointer);
247+ await signer.connect();
248+ setSigner(signer);
249+ await rememberRemote(value, secret, signer);
250+ await signIn();
251+ } catch (err) {
252+ say("Remote signer error: " + errorText(err));
253+ }
254+ });
255+ });
256+ };
257+ bindControls();
258+ document.addEventListener("tiny:navigation", () => { bindShell(); bindControls(); });
259
260 // Resume a remote signer stored for this tab so page loads keep working.
261 (async () => {
262@@ -189,7 +240,7 @@
263 setSigner(signer);
264 } catch (err) {
265 forgetRemote();
266- say("Remote signer resume error: " + err.message);
267+ say("Remote signer resume error: " + errorText(err));
268 }
269 })();
270 })();
271diff --git a/internal/webui/components.js b/internal/webui/components.js
272index 7b000c6..ad1ccf3 100644
273--- a/internal/webui/components.js
274+++ b/internal/webui/components.js
275@@ -43,12 +43,15 @@
276 this.append(form, this.output);
277 form.addEventListener("submit", async event => {
278 event.preventDefault();
279+ if (this.submitting) return;
280+ this.submitting = true;
281 this.busy(true);
282 try {
283 await this.submit(form);
284 } catch (err) {
285 this.report("Error: " + err.message, true);
286 } finally {
287+ this.submitting = false;
288 this.busy(false);
289 }
290 });
291@@ -298,4 +301,158 @@
292 customElements.define("publish-list", PublishList);
293 customElements.define("nostr-key", NostrKey);
294 customElements.define("json-view", JsonView);
295+
296+ // Fixi normally swaps a small target. Internal page links return the full
297+ // shell, so use one shared swap that keeps the shell's live nodes and makes
298+ // navigation behave like a normal document request. The links retain their
299+ // hrefs, which is the no-JavaScript fallback and keeps them bookmarkable.
300+ (() => {
301+ document.documentElement.setAttribute("data-js", "yes");
302+ const navTargets = ["#topbar", "#railbox", "#content", "#panel", "#footer"];
303+ const status = () => document.querySelector("#navigation-status");
304+ const sameDocument = href => {
305+ let url;
306+ try { url = new URL(href, location.href); } catch { return false; }
307+ if (url.origin !== location.origin || url.hash || url.protocol !== location.protocol) return false;
308+ if (!allowedRoute(routePath(url))) return false;
309+ return !/\.(?:json|xml|rss|atom|txt|svg|ico|webmanifest|js|css|png|jpg|jpeg|gif|webp|zip|tar)$/i.test(url.pathname);
310+ };
311+ const setBusy = busy => {
312+ document.querySelector("#content")?.setAttribute("aria-busy", busy ? "true" : "false");
313+ const node = status();
314+ if (node && (busy || node.textContent === "loading")) node.textContent = busy ? "loading" : "";
315+ };
316+ const routePath = url => {
317+ const root = (location.pathname.match(/^\/r\/[^/]+/) || [""])[0];
318+ if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;
319+ return root ? url.pathname.slice(root.length) || "/" : url.pathname;
320+ };
321+ const allowedRoute = path => /^(?:\/(?:inbox|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files)?\/?|\/manage(?:\/(?:people|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a)\/.+)$/.test(path || "");
322+ let navigationSerial = 0, activeAbort;
323+ const streams = new Set();
324+ const closeStreams = () => {
325+ streams.forEach(cfg => cfg.sse.close());
326+ streams.clear();
327+ };
328+ document.addEventListener("fx:sse:open", event => {
329+ const cfg = event.detail.cfg;
330+ if (!cfg.target.isConnected) { cfg.sse.close(); event.preventDefault(); return; }
331+ streams.add(cfg);
332+ });
333+ document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));
334+ window.addEventListener("pagehide", closeStreams);
335+ const repairComponents = () => {
336+ document.querySelectorAll("rpc-form,signed-form,publish-list").forEach(node => {
337+ if (node.form?.isConnected) return;
338+ node.form = null;
339+ node.output = null;
340+ node.connectedCallback();
341+ if (node.result && !node.result.isConnected) node.result = null;
342+ });
343+ document.querySelectorAll("json-view").forEach(node => {
344+ if (!node.rendered || !node.querySelector(":scope > pre")) return;
345+ node.rendered = false;
346+ node.connectedCallback();
347+ });
348+ };
349+ const swapShell = (text, url, push) => {
350+ if (url.__tinyNavigationSerial && url.__tinyNavigationSerial !== navigationSerial) return false;
351+ const parsed = new DOMParser().parseFromString(text, "text/html");
352+ const incoming = new Map(navTargets.map(selector => [selector, parsed.querySelector(selector)]));
353+ if ([...incoming.values()].some(node => !node)) return false;
354+ const focusID = document.activeElement?.id;
355+ closeStreams();
356+ navTargets.forEach(selector => morph(document.querySelector(selector), incoming.get(selector).outerHTML));
357+ repairComponents();
358+ document.title = parsed.title || document.title;
359+ if (push) history.pushState({}, "", url.href);
360+ const focus = focusID && document.getElementById(focusID);
361+ if (focus && typeof focus.focus === "function") focus.focus({preventScroll: true});
362+ else document.querySelector("#content")?.focus({preventScroll: true});
363+ document.dispatchEvent(new CustomEvent("tiny:navigation", {detail: {url: url.href}}));
364+ decorate();
365+ decorateForms();
366+ document.dispatchEvent(new CustomEvent("fx:process", {detail: {url: url.href}, bubbles: false}));
367+ return true;
368+ };
369+ const load = async (url, push) => {
370+ const serial = ++navigationSerial, controller = new AbortController();
371+ activeAbort?.();
372+ activeAbort = () => controller.abort();
373+ url.__tinyNavigationSerial = serial;
374+ setBusy(true);
375+ try {
376+ const response = await fetch(url.href, {headers: {"FX-Request": "true", Accept: "text/html"}, credentials: "same-origin", signal: controller.signal});
377+ if (!response.ok) throw Error("Navigation failed (" + response.status + ")");
378+ if (!swapShell(await response.text(), url, push)) throw Error("Page response did not contain the UI shell");
379+ } catch (error) {
380+ const node = status();
381+ if (serial === navigationSerial && node) node.textContent = "Unable to load page: " + error.message;
382+ throw error;
383+ } finally { if (serial === navigationSerial) setBusy(false); }
384+ };
385+ document.addEventListener("fx:config", event => {
386+ const cfg = event.detail.cfg, elt = cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]");
387+ if (!elt?.matches?.("a[data-fixi-nav], form[data-fixi-nav]")) return;
388+ if ((cfg.trigger.type === "click" && cfg.trigger.button !== 0) || cfg.trigger.metaKey || cfg.trigger.ctrlKey || cfg.trigger.shiftKey || cfg.trigger.altKey) { cfg.drop = 1; cfg.preventTrigger = false; return; }
389+ const serial = ++navigationSerial;
390+ activeAbort?.();
391+ activeAbort = cfg.abort;
392+ cfg.__tinyNavigationSerial = serial;
393+ cfg.target = document.querySelector("#content");
394+ cfg.transition = false;
395+ cfg.swap = async current => {
396+ if (serial !== navigationSerial) return;
397+ const url = new URL(cfg.action, location.href);
398+ url.__tinyNavigationSerial = serial;
399+ if (!cfg.response.ok || !swapShell(cfg.text, url, true)) {
400+ const node = status();
401+ if (node) node.textContent = "Unable to load page";
402+ }
403+ };
404+ });
405+ document.addEventListener("fx:before", event => {
406+ if (event.detail.cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]")) setBusy(true);
407+ });
408+ document.addEventListener("fx:finally", event => {
409+ if (event.detail.cfg.__tinyNavigationSerial === navigationSerial) setBusy(false);
410+ });
411+ document.addEventListener("fx:after", event => {
412+ const cfg = event.detail.cfg;
413+ if (cfg.__tinyNavigationSerial || cfg.response.ok) return;
414+ event.preventDefault();
415+ const node = status();
416+ if (node) node.textContent = cfg.response.status === 401 ? "Sign in again to load this preview." : "Unable to load preview (" + cfg.response.status + ").";
417+ });
418+ document.addEventListener("fx:error", event => {
419+ const cfg = event.detail.cfg;
420+ if (cfg.__tinyNavigationSerial && cfg.__tinyNavigationSerial !== navigationSerial) return;
421+ const node = status();
422+ if (node) node.textContent = "Unable to load page: " + event.detail.error;
423+ });
424+ const decorate = () => document.querySelectorAll("a[href]").forEach(link => {
425+ if (link.hasAttribute("fx-action") || link.closest("[fx-ignore]") || link.target || link.hasAttribute("download") || !sameDocument(link.href)) return;
426+ link.setAttribute("data-fixi-nav", "");
427+ link.setAttribute("fx-action", link.href);
428+ link.setAttribute("fx-method", "get");
429+ link.setAttribute("fx-trigger", "click");
430+ });
431+ const decorateForms = () => document.querySelectorAll("form[method=get][action]").forEach(form => {
432+ if (form.hasAttribute("fx-action") || !sameDocument(form.action)) return;
433+ form.setAttribute("data-fixi-nav", "");
434+ form.setAttribute("fx-action", form.action);
435+ form.setAttribute("fx-method", "get");
436+ form.setAttribute("fx-trigger", "submit");
437+ });
438+ const prepare = () => {
439+ decorate();
440+ decorateForms();
441+ // Fixi's DOMContentLoaded handler runs before this footer script. Ask
442+ // it to initialize the attributes added after the initial scan.
443+ document.dispatchEvent(new CustomEvent("fx:process"));
444+ };
445+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", prepare, {once: true});
446+ else prepare();
447+ window.addEventListener("popstate", () => load(new URL(location.href), false).catch(() => {}));
448+ })();
449 })();
450diff --git a/internal/webui/page.html b/internal/webui/page.html
451index 60f1a2d..903431d 100644
452--- a/internal/webui/page.html
453+++ b/internal/webui/page.html
454@@ -1,5 +1,5 @@
455 {{define "page"}}<!doctype html>
456-<html lang="en">
457+<html lang="en" data-js="no">
458 <head>
459 <meta charset="utf-8">
460 <meta name="viewport" content="width=device-width,initial-scale=1">
461@@ -14,10 +14,10 @@
462 <script src="/webmcp.js"></script>
463 </head>
464 <body>
465-<header id="topbar"><button id="menu" type="button">menu</button> <span><b>{{.Slug}}</b> &raquo; {{prompt .Path .Query}}</span></header>
466+<header id="topbar"><details id="nav-menu"><summary id="menu" aria-controls="rail">menu</summary></details> <span><b>{{.Slug}}</b> &raquo; {{prompt .Path .Query}}</span></header>
467 <table id="layout" aria-label="{{.Slug}}"><tbody><tr>
468 <td id="rail"><div id="railbox">{{template "rail" .}}</div></td>
469-<td id="main"><div id="content">
470+<td id="main"><div id="content" tabindex="-1">
471 {{if .Private}}{{template "signin" .}}
472 {{else if eq .Tab "home"}}{{template "home" .}}
473 {{else if eq .Tab "inbox"}}{{template "inbox" .}}
474@@ -55,7 +55,7 @@
475 {{end}}</div></td>
476 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>
477 </tr></tbody></table>
478-<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; {{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><button id="theme" type="button" aria-label="Switch theme">theme</button></td></tr></table></footer>
479+<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; {{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><button id="theme" type="button" aria-label="Switch theme">theme</button></td></tr></table></footer>
480 <script>{{script "bridge.js"}}</script>
481 <script>{{script "components.js"}}</script>
482 </body>
483diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go
484index 1f66d71..1d21ffc 100644
485--- a/internal/webui/shell_test.go
486+++ b/internal/webui/shell_test.go
487@@ -9,6 +9,8 @@ import (
488 "strings"
489 "testing"
490
491+ "golang.org/x/net/html"
492+
493 "github.com/FelineStateMachine/tinyrelay/internal/policy"
494 )
495
496@@ -50,6 +52,123 @@ func TestShellRendersRailPanelAndPrompt(t *testing.T) {
497 }
498 }
499
500+func TestShellNavigationIsProgressiveAndAccessible(t *testing.T) {
501+ backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
502+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
503+ if err != nil {
504+ t.Fatal(err)
505+ }
506+ recorder := httptest.NewRecorder()
507+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
508+ body := recorder.Body.String()
509+ for _, want := range []string{
510+ `data-js="no"`, `id="content" tabindex="-1"`,
511+ `id="navigation-status" role="status" aria-live="polite"`,
512+ } {
513+ if !strings.Contains(body, want) {
514+ t.Errorf("shell missing progressive navigation marker %q", want)
515+ }
516+ }
517+ assertSharedNativeMenu(t, body, "/")
518+
519+ // The menu is part of the shell, so each viewer and rail context retains
520+ // one native control and one shared navigation container.
521+ for _, path := range []string{"/search", "/articles", "/files", "/repo?owner=alice&repo=notes&view=history", "/e/" + strings.Repeat("1", 64), "/manage/rules"} {
522+ recorder := httptest.NewRecorder()
523+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
524+ if recorder.Code != http.StatusOK {
525+ t.Fatalf("%s: status=%d", path, recorder.Code)
526+ }
527+ assertSharedNativeMenu(t, recorder.Body.String(), path)
528+ }
529+
530+ privatePolicy := policy.Defaults(strings.Repeat("b", 64))
531+ privatePolicy.Reads = "members"
532+ private := &privateFakeBackend{fakeBackend: &fakeBackend{policy: privatePolicy}, allowed: map[string]bool{}}
533+ privateApp, err := New(private, Options{Actor: func(*http.Request) (string, error) { return "", nil }})
534+ if err != nil {
535+ t.Fatal(err)
536+ }
537+ recorder = httptest.NewRecorder()
538+ privateApp.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/identity", nil))
539+ if recorder.Code != http.StatusOK {
540+ t.Fatalf("private shell: status=%d", recorder.Code)
541+ }
542+ assertSharedNativeMenu(t, recorder.Body.String(), "private")
543+}
544+
545+func assertSharedNativeMenu(t *testing.T, body, path string) {
546+ t.Helper()
547+ doc, err := html.Parse(strings.NewReader(body))
548+ if err != nil {
549+ t.Fatalf("%s: parse shell: %v", path, err)
550+ }
551+ var details, summary, rail *html.Node
552+ detailsCount, summaryCount, railCount := 0, 0, 0
553+ var walk func(*html.Node)
554+ walk = func(node *html.Node) {
555+ if node.Type == html.ElementNode {
556+ switch node.Data {
557+ case "details":
558+ if attr(node, "id") != "nav-menu" {
559+ break
560+ }
561+ detailsCount++
562+ if details == nil {
563+ details = node
564+ }
565+ case "summary":
566+ if attr(node, "id") != "menu" {
567+ break
568+ }
569+ summaryCount++
570+ if summary == nil {
571+ summary = node
572+ }
573+ case "td":
574+ if attr(node, "id") == "rail" {
575+ railCount++
576+ rail = node
577+ }
578+ }
579+ }
580+ for child := node.FirstChild; child != nil; child = child.NextSibling {
581+ walk(child)
582+ }
583+ }
584+ walk(doc)
585+ if detailsCount != 1 || summaryCount != 1 || railCount != 1 {
586+ t.Fatalf("%s: expected one details, summary and rail; got %d, %d, %d", path, detailsCount, summaryCount, railCount)
587+ }
588+ if hasAttr(details, "open") {
589+ t.Errorf("%s: menu details must be nav-menu without open", path)
590+ }
591+ if summary.Parent != details || attr(summary, "id") != "menu" || attr(summary, "aria-controls") != "rail" {
592+ t.Errorf("%s: summary must be the menu details child controlling rail", path)
593+ }
594+ if attr(rail, "id") != "rail" {
595+ t.Errorf("%s: menu target must be the rail", path)
596+ }
597+}
598+
599+func attr(node *html.Node, name string) string {
600+ for _, attribute := range node.Attr {
601+ if attribute.Key == name {
602+ return attribute.Val
603+ }
604+ }
605+ return ""
606+}
607+
608+func hasAttr(node *html.Node, name string) bool {
609+ for _, attribute := range node.Attr {
610+ if attribute.Key == name {
611+ return true
612+ }
613+ }
614+ return false
615+}
616+
617 func TestRepositoryHomeRendersReadmeAsMarkdown(t *testing.T) {
618 b := &browseBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
619 app, err := New(b, Options{})
620diff --git a/internal/webui/style.css b/internal/webui/style.css
621index c913a8d..de95946 100644
622--- a/internal/webui/style.css
623+++ b/internal/webui/style.css
624@@ -52,6 +52,8 @@ img{max-width:100%}
625
626 /* Panel */
627 #panel section{padding:10px 14px;border-bottom:1px solid var(--line);font:12px var(--mono)}
628+#content:focus{outline:2px solid var(--accent);outline-offset:-2px}
629+#navigation-status{color:var(--muted);font-size:11px}
630 #panel h4{margin:0 0 6px;font:12px var(--mono);color:var(--muted)}
631 #panel table{width:100%;border-collapse:collapse;margin:0}
632 #panel th{text-align:left;font-weight:normal;color:var(--muted);padding:2px 1ch 2px 0;border:0;white-space:nowrap;vertical-align:top}
633@@ -171,15 +173,20 @@ diff-view caption{text-align:left;font:13px var(--mono);padding:8px 12px;border-
634 @media(max-width:720px){
635 html,body{overflow:auto}
636 #topbar{display:block;position:fixed;top:0;left:0;right:0;height:40px;padding:0 2.5ch;background:var(--rail);border-bottom:1px solid var(--line);font:13px/40px var(--mono);color:var(--muted);z-index:3;box-sizing:border-box;white-space:nowrap;overflow:hidden}
637-#topbar b{color:var(--ink);font-weight:500}#menu{border:1px solid var(--line);padding:2px 10px;margin-right:10px;line-height:1.4}
638+#topbar b{color:var(--ink);font-weight:500}
639+#nav-menu{display:inline-block;margin-right:10px}
640+#menu{display:block;list-style:none;border:1px solid var(--line);padding:2px 10px;line-height:1.4;cursor:pointer}
641+#menu::-webkit-details-marker{display:none}
642 #layout,#layout>tbody,#layout>tbody>tr,#layout>tbody>tr>td{display:block;width:auto;height:auto}
643 #layout{padding:40px 0 28px}
644 #railbox,#content,#panel{position:static;overflow:visible}
645-#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%);transition:transform .15s;z-index:2;overflow:auto}
646-html[data-menu=open] #layout>tbody>tr>td#rail{transform:none;box-shadow:8px 0 24px rgba(0,0,0,.15)}
647+#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}
648+#topbar:has(#nav-menu[open]) + #layout>tbody>tr>td#rail{transform:none;visibility:visible;box-shadow:8px 0 24px rgba(0,0,0,.15)}
649 #layout>tbody>tr>td#aside{width:auto;border-left:0;border-top:1px solid var(--line)}
650 #content>section{padding:14px 2.5ch 20px}
651-#prompt{display:none}#footer td:last-child{text-align:left}
652+#prompt{display:none}#footer td:last-child{text-align:left;white-space:nowrap}
653+#footer{padding:0 12px}#who{font-size:11px}
654+#navigation-status:not(:empty),#session-status:not(:empty){position:absolute;bottom:100%;left:0;right:0;padding:6px 12px;background:var(--rail);border-top:1px solid var(--line);white-space:normal}
655 #split,#split>tbody,#split>tbody>tr,#split>tbody>tr>td{display:block;width:auto}
656 #tree{border-right:0;border-bottom:1px solid var(--line)}
657 #rows{table-layout:fixed}#rows td{white-space:normal;overflow-wrap:anywhere;height:auto;padding:8px 6px;line-height:1.35}#rows td:first-child{width:90px}#rows td:last-child{width:80px}
658diff --git a/scripts/signer.test.mjs b/scripts/signer.test.mjs
659index eeac575..0c9bfc0 100644
660--- a/scripts/signer.test.mjs
661+++ b/scripts/signer.test.mjs
662@@ -4,44 +4,71 @@ import {readFile} from "node:fs/promises";
663 import test from "node:test";
664 import vm from "node:vm";
665
666-const source=await readFile(new URL("../internal/webui/webui.go",import.meta.url),"utf8");
667-const bridge=source.split('const signerBridgeJS = `')[1].split('`')[0];
668+const bridge = await readFile(new URL("../internal/webui/bridge.js", import.meta.url), "utf8");
669
670-async function page({signedIn=false}={}) {
671- const elements=new Map();
672- for(const id of ["session-login","nostrconnect","nostrconnect-open","signer-qr","signer-status",...(signedIn?["session-logout"]:[])]) {
673- elements.set(id,{hidden:true,addEventListener(event,handler){this[event]=handler;}});
674+async function page({signedIn = false, bunkerResult = null} = {}) {
675+ const elements = new Map();
676+ for (const id of ["session-login", "nostrconnect", "nostrconnect-open", "signer-qr", "signer-status", "bunker", "bunker-url", ...(signedIn ? ["session-logout"] : [])]) {
677+ elements.set(id, {hidden: true, value: "", addEventListener(event, handler) { this[event] = handler; }, setAttribute() {}, removeAttribute() {}});
678 }
679- const saved=new Map();
680- const requests=[];
681- const signer={bp:{pubkey:"a".repeat(64),relays:["wss://tiny.example/r/work"]},getPublicKey:async()=>"a".repeat(64),signEvent:async event=>({...event,pubkey:"a".repeat(64),sig:"test"})};
682- const sandbox={document:{getElementById:id=>elements.get(id)},crypto:webcrypto,TextEncoder,URL,Uint8Array,ArrayBuffer,
683- btoa:value=>Buffer.from(value).toString("base64"),
684- location:{pathname:"/r/work/signin",href:"https://tiny.example/r/work/signin",origin:"https://tiny.example",assign(url){sandbox.opened=url},reload(){sandbox.reloaded=true}},
685- navigator:{clipboard:{writeText:async()=>{}}},
686- sessionStorage:{getItem:key=>saved.get(key),setItem:(key,value)=>saved.set(key,value),removeItem:key=>saved.delete(key)},
687- NostrSigner:{generateSecretKey:()=>new Uint8Array(32),getPublicKey:()=>"b".repeat(64),createNostrConnectURI:()=>"nostrconnect://test",bytesToHex:()=>"test-key",BunkerSigner:{fromURI:async()=>signer}},
688- fetch:async(url,options)=>{requests.push({url,options});return new Response("{}")}
689+ const saved = new Map();
690+ const requests = [];
691+ const signer = {bp: {pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]}, getPublicKey: async () => "a".repeat(64), signEvent: async event => ({...event, pubkey: "a".repeat(64), sig: "test"})};
692+ const sandbox = {
693+ document: {getElementById: id => elements.get(id), querySelector: () => null, addEventListener() {}, documentElement: {dataset: {}}},
694+ crypto: webcrypto, TextEncoder, URL, Uint8Array, ArrayBuffer,
695+ btoa: value => Buffer.from(value).toString("base64"),
696+ location: {pathname: "/r/work/signin", href: "https://tiny.example/r/work/signin", origin: "https://tiny.example", assign(url) { sandbox.opened = url; }, reload() { sandbox.reloaded = true; }},
697+ navigator: {clipboard: {writeText: async () => {} }},
698+ getComputedStyle: () => ({getPropertyValue: () => ""}),
699+ matchMedia: () => ({matches: false, addEventListener() {}}),
700+ localStorage: {getItem: () => null, setItem() {}},
701+ sessionStorage: {getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value), removeItem: key => saved.delete(key)},
702+ NostrSigner: {
703+ generateSecretKey: () => new Uint8Array(32), getPublicKey: () => "b".repeat(64), createNostrConnectURI: () => "nostrconnect://test",
704+ bytesToHex: () => "test-key", hexToBytes: () => new Uint8Array(32), parseBunkerInput: async () => bunkerResult,
705+ BunkerSigner: {fromURI: async () => signer, fromBunker: () => signer}
706+ },
707+ fetch: async (url, options) => { requests.push({url, options}); return new Response("{}"); }
708 };
709- sandbox.window=sandbox;
710- vm.runInNewContext(bridge,sandbox);
711- return {elements,saved,requests,sandbox};
712+ sandbox.window = sandbox;
713+ vm.runInNewContext(bridge, sandbox);
714+ await new Promise(resolve => setTimeout(resolve, 0));
715+ return {elements, saved, requests, sandbox};
716 }
717
718-test("approving a phone signer signs in and opens the tenant homepage",async()=>{
719- const {elements,saved,requests,sandbox}=await page();
720- await elements.get("nostrconnect").click({preventDefault(){}});
721- assert.equal(requests.length,1);
722- assert.equal(requests[0].url,"/r/work/session");
723+test("approving a phone signer signs in and opens the tenant homepage", async () => {
724+ const {elements, saved, requests, sandbox} = await page();
725+ await elements.get("nostrconnect").click({currentTarget: elements.get("nostrconnect"), preventDefault() {}});
726+ assert.equal(requests.length, 1);
727+ assert.equal(requests[0].url, "/r/work/session");
728 assert.ok(requests[0].options.headers.authorization.startsWith("Nostr "));
729- assert.equal(sandbox.opened,"/r/work/");
730- assert.equal(saved.get("tiny.bunker/r/work.identity"),"a".repeat(64));
731+ assert.equal(sandbox.opened, "/r/work/");
732+ assert.equal(saved.get("tiny.bunker/r/work.identity"), "a".repeat(64));
733 });
734
735-test("sign out revokes the session without requiring a connected phone",async()=>{
736- const {elements,requests,sandbox}=await page({signedIn:true});
737- await elements.get("session-logout").click();
738- assert.equal(requests[0].url,"/r/work/session/logout");
739- assert.equal(requests[0].options.credentials,"same-origin");
740- assert.equal(sandbox.reloaded,true);
741+test("invalid bunker input reports a useful error without connecting", async () => {
742+ const {elements, requests} = await page({bunkerResult: null});
743+ elements.get("bunker-url").value = "not-a-bunker";
744+ await elements.get("bunker").submit({currentTarget: elements.get("bunker"), preventDefault() {}});
745+ assert.equal(requests.length, 0);
746+ assert.equal(elements.get("signer-status").textContent, "Remote signer error: Enter a valid bunker URL with at least one relay.");
747+});
748+
749+test("NIP-07 signer can sign in directly", async () => {
750+ const {elements, requests, sandbox} = await page();
751+ sandbox.nostr = {signEvent: async event => ({...event, pubkey: "c".repeat(64), sig: "extension"})};
752+ await elements.get("session-login").click({currentTarget: elements.get("session-login")});
753+ assert.equal(requests.length, 1);
754+ assert.equal(requests[0].url, "/r/work/session");
755+ assert.match(elements.get("signer-status").textContent, /Signing in|Signed in/);
756+ assert.equal(sandbox.opened, "/r/work/");
757+});
758+
759+test("sign out revokes the session without requiring a connected phone", async () => {
760+ const {elements, requests, sandbox} = await page({signedIn: true});
761+ await elements.get("session-logout").click({currentTarget: elements.get("session-logout")});
762+ assert.equal(requests[0].url, "/r/work/session/logout");
763+ assert.equal(requests[0].options.credentials, "same-origin");
764+ assert.equal(sandbox.reloaded, true);
765 });

About

ownerc01503e8035f
visibilitypublic
head2500b72813d5

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs