| 1 | diff --git a/docs/app.md b/docs/app.md |
| 2 | index b2ed4a3..c25e256 100644 |
| 3 | --- a/docs/app.md |
| 4 | +++ b/docs/app.md |
| 5 | @@ -2,6 +2,10 @@ |
| 6 | |
| 7 | The relay installs as an app from the browser menu on Android, iOS and desktop. The installed app follows the site theme, opens in its own window and keeps the last pages available when the network fails. |
| 8 | |
| 9 | +## Mobile navigation |
| 10 | + |
| 11 | +The mobile header shows the current page or conversation. Chats show the room picture and name; replies show **Thread** above the room name. Use the back arrow to return to the parent page or conversation. Tap the title to open page details, including room members and settings. Tap **Menu** for navigation, your account and sign-out controls. |
| 12 | + |
| 13 | ## Sharing to the relay |
| 14 | |
| 15 | Share a photo, file or link from another app and choose the relay. Shared files wait in the Upload panel on the Files page until you press **Upload**, so nothing is stored without your signature. A shared link opens the **Import from URL** control with the address filled in. |
| 16 | @@ -24,6 +28,12 @@ A request for a decision arrives with **Approve**, **Deny** and **Reply** button |
| 17 | |
| 18 | An agent's access request offers **Review access** instead. It opens the proposed grant changes in Approvals. Only the current grant operator can approve them by signing the replacement grant; the notification itself cannot authorize more access. |
| 19 | |
| 20 | +## Reconnecting a signer |
| 21 | + |
| 22 | +Your sign-in session and signer connection are separate. After the app resumes, it checks the signer connection. If the signer is unavailable, the mobile header offers **Reconnect**. Your session stays signed in, and a draft stays in place. After reconnecting, retry the action that failed. |
| 23 | + |
| 24 | +If this browser has no saved signer connection, choose **Connect signer** to pair it again. Use the signer for the account already signed in. You do not need to sign out first. |
| 25 | + |
| 26 | ## Names |
| 27 | |
| 28 | People show by name wherever the relay knows one: room authors and members, mentions, approvals, agent owners, wiki authors and the signed-in line. Names come from the person's profile (kind 0) as held by this relay, so looking up a name does not contact a public indexer. Names are cached in the browser for six hours. Without JavaScript, or before a profile arrives, the short key shows instead. |
| 29 | @@ -36,10 +46,10 @@ A profile picture is used when it is a safe, usable URL. Otherwise the relay sho |
| 30 | |
| 31 | ## Account |
| 32 | |
| 33 | -The **Account** page is the home for settings that belong to the signed-in key. It links to your profile, shows your current relay lists and lets you choose whether this account shares presence and typing in Chat. That preference is stored for the account on this relay and applies across browsers; it is not a room setting. Select your name in the footer to return to Account. |
| 34 | +The **Account** page is the home for settings that belong to the signed-in key. It links to your profile, shows your current relay lists and lets you choose whether this account shares presence and typing in Chat. That preference is stored for the account on this relay and applies across browsers; it is not a room setting. Open **Menu** on a phone and select **Account**, or select your name in the desktop footer. |
| 35 | |
| 36 | When a protected page sends you to **Sign in**, the sign-in link carries that page's tenant path and query. After the signer connects, the browser returns to the page you opened, including repository, file, wiki and approval context. Fragments are kept by the browser when client-side navigation supplies them. |
| 37 | |
| 38 | ## Copying a page address |
| 39 | |
| 40 | -The crumb that names the current page, next to the relay name at the top on phones and in the footer on larger screens, copies the page's address when tapped and says "copied" for a moment. The address is the relay's public URL with the page's canonical path, so a link copied while browsing over a tailnet or LAN address still opens for anyone: wiki names are normalized the way the relay resolves them, and repositories and files keep the query that names them. |
| 41 | +On a phone, open **Menu** and choose **Copy page link**. On larger screens, tap the page name in the footer. The control says "copied" for a moment. The address is the relay's public URL with the page's canonical path, so a link copied while browsing over a tailnet or LAN address still opens for anyone: wiki names are normalized the way the relay resolves them, and repositories and files keep the query that names them. |
| 42 | diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js |
| 43 | index 51d7ac4..a299fa4 100644 |
| 44 | --- a/internal/webui/bridge.js |
| 45 | +++ b/internal/webui/bridge.js |
| 46 | @@ -94,10 +94,17 @@ |
| 47 | const menu = document.getElementById("nav-menu"); |
| 48 | if (menu) menu.open = open; |
| 49 | }; |
| 50 | + const setContext = open => { |
| 51 | + const menu = document.getElementById("context-menu"); |
| 52 | + if (menu) menu.open = open; |
| 53 | + }; |
| 54 | const shellBindings = new WeakSet(); |
| 55 | + let shellDocumentBound = false; |
| 56 | const bindShell = () => { |
| 57 | const rail = document.getElementById("rail"); |
| 58 | const themeButton = document.getElementById("theme"); |
| 59 | + const navMenu = document.getElementById("nav-menu"); |
| 60 | + const contextMenu = document.getElementById("context-menu"); |
| 61 | if (rail && !shellBindings.has(rail)) { |
| 62 | shellBindings.add(rail); |
| 63 | rail.addEventListener("click", event => { if (event.target.closest("a")) setMenu(false); }); |
| 64 | @@ -125,12 +132,38 @@ |
| 65 | paintChrome(); |
| 66 | }); |
| 67 | } |
| 68 | + if (navMenu && !shellBindings.has(navMenu)) { |
| 69 | + shellBindings.add(navMenu); |
| 70 | + navMenu.addEventListener("toggle", () => { if (navMenu.open) setContext(false); }); |
| 71 | + } |
| 72 | + if (contextMenu && !shellBindings.has(contextMenu)) { |
| 73 | + shellBindings.add(contextMenu); |
| 74 | + contextMenu.addEventListener("toggle", () => { if (contextMenu.open) setMenu(false); }); |
| 75 | + } |
| 76 | + if (!shellDocumentBound) { |
| 77 | + shellDocumentBound = true; |
| 78 | + document.addEventListener("click", event => { |
| 79 | + const nav = document.getElementById("nav-menu"); |
| 80 | + const context = document.getElementById("context-menu"); |
| 81 | + if ((!nav?.open && !context?.open) || event.target?.closest?.("#rail, #aside, #topbar, #footer")) return; |
| 82 | + setMenu(false); |
| 83 | + setContext(false); |
| 84 | + }); |
| 85 | + } |
| 86 | }; |
| 87 | bindShell(); |
| 88 | document.addEventListener("keydown", event => { |
| 89 | - if (event.key !== "Escape" || !document.getElementById("nav-menu")?.open) return; |
| 90 | - setMenu(false); |
| 91 | - document.getElementById("menu")?.focus(); |
| 92 | + if (event.key !== "Escape") return; |
| 93 | + const nav = document.getElementById("nav-menu"); |
| 94 | + const context = document.getElementById("context-menu"); |
| 95 | + if (nav?.open) { |
| 96 | + setMenu(false); |
| 97 | + setContext(false); |
| 98 | + document.getElementById("menu")?.focus(); |
| 99 | + } else if (context?.open) { |
| 100 | + setContext(false); |
| 101 | + document.getElementById("context-toggle")?.focus(); |
| 102 | + } |
| 103 | }); |
| 104 | if ("serviceWorker" in navigator) navigator.serviceWorker.register(localPath("/sw.js")).catch(() => {}); |
| 105 | // An installed app asks to keep its cached shell and parked shares. Browsers |
| 106 | @@ -182,7 +215,9 @@ |
| 107 | const signIn = async () => { |
| 108 | const destination = signInDestination(); |
| 109 | say("Signing in…"); |
| 110 | + if (sessionActor() && window.tiny.signer()?.getPublicKey) verifySignerIdentity(await window.tiny.signer().getPublicKey()); |
| 111 | await signedSession("/session"); |
| 112 | + announceSigner("ready"); |
| 113 | say("Signed in."); |
| 114 | location.replace(destination); |
| 115 | }; |
| 116 | @@ -204,14 +239,208 @@ |
| 117 | store.setItem(storage.remote, JSON.stringify(signer.bp)); |
| 118 | }; |
| 119 | |
| 120 | + let activeSigner, activeRemote, remoteFacade, nativeSigner = window.nostr; |
| 121 | + let signerStatus = "disconnected", signerMessage = "Signer unavailable."; |
| 122 | + let reconnecting, lastCheck = 0, signerGeneration = 0; |
| 123 | + const pendingCalls = new Map(); |
| 124 | + const signerStatusText = { |
| 125 | + disconnected: "Signer unavailable.", checking: "Checking signer…", |
| 126 | + reconnecting: "Reconnecting signer…", lost: "Signer connection lost.", ready: "Signer connected." |
| 127 | + }; |
| 128 | + const sessionActor = () => document.querySelector?.("signer-connection")?.getAttribute("actor") || ""; |
| 129 | + const nativeProvider = () => { |
| 130 | + if (window.nostr && window.nostr !== remoteFacade) nativeSigner = window.nostr; |
| 131 | + return nativeSigner; |
| 132 | + }; |
| 133 | + const withTimeout = (promise, ms, message = "Signer connection timed out.") => { |
| 134 | + let timer; |
| 135 | + return Promise.race([promise, new Promise((_, reject) => { |
| 136 | + timer = setTimeout(() => reject(Error(message)), ms); |
| 137 | + })]).finally(() => clearTimeout(timer)); |
| 138 | + }; |
| 139 | + const closeRemote = signer => { |
| 140 | + if (!signer) return; |
| 141 | + for (const reject of pendingCalls.get(signer) || []) reject(Error("Signer connection changed. Retry when connected.")); |
| 142 | + pendingCalls.delete(signer); |
| 143 | + try { Promise.resolve(signer.close?.()).catch(() => {}); } catch {} |
| 144 | + try { signer.pool?.destroy?.(); } catch {} |
| 145 | + }; |
| 146 | + const connectionURL = () => { |
| 147 | + const current = new URL(location.href); |
| 148 | + const url = new URL(localPath("/signin"), location.href); |
| 149 | + url.searchParams.set("connect", "1"); |
| 150 | + url.searchParams.set("next", current.pathname + current.search); |
| 151 | + if (current.hash) url.hash = "return=" + encodeURIComponent(current.pathname + current.search + current.hash); |
| 152 | + return url.href; |
| 153 | + }; |
| 154 | + const hasRememberedSigner = () => !!(sessionStorage.getItem(storage.key) || localStorage.getItem(storage.key)); |
| 155 | + const updateSignerConnection = () => { |
| 156 | + const host = document.querySelector?.("signer-connection"); |
| 157 | + if (!host || !document.createElement) return; |
| 158 | + host.hidden = signerStatus === "ready"; |
| 159 | + host.setAttribute("data-state", signerStatus); |
| 160 | + host.setAttribute("role", "status"); |
| 161 | + host.replaceChildren(); |
| 162 | + if (host.hidden) return; |
| 163 | + const message = document.createElement("span"); |
| 164 | + message.textContent = signerMessage; |
| 165 | + host.append(message); |
| 166 | + if (signerStatus === "checking" || signerStatus === "reconnecting") return; |
| 167 | + const button = document.createElement("button"); |
| 168 | + button.type = "button"; |
| 169 | + button.textContent = hasRememberedSigner() || nativeProvider() ? "Reconnect" : "Connect signer"; |
| 170 | + button.addEventListener("click", () => { |
| 171 | + if (!hasRememberedSigner() && !nativeProvider()) { location.assign(connectionURL()); return; } |
| 172 | + reconnectSigner({force: true}).catch(() => {}); |
| 173 | + }); |
| 174 | + host.append(button); |
| 175 | + }; |
| 176 | + const announceSigner = (status, message = signerStatusText[status]) => { |
| 177 | + signerStatus = status; |
| 178 | + signerMessage = message; |
| 179 | + updateSignerConnection(); |
| 180 | + const statusNode = document.getElementById("session-status"); |
| 181 | + if (statusNode) statusNode.textContent = status === "lost" ? message : ""; |
| 182 | + if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:signer-status", { |
| 183 | + detail: {status, text: message, canReconnect: status === "lost" || status === "disconnected"} |
| 184 | + })); |
| 185 | + }; |
| 186 | + const loseRemote = (remote, message) => { |
| 187 | + if (remote !== activeRemote) return; |
| 188 | + activeRemote = activeSigner = undefined; |
| 189 | + window.tinySigner = undefined; |
| 190 | + if (window.nostr === remoteFacade) window.nostr = nativeSigner; |
| 191 | + closeRemote(remote); |
| 192 | + announceSigner("lost", message || signerStatusText.lost); |
| 193 | + }; |
| 194 | + const guardSigner = signer => { |
| 195 | + const methods = new Map(); |
| 196 | + return new Proxy(signer, {get(target, property) { |
| 197 | + const value = Reflect.get(target, property, target); |
| 198 | + if (typeof value !== "function") return value; |
| 199 | + if (!/^(?:signEvent|getPublicKey|nip(?:04|44)(?:Encrypt|Decrypt))$/.test(String(property))) return value.bind(target); |
| 200 | + if (!methods.has(property)) methods.set(property, async (...args) => { |
| 201 | + if (target !== activeRemote) throw Error("Signer connection changed. Retry when connected."); |
| 202 | + let cancel; |
| 203 | + const pending = pendingCalls.get(target) || new Set(); |
| 204 | + pendingCalls.set(target, pending); |
| 205 | + const canceled = new Promise((_, reject) => { cancel = reject; pending.add(reject); }); |
| 206 | + try { |
| 207 | + const result = await withTimeout(Promise.race([Promise.resolve().then(() => value.apply(target, args)), canceled]), property === "signEvent" ? 120000 : 30000); |
| 208 | + if (target !== activeRemote) throw Error("Signer connection changed. Retry when connected."); |
| 209 | + return result; |
| 210 | + } catch (err) { |
| 211 | + if (!/reject|denied|cancel|declin|unsupported|invalid|permission/i.test(errorText(err))) loseRemote(target); |
| 212 | + throw err; |
| 213 | + } finally { pending.delete(cancel); } |
| 214 | + }); |
| 215 | + return methods.get(property); |
| 216 | + }}); |
| 217 | + }; |
| 218 | const setSigner = signer => { |
| 219 | - window.tinySigner = signer; |
| 220 | - window.nostr = {signEvent: event => window.tinySigner.signEvent(event)}; |
| 221 | + const previous = activeRemote; |
| 222 | + signerGeneration++; |
| 223 | + activeRemote = signer; |
| 224 | + activeSigner = guardSigner(signer); |
| 225 | + window.tinySigner = activeSigner; |
| 226 | + nativeProvider(); |
| 227 | + remoteFacade = {signEvent: event => activeSigner ? activeSigner.signEvent(event) : Promise.reject(Error("Reconnect your signer first."))}; |
| 228 | + window.nostr = remoteFacade; |
| 229 | + if (previous !== signer) closeRemote(previous); |
| 230 | + announceSigner("ready"); |
| 231 | if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:signer")); |
| 232 | - if (document.getElementById("signer-status")) { |
| 233 | - say(document.getElementById("session-logout") ? "Signer connected." : "Signer connected. Choose Sign in with connected signer to continue."); |
| 234 | - } |
| 235 | }; |
| 236 | + const storedRemote = async () => { |
| 237 | + const store = sessionStorage.getItem(storage.key) ? sessionStorage : localStorage; |
| 238 | + const uri = store.getItem(storage.uri), keyHex = store.getItem(storage.key); |
| 239 | + if (!uri || !keyHex) return null; |
| 240 | + const remote = store.getItem(storage.remote); |
| 241 | + const pointer = remote ? JSON.parse(remote) : await withTimeout(window.NostrSigner.parseBunkerInput(uri), 5000); |
| 242 | + if (!validBunker(pointer)) throw Error("Saved signer connection is invalid. Connect your signer again."); |
| 243 | + return {key: window.NostrSigner.hexToBytes(keyHex), pointer, expected: store.getItem(storage.pubkey)}; |
| 244 | + }; |
| 245 | + const verifySignerIdentity = (pubkey, expected) => { |
| 246 | + if (!/^[0-9a-f]{64}$/i.test(pubkey || "") || (expected && pubkey.toLowerCase() !== expected.toLowerCase()) || (sessionActor() && pubkey.toLowerCase() !== sessionActor().toLowerCase())) |
| 247 | + throw Error("Choose the signer for your signed-in account."); |
| 248 | + }; |
| 249 | + const reconnectSigner = ({force = false} = {}) => { |
| 250 | + if (reconnecting) return reconnecting; |
| 251 | + if (!force && Date.now() - lastCheck < 15000) return Promise.resolve(activeSigner); |
| 252 | + lastCheck = Date.now(); |
| 253 | + const generation = signerGeneration; |
| 254 | + // Deferring keeps even an immediately failed attempt in the single-flight slot. |
| 255 | + const task = Promise.resolve().then(async () => { |
| 256 | + if (!hasRememberedSigner()) { |
| 257 | + const native = nativeProvider(); |
| 258 | + if (!native?.getPublicKey) { announceSigner("disconnected"); return; } |
| 259 | + announceSigner("checking"); |
| 260 | + try { |
| 261 | + verifySignerIdentity(await withTimeout(native.getPublicKey(), 5000)); |
| 262 | + if (generation !== signerGeneration) return activeSigner; |
| 263 | + announceSigner("ready"); |
| 264 | + if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:signer")); |
| 265 | + return native; |
| 266 | + } catch (err) { |
| 267 | + if (generation === signerGeneration) announceSigner("lost", /signed-in account/.test(errorText(err)) ? errorText(err) : "Unlock or reconnect your signer."); |
| 268 | + throw err; |
| 269 | + } |
| 270 | + } |
| 271 | + if (!force && activeRemote?.ping) { |
| 272 | + const previous = activeRemote; |
| 273 | + announceSigner("checking"); |
| 274 | + try { |
| 275 | + await withTimeout(previous.ping(), 5000); |
| 276 | + if (generation !== signerGeneration) return activeSigner; |
| 277 | + announceSigner("ready"); |
| 278 | + return activeSigner; |
| 279 | + } catch { if (generation !== signerGeneration) return activeSigner; } |
| 280 | + } |
| 281 | + announceSigner("reconnecting"); |
| 282 | + let candidate; |
| 283 | + try { |
| 284 | + const stored = await storedRemote(); |
| 285 | + if (!stored) { announceSigner("disconnected"); return; } |
| 286 | + candidate = window.NostrSigner.BunkerSigner.fromBunker(stored.key, stored.pointer); |
| 287 | + await withTimeout(candidate.connect(), 8000); |
| 288 | + verifySignerIdentity(await withTimeout(candidate.getPublicKey(), 5000), stored.expected); |
| 289 | + if (generation !== signerGeneration) { closeRemote(candidate); return activeSigner; } |
| 290 | + setSigner(candidate); |
| 291 | + return activeSigner; |
| 292 | + } catch (err) { |
| 293 | + closeRemote(candidate); |
| 294 | + if (generation === signerGeneration) { |
| 295 | + const previous = activeRemote; |
| 296 | + activeRemote = activeSigner = undefined; |
| 297 | + window.tinySigner = undefined; |
| 298 | + if (window.nostr === remoteFacade) window.nostr = nativeSigner; |
| 299 | + closeRemote(previous); |
| 300 | + announceSigner("lost", /signed-in account/.test(errorText(err)) ? errorText(err) : signerStatusText.lost); |
| 301 | + } |
| 302 | + throw err; |
| 303 | + } |
| 304 | + }); |
| 305 | + reconnecting = task; |
| 306 | + task.finally(() => { if (reconnecting === task) reconnecting = undefined; }).catch(() => {}); |
| 307 | + return task; |
| 308 | + }; |
| 309 | + Object.assign(window.tiny, {signerState: () => signerStatus, reconnectSigner}); |
| 310 | + document.addEventListener("tiny:navigation", updateSignerConnection); |
| 311 | + const resumeSigner = () => { |
| 312 | + if (document.visibilityState && document.visibilityState !== "visible") return; |
| 313 | + if (hasRememberedSigner() || nativeProvider()) reconnectSigner().catch(() => {}); |
| 314 | + else updateSignerConnection(); |
| 315 | + }; |
| 316 | + document.addEventListener("visibilitychange", () => { |
| 317 | + if (document.visibilityState === "hidden") { lastCheck = -Infinity; return; } |
| 318 | + resumeSigner(); |
| 319 | + }); |
| 320 | + window.addEventListener("pageshow", event => { if (event.persisted) lastCheck = -Infinity; resumeSigner(); }); |
| 321 | + document.addEventListener("resume", () => { lastCheck = -Infinity; resumeSigner(); }); |
| 322 | + window.addEventListener("nostr:ready", () => { lastCheck = -Infinity; resumeSigner(); }); |
| 323 | + window.addEventListener("focus", resumeSigner); |
| 324 | + window.addEventListener("online", () => { lastCheck = -Infinity; resumeSigner(); }); |
| 325 | + window.addEventListener("offline", () => { if (activeRemote) loseRemote(activeRemote, "Offline. Reconnect when online."); }); |
| 326 | + updateSignerConnection(); |
| 327 | |
| 328 | // Fixi can morph the content column without reloading this script. Bind |
| 329 | // controls by element identity so navigation can safely discover new ones. |
| 330 | @@ -241,6 +470,10 @@ |
| 331 | say("Signing out…"); |
| 332 | const response = await fetch(localPath("/session/logout"), {method: "POST", credentials: "same-origin"}); |
| 333 | if (!response.ok) throw Error(await response.text()); |
| 334 | + signerGeneration++; |
| 335 | + closeRemote(activeRemote); |
| 336 | + activeRemote = activeSigner = undefined; |
| 337 | + window.tinySigner = undefined; |
| 338 | forgetRemote(); |
| 339 | if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:logout")); |
| 340 | say("Signed out."); |
| 341 | @@ -307,23 +540,8 @@ |
| 342 | |
| 343 | // Resume a remote signer stored for this tab so page loads keep working. |
| 344 | (async () => { |
| 345 | - const store = sessionStorage.getItem(storage.key) ? sessionStorage : localStorage; |
| 346 | - const uri = store.getItem(storage.uri); |
| 347 | - const keyHex = store.getItem(storage.key); |
| 348 | - const expected = store.getItem(storage.pubkey); |
| 349 | - const remote = store.getItem(storage.remote); |
| 350 | - if (!uri || !keyHex) return; |
| 351 | try { |
| 352 | - const key = window.NostrSigner.hexToBytes(keyHex); |
| 353 | - const pointer = remote ? JSON.parse(remote) : await window.NostrSigner.parseBunkerInput(uri); |
| 354 | - const signer = window.NostrSigner.BunkerSigner.fromBunker(key, pointer); |
| 355 | - await signer.connect(); |
| 356 | - const pubkey = await signer.getPublicKey(); |
| 357 | - if (expected && pubkey !== expected) throw Error("Remote signer identity changed"); |
| 358 | - setSigner(signer); |
| 359 | - } catch (err) { |
| 360 | - forgetRemote(); |
| 361 | - say("Remote signer resume error: " + errorText(err)); |
| 362 | - } |
| 363 | + if (hasRememberedSigner() || nativeProvider()) await reconnectSigner({force: true}); |
| 364 | + } catch {} |
| 365 | })(); |
| 366 | })(); |
| 367 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 368 | index 9ad41db..6a10df1 100644 |
| 369 | --- a/internal/webui/components.js |
| 370 | +++ b/internal/webui/components.js |
| 371 | @@ -1734,6 +1734,7 @@ |
| 372 | }; |
| 373 | const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|account|outbox|search|social|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki|social)\/.+|\/chat\/dm\/[0-9a-f]{64}|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || ""); |
| 374 | let navigationSerial = 0, activeAbort; |
| 375 | + let renderedURL = location.href; |
| 376 | const streams = new Set(); |
| 377 | const closeStreams = () => { |
| 378 | streams.forEach(cfg => cfg.sse.close()); |
| 379 | @@ -1762,6 +1763,7 @@ |
| 380 | }; |
| 381 | const swapShell = (text, url, push) => { |
| 382 | if (url.__tinyNavigationSerial && url.__tinyNavigationSerial !== navigationSerial) return false; |
| 383 | + const routeChanged = renderedURL !== url.href; |
| 384 | const parsed = new DOMParser().parseFromString(text, "text/html"); |
| 385 | const incoming = new Map(navTargets.map(selector => [selector, parsed.querySelector(selector)])); |
| 386 | if ([...incoming.values()].some(node => !node)) return false; |
| 387 | @@ -1771,9 +1773,19 @@ |
| 388 | repairComponents(); |
| 389 | document.title = parsed.title || document.title; |
| 390 | if (push) history.pushState({}, "", url.href); |
| 391 | - const focus = focusID && document.getElementById(focusID); |
| 392 | - if (focus && typeof focus.focus === "function") focus.focus({preventScroll: true}); |
| 393 | - else document.querySelector("#content")?.focus({preventScroll: true}); |
| 394 | + renderedURL = url.href; |
| 395 | + if (routeChanged) { |
| 396 | + document.getElementById("nav-menu")?.removeAttribute("open"); |
| 397 | + document.getElementById("context-menu")?.removeAttribute("open"); |
| 398 | + const content = document.querySelector("#content"); |
| 399 | + if (content) content.scrollTop = 0; |
| 400 | + if (typeof window.scrollTo === "function") window.scrollTo(0, 0); |
| 401 | + content?.focus({preventScroll: true}); |
| 402 | + } else { |
| 403 | + const focus = focusID && document.getElementById(focusID); |
| 404 | + if (focus && typeof focus.focus === "function") focus.focus({preventScroll: true}); |
| 405 | + else document.querySelector("#content")?.focus({preventScroll: true}); |
| 406 | + } |
| 407 | document.dispatchEvent(new CustomEvent("tiny:navigation", {detail: {url: url.href}})); |
| 408 | ensureModules(); |
| 409 | decorate(); |
| 410 | diff --git a/internal/webui/mobile_header.go b/internal/webui/mobile_header.go |
| 411 | new file mode 100644 |
| 412 | index 0000000..a759ec8 |
| 413 | --- /dev/null |
| 414 | +++ b/internal/webui/mobile_header.go |
| 415 | @@ -0,0 +1,216 @@ |
| 416 | +package webui |
| 417 | + |
| 418 | +import ( |
| 419 | + "net/url" |
| 420 | + "strings" |
| 421 | +) |
| 422 | + |
| 423 | +// mobileHeaderData is the small, route-aware context used by the mobile shell. |
| 424 | +// It contains only data that has already passed page access checks. |
| 425 | +type mobileHeaderData struct { |
| 426 | + Title string |
| 427 | + Subtitle string |
| 428 | + BackURL string |
| 429 | + BackLabel string |
| 430 | + PubKey string |
| 431 | + RoomID string |
| 432 | + Picture string |
| 433 | +} |
| 434 | + |
| 435 | +// mobileHeader derives a compact header from rendered page data. Private and |
| 436 | +// failed room pages deliberately use generic values so hidden room metadata |
| 437 | +// cannot appear in the shell. |
| 438 | +func mobileHeader(data PageData) mobileHeaderData { |
| 439 | + if data.Private { |
| 440 | + return mobileHeaderData{Title: "Sign in", Subtitle: data.Slug} |
| 441 | + } |
| 442 | + if data.Tab == "signin" { |
| 443 | + title := "Sign in" |
| 444 | + if data.Actor != "" { |
| 445 | + title = "Account" |
| 446 | + if data.Query.Get("connect") != "" { |
| 447 | + title = "Connect signer" |
| 448 | + } |
| 449 | + } |
| 450 | + result := mobileHeaderData{Title: title, Subtitle: data.Slug, BackLabel: "Back"} |
| 451 | + if next, ok := mobileReturnPath(data.Query.Get("next"), data.Base); ok { |
| 452 | + result.BackURL = next |
| 453 | + result.BackLabel = "Back" |
| 454 | + } |
| 455 | + return result |
| 456 | + } |
| 457 | + if data.Error != "" && roomRoute(data.Path).tab != "" { |
| 458 | + return mobileHeaderData{Title: "Chat", BackURL: "/chat", BackLabel: "Back to chats"} |
| 459 | + } |
| 460 | + route := roomRoute(data.Path) |
| 461 | + switch route.tab { |
| 462 | + case "room": |
| 463 | + return roomHeader(data, route, false) |
| 464 | + case "thread": |
| 465 | + return roomHeader(data, route, true) |
| 466 | + } |
| 467 | + if data.Tab == "direct" || strings.HasPrefix(data.Path, "/chat/dm/") { |
| 468 | + result := mobileHeaderData{Title: "Direct chat", Subtitle: "Private conversation", BackURL: "/chat", BackLabel: "Back to chats"} |
| 469 | + if eventIDPattern.MatchString(data.View) { |
| 470 | + result.PubKey = data.View |
| 471 | + } |
| 472 | + return result |
| 473 | + } |
| 474 | + return generalMobileHeader(data) |
| 475 | +} |
| 476 | + |
| 477 | +func roomHeader(data PageData, route roomPath, thread bool) mobileHeaderData { |
| 478 | + result := mobileHeaderData{Title: "Group chat", Subtitle: "Chat", RoomID: route.id} |
| 479 | + room := valueMap(valueMap(data.Event)["room"]) |
| 480 | + if name := plainString(room["name"]); name != "" { |
| 481 | + result.Title = name |
| 482 | + } |
| 483 | + result.Picture = socialMediaURL(plainString(room["picture"])) |
| 484 | + if thread { |
| 485 | + result.Title = "Thread" |
| 486 | + result.Subtitle = plainString(room["name"]) |
| 487 | + if result.Subtitle == "" { |
| 488 | + result.Subtitle = "Group chat" |
| 489 | + } |
| 490 | + if eventIDPattern.MatchString(route.event) { |
| 491 | + result.BackURL = "/rooms/" + route.id + "#msg-" + route.event |
| 492 | + } |
| 493 | + result.BackLabel = "Back to room" |
| 494 | + return result |
| 495 | + } |
| 496 | + result.BackURL, result.BackLabel = "/chat", "Back to chats" |
| 497 | + return result |
| 498 | +} |
| 499 | + |
| 500 | +func generalMobileHeader(data PageData) mobileHeaderData { |
| 501 | + path := data.Path |
| 502 | + result := mobileHeaderData{Title: mobileTitle(path, data), Subtitle: data.Slug, BackLabel: "Back"} |
| 503 | + if data.Tab == "social-thread" { |
| 504 | + result.Title = eventShortTitle(data.Event, "Social") |
| 505 | + result.Subtitle = "Social" |
| 506 | + } |
| 507 | + if data.Tab == "file" { |
| 508 | + result.Title = eventShortTitle(data.Event, "File") |
| 509 | + result.Subtitle = "Files" |
| 510 | + } |
| 511 | + if data.Tab == "files" && data.Query.Get("view") == "sites" { |
| 512 | + result.Title = "Sites" |
| 513 | + result.Subtitle = "Files" |
| 514 | + } |
| 515 | + if data.Tab == "repo" { |
| 516 | + if repo := data.Query.Get("repo"); repo != "" { |
| 517 | + result.Title = repo |
| 518 | + } |
| 519 | + result.Subtitle = repoView(data.Query) |
| 520 | + } |
| 521 | + if data.Tab == "wikipage" { |
| 522 | + result.Title = eventShortTitle(data.Event, data.Query.Get("d")) |
| 523 | + result.Subtitle = "Wiki" |
| 524 | + } |
| 525 | + switch { |
| 526 | + case path == "/" || path == "": |
| 527 | + case path == "/chat" || path == "/social" || path == "/files" || path == "/wiki" || path == "/repos": |
| 528 | + result.BackURL = "/" |
| 529 | + case path == "/manage" || path == "/manage/people": |
| 530 | + result.BackURL = "/" |
| 531 | + result.BackLabel = "Back to home" |
| 532 | + case strings.HasPrefix(path, "/manage/"): |
| 533 | + result.BackURL = "/manage/people" |
| 534 | + result.BackLabel = "Back to manage" |
| 535 | + case path == "/sites": |
| 536 | + result.BackURL = "/" |
| 537 | + case path == "/file": |
| 538 | + result.BackURL = "/files" |
| 539 | + result.BackLabel = "Back to files" |
| 540 | + case path == "/repo": |
| 541 | + result.BackURL = "/repos" |
| 542 | + result.BackLabel = "Back to repositories" |
| 543 | + case path == "/profile": |
| 544 | + result.BackURL = "/account" |
| 545 | + result.BackLabel = "Back to account" |
| 546 | + case strings.HasPrefix(path, "/social/"): |
| 547 | + result.BackURL = "/social" |
| 548 | + result.BackLabel = "Back to social" |
| 549 | + case strings.HasPrefix(path, "/files/"): |
| 550 | + result.BackURL = "/files" |
| 551 | + result.BackLabel = "Back to files" |
| 552 | + case strings.HasPrefix(path, "/wiki/"): |
| 553 | + result.BackURL = "/wiki" |
| 554 | + result.BackLabel = "Back to wiki" |
| 555 | + case strings.HasPrefix(path, "/repos/"): |
| 556 | + result.BackURL = "/repos" |
| 557 | + result.BackLabel = "Back to repositories" |
| 558 | + case path != "/social" && path != "/files" && path != "/wiki" && path != "/repos": |
| 559 | + result.BackURL = "/" |
| 560 | + } |
| 561 | + if strings.HasPrefix(path, "/manage/") { |
| 562 | + result.Subtitle = "Manage" |
| 563 | + } |
| 564 | + if data.Tab == "signin" { |
| 565 | + result.Title = "Sign in" |
| 566 | + result.Subtitle = data.Slug |
| 567 | + } |
| 568 | + return result |
| 569 | +} |
| 570 | + |
| 571 | +func mobileReturnPath(raw, base string) (string, bool) { |
| 572 | + if raw == "" || strings.IndexFunc(raw, func(r rune) bool { return r < 0x20 || r == '\\' }) >= 0 { |
| 573 | + return "", false |
| 574 | + } |
| 575 | + u, err := url.Parse(raw) |
| 576 | + if err != nil || u.IsAbs() || u.Host != "" || u.Path == "/signin" || !strings.HasPrefix(u.Path, "/") { |
| 577 | + return "", false |
| 578 | + } |
| 579 | + decodedPath, err := url.PathUnescape(u.Path) |
| 580 | + if err != nil || strings.IndexFunc(decodedPath, func(r rune) bool { return r < 0x20 || r == '\\' }) >= 0 { |
| 581 | + return "", false |
| 582 | + } |
| 583 | + if base != "" && strings.HasPrefix(u.Path, "/r/") && !strings.HasPrefix(u.Path, strings.TrimSuffix(base, "/")+"/") { |
| 584 | + return "", false |
| 585 | + } |
| 586 | + return raw, true |
| 587 | +} |
| 588 | + |
| 589 | +func eventShortTitle(value any, fallback string) string { |
| 590 | + row := valueMap(value) |
| 591 | + if post := valueMap(row["post"]); post != nil { |
| 592 | + row = post |
| 593 | + } |
| 594 | + for _, key := range []string{"name", "filename", "title", "d"} { |
| 595 | + if title := strings.TrimSpace(plainString(row[key])); title != "" && !eventIDPattern.MatchString(title) { |
| 596 | + return shortHeaderText(title) |
| 597 | + } |
| 598 | + } |
| 599 | + if content := strings.TrimSpace(plainString(row["content"])); content != "" { |
| 600 | + return shortHeaderText(strings.Split(content, "\n")[0]) |
| 601 | + } |
| 602 | + return fallback |
| 603 | +} |
| 604 | + |
| 605 | +func shortHeaderText(value string) string { |
| 606 | + value = strings.TrimSpace(value) |
| 607 | + if len([]rune(value)) > 64 { |
| 608 | + return string([]rune(value)[:61]) + "..." |
| 609 | + } |
| 610 | + return value |
| 611 | +} |
| 612 | + |
| 613 | +func mobileTitle(path string, data PageData) string { |
| 614 | + known := map[string]string{"/": "Home", "/home": "Home", "/search": "Search", "/social": "Social", "/chat": "Chat", "/files": "Files", "/file": "File", "/wiki": "Wiki", "/repos": "Repositories", "/repo": "Repository", "/approvals": "Approvals", "/profile": "Profile", "/account": "Account", "/manage": "Manage", "/manage/people": "People", "/manage/agents": "Agents", "/manage/moderation": "Moderation", "/manage/rules": "Rules", "/manage/identity": "Identity", "/manage/connect": "Connect", "/manage/owner": "Owner", "/manage/sync": "Sync", "/manage/data": "Data", "/manage/views": "Views", "/manage/health": "Health", "/sites": "Sites"} |
| 615 | + if title, ok := known[path]; ok { |
| 616 | + return title |
| 617 | + } |
| 618 | + if strings.HasPrefix(path, "/social/") { |
| 619 | + return "Social" |
| 620 | + } |
| 621 | + if strings.HasPrefix(path, "/wiki/") { |
| 622 | + return "Wiki" |
| 623 | + } |
| 624 | + if strings.HasPrefix(path, "/repos/") { |
| 625 | + return "Repositories" |
| 626 | + } |
| 627 | + if title := strings.TrimSuffix(data.Title, " | "+data.Slug); title != "" && !eventIDPattern.MatchString(title) { |
| 628 | + return title |
| 629 | + } |
| 630 | + return "Tiny" |
| 631 | +} |
| 632 | diff --git a/internal/webui/mobile_header_test.go b/internal/webui/mobile_header_test.go |
| 633 | new file mode 100644 |
| 634 | index 0000000..d32d606 |
| 635 | --- /dev/null |
| 636 | +++ b/internal/webui/mobile_header_test.go |
| 637 | @@ -0,0 +1,124 @@ |
| 638 | +package webui |
| 639 | + |
| 640 | +import ( |
| 641 | + "net/url" |
| 642 | + "strings" |
| 643 | + "testing" |
| 644 | +) |
| 645 | + |
| 646 | +func TestMobileHeaderGeneralRoutes(t *testing.T) { |
| 647 | + tests := []struct { |
| 648 | + path, title, wantTitle, wantSubtitle, wantBack string |
| 649 | + }{ |
| 650 | + {"/", "tiny | relay", "Home", "tiny", ""}, |
| 651 | + {"/social", "social | tiny", "Social", "tiny", "/"}, |
| 652 | + {"/files", "Files | tiny", "Files", "tiny", "/"}, |
| 653 | + {"/wiki", "Wiki | tiny", "Wiki", "tiny", "/"}, |
| 654 | + {"/repos", "Repositories | tiny", "Repositories", "tiny", "/"}, |
| 655 | + {"/manage/people", "people | tiny", "People", "Manage", "/"}, |
| 656 | + {"/social/thread/abc", "Social | tiny", "Social", "tiny", "/social"}, |
| 657 | + {"/file", "File | tiny", "File", "tiny", "/files"}, |
| 658 | + {"/repo", "Repository | tiny", "Repository", "tiny", "/repos"}, |
| 659 | + {"/profile", "Profile | tiny", "Profile", "tiny", "/account"}, |
| 660 | + {"/sites", "sites | tiny", "Sites", "tiny", "/"}, |
| 661 | + } |
| 662 | + for _, test := range tests { |
| 663 | + got := mobileHeader(PageData{Path: test.path, Title: test.title, Slug: "tiny"}) |
| 664 | + if got.Title != test.wantTitle || got.Subtitle != test.wantSubtitle || got.BackURL != test.wantBack || got.BackLabel == "" && test.wantBack != "" { |
| 665 | + t.Errorf("mobileHeader(%q) = %+v, want title=%q subtitle=%q back=%q", test.path, got, test.wantTitle, test.wantSubtitle, test.wantBack) |
| 666 | + } |
| 667 | + } |
| 668 | +} |
| 669 | + |
| 670 | +func TestMobileHeaderRoomAndThread(t *testing.T) { |
| 671 | + room := map[string]any{"room": map[string]any{"id": "general", "name": "The <General> Room", "picture": "https://cdn.example/room.png"}} |
| 672 | + got := mobileHeader(PageData{Path: "/rooms/general", Tab: "room", Event: room, URL: "https://relay.example"}) |
| 673 | + if got.Title != "The <General> Room" || got.Subtitle != "Chat" || got.BackURL != "/chat" || got.RoomID != "general" || got.Picture == "" { |
| 674 | + t.Fatalf("room header = %+v", got) |
| 675 | + } |
| 676 | + root := strings.Repeat("a", 64) |
| 677 | + thread := mobileHeader(PageData{Path: "/rooms/general/thread/" + root, Tab: "thread", Event: map[string]any{ |
| 678 | + "room": map[string]any{"id": "general", "name": "General"}, |
| 679 | + "root": map[string]any{"id": root}, |
| 680 | + }}) |
| 681 | + if thread.Title != "Thread" || thread.Subtitle != "General" || thread.BackURL != "/rooms/general#msg-"+root || thread.BackLabel != "Back to room" { |
| 682 | + t.Fatalf("thread header = %+v", thread) |
| 683 | + } |
| 684 | +} |
| 685 | + |
| 686 | +func TestMobileHeaderDirectAndHiddenData(t *testing.T) { |
| 687 | + peer := strings.Repeat("b", 64) |
| 688 | + got := mobileHeader(PageData{Path: "/chat/dm/" + peer, Tab: "direct", View: peer}) |
| 689 | + if got.Title != "Direct chat" || got.Subtitle != "Private conversation" || got.PubKey != peer || got.BackURL != "/chat" { |
| 690 | + t.Fatalf("direct header = %+v", got) |
| 691 | + } |
| 692 | + private := mobileHeader(PageData{Path: "/rooms/secret", Tab: "room", Private: true, Event: map[string]any{"room": map[string]any{"name": "Hidden", "picture": "https://secret.example/p.png"}}}) |
| 693 | + if private.Title != "Sign in" || private.Subtitle != "" || private.Picture != "" || private.RoomID != "" { |
| 694 | + t.Fatalf("private header leaked data: %+v", private) |
| 695 | + } |
| 696 | + privateDirect := mobileHeader(PageData{Path: "/chat/dm/" + peer, Tab: "direct", View: peer, Private: true, Slug: "tiny", Event: map[string]any{"pubkey": peer}}) |
| 697 | + if privateDirect.Title != "Sign in" || privateDirect.PubKey != "" || privateDirect.RoomID != "" || privateDirect.Picture != "" { |
| 698 | + t.Fatalf("private direct header leaked data: %+v", privateDirect) |
| 699 | + } |
| 700 | + err := mobileHeader(PageData{Path: "/rooms/secret", Tab: "room", Error: "denied", Event: map[string]any{"room": map[string]any{"name": "Hidden"}}}) |
| 701 | + if err.Title != "Chat" || err.RoomID != "" || err.BackURL != "/chat" || err.BackLabel != "Back to chats" { |
| 702 | + t.Fatalf("error header leaked data: %+v", err) |
| 703 | + } |
| 704 | + signin := mobileHeader(PageData{Path: "/manage/people", Tab: "signin", Title: "People | tiny", Slug: "tiny"}) |
| 705 | + if signin.Title != "Sign in" || signin.Subtitle != "tiny" { |
| 706 | + t.Fatalf("signin header = %+v", signin) |
| 707 | + } |
| 708 | +} |
| 709 | + |
| 710 | +func TestMobileHeaderUsesDeepLinkContext(t *testing.T) { |
| 711 | + article := mobileHeader(PageData{Path: "/social/" + strings.Repeat("a", 64), Tab: "social-thread", Slug: "tiny", Event: map[string]any{ |
| 712 | + "post": map[string]any{"title": "A useful article"}, |
| 713 | + }}) |
| 714 | + if article.Title != "A useful article" || article.Subtitle != "Social" || article.BackURL != "/social" || article.BackLabel != "Back to social" { |
| 715 | + t.Fatalf("article header = %+v", article) |
| 716 | + } |
| 717 | + file := mobileHeader(PageData{Path: "/file", Tab: "file", Slug: "tiny", Event: map[string]any{"name": "photo.mp4"}}) |
| 718 | + if file.Title != "photo.mp4" || file.Subtitle != "Files" || file.BackURL != "/files" || file.BackLabel != "Back to files" { |
| 719 | + t.Fatalf("file header = %+v", file) |
| 720 | + } |
| 721 | + repo := mobileHeader(PageData{Path: "/repo", Tab: "repo", Slug: "tiny", Query: url.Values{"repo": []string{"seedmark"}, "view": []string{"issues"}}}) |
| 722 | + if repo.Title != "seedmark" || repo.Subtitle != "issues" || repo.BackURL != "/repos" || repo.BackLabel != "Back to repositories" { |
| 723 | + t.Fatalf("repo header = %+v", repo) |
| 724 | + } |
| 725 | + wiki := mobileHeader(PageData{Path: "/wiki/release-notes", Tab: "wikipage", Slug: "tiny", Query: url.Values{"d": []string{"release-notes"}}, Event: map[string]any{"title": "Release notes"}}) |
| 726 | + if wiki.Title != "Release notes" || wiki.Subtitle != "Wiki" || wiki.BackURL != "/wiki" { |
| 727 | + t.Fatalf("wiki header = %+v", wiki) |
| 728 | + } |
| 729 | + sites := mobileHeader(PageData{Path: "/files", Tab: "files", Slug: "tiny", Query: url.Values{"view": []string{"sites"}}}) |
| 730 | + if sites.Title != "Sites" || sites.Subtitle != "Files" || sites.BackURL != "/" { |
| 731 | + t.Fatalf("sites header = %+v", sites) |
| 732 | + } |
| 733 | +} |
| 734 | + |
| 735 | +func TestMobileHeaderManagementParent(t *testing.T) { |
| 736 | + got := mobileHeader(PageData{Path: "/manage/agents", Tab: "agents", Slug: "tiny"}) |
| 737 | + if got.BackURL != "/manage/people" || got.BackLabel != "Back to manage" { |
| 738 | + t.Fatalf("management header = %+v", got) |
| 739 | + } |
| 740 | +} |
| 741 | + |
| 742 | +func TestMobileHeaderSigninReturnValidation(t *testing.T) { |
| 743 | + valid := mobileHeader(PageData{Tab: "signin", Slug: "tiny", Base: "/r/demo", Query: url.Values{"next": []string{"/r/demo/social?q=hello"}}}) |
| 744 | + if valid.BackURL != "/r/demo/social?q=hello" { |
| 745 | + t.Fatalf("valid signin return = %+v", valid) |
| 746 | + } |
| 747 | + for _, next := range []string{"/\\evil.test", "//evil.test", "/r/other/chat", "/r/demo/%5Cevil", "/signin"} { |
| 748 | + got := mobileHeader(PageData{Tab: "signin", Slug: "tiny", Base: "/r/demo", Query: url.Values{"next": []string{next}}}) |
| 749 | + if got.BackURL != "" { |
| 750 | + t.Errorf("unsafe signin return %q accepted as %+v", next, got) |
| 751 | + } |
| 752 | + } |
| 753 | + connected := mobileHeader(PageData{Tab: "signin", Actor: strings.Repeat("a", 64), Slug: "tiny", Query: url.Values{"connect": []string{"1"}}}) |
| 754 | + if connected.Title != "Connect signer" { |
| 755 | + t.Fatalf("connected signin header = %+v", connected) |
| 756 | + } |
| 757 | + account := mobileHeader(PageData{Tab: "signin", Actor: strings.Repeat("a", 64), Slug: "tiny"}) |
| 758 | + if account.Title != "Account" { |
| 759 | + t.Fatalf("account signin header = %+v", account) |
| 760 | + } |
| 761 | +} |
| 762 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 763 | index fbe8250..cd96ed9 100644 |
| 764 | --- a/internal/webui/page.html |
| 765 | +++ b/internal/webui/page.html |
| 766 | @@ -24,7 +24,15 @@ then feature modules are loaded for the current page. */}} |
| 767 | <script src="/webmcp.js"></script> |
| 768 | </head> |
| 769 | <body> |
| 770 | -<header id="topbar"><details id="nav-menu"><summary id="menu" aria-controls="rail">menu</summary></details> <span><b>{{.Slug}}</b> » <page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address">{{prompt .Path .Query}}</page-link></span></header> |
| 771 | +{{$mobile := mobileHeader .}}<header id="topbar"> |
| 772 | +{{if $mobile.BackURL}}<a id="mobile-back" href="{{$mobile.BackURL}}" aria-label="{{$mobile.BackLabel}}" title="{{$mobile.BackLabel}}"><svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m14 6-6 6 6 6"/></svg></a>{{end}} |
| 773 | +<details id="context-menu" name="mobile-chrome"><summary id="context-toggle" aria-controls="aside" title="Page details"> |
| 774 | +{{if $mobile.RoomID}}<room-avatar picture="{{$mobile.Picture}}" fallback="{{roomAvatarURL .URL $mobile.RoomID}}" aria-hidden="true"><img src="{{if $mobile.Picture}}{{$mobile.Picture}}{{else}}{{roomAvatarURL .URL $mobile.RoomID}}{{end}}" alt="" width="36" height="36" loading="lazy"></room-avatar>{{else if $mobile.PubKey}}<nostr-avatar pubkey="{{$mobile.PubKey}}" aria-hidden="true"><img src="{{avatarURL $mobile.PubKey}}" alt="" width="36" height="36" loading="lazy"></nostr-avatar>{{end}} |
| 775 | +<span id="mobile-heading"><strong id="mobile-title" role="heading" aria-level="1">{{if $mobile.PubKey}}{{template "nostrName" $mobile.PubKey}}{{else}}{{$mobile.Title}}{{end}}</strong><small id="mobile-context">{{$mobile.Subtitle}}</small></span><svg data-chevron viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m8 10 4 4 4-4"/></svg> |
| 776 | +</summary></details> |
| 777 | +<details id="nav-menu" name="mobile-chrome"><summary id="menu" aria-controls="rail" aria-label="Menu" title="Menu"><svg data-menu-icon viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg><svg data-close-icon viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6"/></svg></summary></details> |
| 778 | +{{if and .Actor (not .Private)}}<signer-connection actor="{{.Actor}}" hidden></signer-connection>{{end}} |
| 779 | +</header> |
| 780 | <table id="layout" aria-label="{{.Slug}}"><tbody><tr> |
| 781 | <td id="rail"><div id="railbox">{{template "rail" .}}</div></td> |
| 782 | <td id="main"><div id="content" tabindex="-1"> |
| 783 | @@ -114,7 +122,7 @@ then feature modules are loaded for the current page. */}} |
| 784 | <p><a href="/chat#new-room">+ new room</a></p> |
| 785 | {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header> |
| 786 | {{range $group := navGroups relayItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}{{if .Actor}}<p id="personal-nav"><a href="/account"{{if eq .Tab "account"}} aria-current="page"{{end}}>account</a></p>{{end}} |
| 787 | -{{end}}<p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">← all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">← relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}} |
| 788 | +{{end}}<div id="mobile-actions">{{if .Actor}}<a href="/account">Account</a>{{end}}<page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address"><button type="button">Copy page link</button></page-link></div><p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">← all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">← relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}} |
| 789 | |
| 790 | {{/* The panel holds facts and filters for the current page. */}} |
| 791 | {{define "panel"}}{{$kind := railKind .Tab}}{{if .Private}}{{template "panel-signin" .}} |
| 792 | diff --git a/internal/webui/public.html b/internal/webui/public.html |
| 793 | index 1337ffc..f06a2f3 100644 |
| 794 | --- a/internal/webui/public.html |
| 795 | +++ b/internal/webui/public.html |
| 796 | @@ -25,11 +25,12 @@ |
| 797 | {{end}} |
| 798 | |
| 799 | {{define "signin"}}<section id="signin"> |
| 800 | -<h1>{{if .Actor}}Your account{{else}}Sign in{{end}}</h1> |
| 801 | +<h1>{{if and .Actor (.Query.Get "connect")}}Connect your signer{{else if .Actor}}Your account{{else}}Sign in{{end}}</h1> |
| 802 | {{if .Notice}}<p role="status">{{.Notice}}</p>{{end}} |
| 803 | {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p> |
| 804 | <p><a href="/account">Open your account</a> | <a href="/chat">Chat</a></p> |
| 805 | -<p>Use the sign out control in the footer to end this session.</p> |
| 806 | +{{if not (.Query.Get "connect")}}<p><a href="/signin?connect=1{{with .Query.Get "next"}}&next={{urlquery .}}{{end}}">Connect a signer</a> to restore signing without signing out.</p>{{end}} |
| 807 | +{{if .Query.Get "connect"}}<p>Connect the signer for this account. Your sign-in session stays active.</p>{{template "signerControls" .}}{{else}}<p>Open Menu on a phone, or use the desktop footer, to sign out.</p>{{end}} |
| 808 | {{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p> |
| 809 | {{template "signerControls" .}} |
| 810 | {{end}}</section> |
| 811 | @@ -52,7 +53,7 @@ |
| 812 | </tbody></table><output role="status"></output></relay-lists> |
| 813 | </section>{{end}} |
| 814 | |
| 815 | -{{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p> |
| 816 | +{{define "signerControls"}}<p><button id="session-login" type="button">{{if .Actor}}Use connected signer{{else}}Sign in with connected signer{{end}}</button></p> |
| 817 | <h2>Phone signer</h2> |
| 818 | <p>Scan the QR code with Amber and approve the connection and sign-in request. You will return to the page you opened.</p> |
| 819 | <p><label><input type="checkbox" id="remember-signer"> Remember this device</label> <small>Keeps the phone signer connected after the app closes. Sign out to forget it.</small></p> |
| 820 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 821 | index 7c54e87..a449af8 100644 |
| 822 | --- a/internal/webui/style.css |
| 823 | +++ b/internal/webui/style.css |
| 824 | @@ -24,7 +24,7 @@ img{max-width:100%} |
| 825 | #rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,nostr-compose label,wiki-compose label,file-tools label,file-upload label,nostr-event>header,approval-item>header,wiki-article>header,diff-view caption small{user-select:none;-webkit-user-select:none} |
| 826 | |
| 827 | /* Shell: a table with three columns; each column scrolls on its own. */ |
| 828 | -#topbar{display:none} |
| 829 | +#topbar,#mobile-actions{display:none} |
| 830 | #layout{border-collapse:collapse;table-layout:fixed;width:100%;height:calc(100vh - 28px - env(safe-area-inset-bottom,0px))} |
| 831 | #layout>tbody>tr>td{vertical-align:top;padding:0;position:relative;height:100%} |
| 832 | #railbox,#content,#panel{position:absolute;top:0;right:0;bottom:0;left:0;overflow:auto;overscroll-behavior:contain} |
| 833 | @@ -368,26 +368,63 @@ room-action{display:block}#panel room-action label{margin-top:6px}#panel room-ac |
| 834 | #members li[data-agent] small{color:var(--bark)} |
| 835 | #panel #members li{font:12px var(--mono);line-height:22px} |
| 836 | |
| 837 | -/* Phones: a top bar with a menu, the rail slides in, the panel stacks under the content. */ |
| 838 | +/* Phones keep conversation identity and navigation in a safe-area-aware header. |
| 839 | + Native details controls expose the rail and contextual panel without scripts. */ |
| 840 | @media(max-width:720px){ |
| 841 | -html,body{overflow:auto} |
| 842 | -#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} |
| 843 | -#topbar b{color:var(--ink);font-weight:500} |
| 844 | -#nav-menu{display:inline-block;margin-right:10px} |
| 845 | -#menu{display:block;list-style:none;border:1px solid var(--line);padding:2px 10px;line-height:1.4;cursor:pointer} |
| 846 | -#menu::-webkit-details-marker{display:none} |
| 847 | +:root{--mobile-header-height:calc(64px + env(safe-area-inset-top,0px));--mobile-drawer-width:min(320px,100vw)} |
| 848 | +html:has(signer-connection:not([hidden])){--mobile-header-height:calc(108px + env(safe-area-inset-top,0px))} |
| 849 | +html,body{overflow:auto;scroll-padding-top:var(--mobile-header-height)} |
| 850 | +#topbar{display:block;position:fixed;top:0;left:0;right:0;height:var(--mobile-header-height);background:var(--rail);border-bottom:1px solid var(--line);color:var(--ink);z-index:5;box-sizing:border-box} |
| 851 | +signer-connection{display:block;position:absolute;top:calc(64px + env(safe-area-inset-top,0px));left:0;right:0;height:44px;padding:0 12px;box-sizing:border-box;border-top:1px solid var(--line);background:var(--soft);font:12px/44px var(--sans);color:var(--ink)} |
| 852 | +signer-connection[hidden]{display:none} |
| 853 | +signer-connection>span{display:block;padding-right:92px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis} |
| 854 | +signer-connection>button{position:absolute;right:8px;top:0;min-width:88px;height:44px;border:0;background:transparent;color:var(--accent);font:600 12px var(--sans)} |
| 855 | +body:has(signer-connection:not([hidden])) #session-status{display:none} |
| 856 | +#mobile-back,#menu{display:block;box-sizing:border-box;width:44px;height:44px;padding:10px;border:0;border-radius:10px;background:transparent;color:var(--ink);line-height:0;cursor:pointer;text-align:center} |
| 857 | +#mobile-back{position:absolute;top:calc(10px + env(safe-area-inset-top,0px));left:calc(6px + env(safe-area-inset-left,0px))} |
| 858 | +#mobile-back:hover,#menu:hover,#context-toggle:hover{background:var(--hover);text-decoration:none} |
| 859 | +#nav-menu{position:absolute;right:calc(8px + env(safe-area-inset-right,0px));top:calc(10px + env(safe-area-inset-top,0px));margin:0} |
| 860 | +#menu,#context-toggle{list-style:none} |
| 861 | +#menu::-webkit-details-marker,#context-toggle::-webkit-details-marker{display:none} |
| 862 | +#menu [data-close-icon],#nav-menu[open] [data-menu-icon]{display:none} |
| 863 | +#nav-menu[open] [data-close-icon]{display:inline} |
| 864 | +#context-menu{position:absolute;left:calc(14px + env(safe-area-inset-left,0px));right:calc(62px + env(safe-area-inset-right,0px));top:calc(4px + env(safe-area-inset-top,0px));min-width:0} |
| 865 | +#topbar:has(#mobile-back) #context-menu{left:calc(54px + env(safe-area-inset-left,0px))} |
| 866 | +#context-toggle{position:relative;display:block;box-sizing:border-box;height:56px;padding:9px 20px 9px 0;border-radius:8px;cursor:pointer;min-width:0} |
| 867 | +#context-toggle:has(room-avatar),#context-toggle:has(nostr-avatar){padding-left:44px} |
| 868 | +#context-toggle>room-avatar,#context-toggle>nostr-avatar{position:absolute;left:0;top:10px;width:36px;height:36px} |
| 869 | +#mobile-heading,#mobile-title,#mobile-context{display:block;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis} |
| 870 | +#mobile-title{font:600 16px/21px var(--sans);color:var(--ink)} |
| 871 | +#mobile-context{font:12px/17px var(--sans);color:var(--muted)} |
| 872 | +#context-toggle>[data-chevron]{position:absolute;right:0;top:20px;color:var(--muted)} |
| 873 | +#context-menu[open] #context-toggle>[data-chevron]{transform:rotate(180deg)} |
| 874 | +#topbar a:focus-visible,#topbar summary:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} |
| 875 | #layout,#layout>tbody,#layout>tbody>tr,#layout>tbody>tr>td{display:block;width:auto;height:auto} |
| 876 | -#layout{padding:40px 0 28px} |
| 877 | +#layout{padding:var(--mobile-header-height) 0 env(safe-area-inset-bottom,0px)} |
| 878 | #railbox,#content,#panel{position:static;overflow:visible} |
| 879 | -#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:3;overflow:hidden auto} |
| 880 | +#layout>tbody>tr>td#rail{position:fixed;top:var(--mobile-header-height);bottom:calc(96px + env(safe-area-inset-bottom,0px));left:0;width:var(--mobile-drawer-width);height:auto;box-sizing:border-box;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:4;overflow:hidden auto} |
| 881 | #railbox{position:relative;min-height:100%} |
| 882 | +body:has(#nav-menu[open]) #main::after{content:"";position:fixed;top:var(--mobile-header-height);bottom:0;left:0;right:0;background:rgba(0,0,0,.25);z-index:3} |
| 883 | #topbar:has(#nav-menu[open]) + #layout>tbody>tr>td#rail{transform:none;visibility:visible;box-shadow:8px 0 24px rgba(0,0,0,.15)} |
| 884 | -#layout>tbody>tr>td#aside{width:auto;border-left:0;border-top:1px solid var(--line)} |
| 885 | +#layout>tbody>tr>td#aside{display:none;width:auto;border-left:0;border-top:0} |
| 886 | +body:has(#context-menu[open]) #layout>tbody>tr>td#aside{display:block;position:fixed;top:var(--mobile-header-height);bottom:0;left:0;right:0;height:auto;overflow:auto;z-index:4;background:var(--paper);padding-bottom:env(safe-area-inset-bottom,0px)} |
| 887 | #content>section{padding:14px 2.5ch 20px} |
| 888 | -#prompt{display:none}#footer td:last-child{text-align:left;white-space:nowrap} |
| 889 | -#footer{padding:0 12px}#who{font-size:11px} |
| 890 | -#install{left:auto;right:12px;transform:none} |
| 891 | -#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} |
| 892 | +#room>h1,#thread>#crumbs,#direct-chat>header>h1{display:none} |
| 893 | +#thread>h1{font-size:0;margin-bottom:8px}#thread>h1 small{font-size:12px} |
| 894 | +#mobile-actions{display:block;padding:12px 20px;border-top:1px solid var(--line)} |
| 895 | +#personal-nav{display:none} |
| 896 | +#mobile-actions>a,#mobile-actions button{display:block;box-sizing:border-box;width:100%;min-height:44px;padding:12px 0;font:13px/20px var(--sans);text-align:left;background:none;border:0;color:var(--accent)} |
| 897 | +#mobile-actions page-link{display:block} |
| 898 | +html[data-js=no] #mobile-actions page-link{display:none} |
| 899 | +#prompt{display:none} |
| 900 | +#footer{height:0;padding:0;border:0;background:none;z-index:4} |
| 901 | +#footer>table,#footer #install{display:none} |
| 902 | +body:has(#nav-menu[open]) #footer{width:var(--mobile-drawer-width);height:calc(96px + env(safe-area-inset-bottom,0px));padding:0 12px env(safe-area-inset-bottom,0px);border-top:1px solid var(--line);border-right:1px solid var(--line);background:var(--rail)} |
| 903 | +body:has(#nav-menu[open]) #footer>table{display:table;height:56px;table-layout:fixed} |
| 904 | +#footer td:first-child{display:none}#footer td:last-child{text-align:left;white-space:nowrap;overflow:hidden} |
| 905 | +#who{font-size:11px}#who>a{display:inline-block;max-width:100px;vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#who button{min-height:44px} |
| 906 | +body:has(#nav-menu[open]) #install:not([hidden]){display:block;position:static;transform:none;width:100%;height:40px;text-align:left} |
| 907 | +#navigation-status:not(:empty),#session-status:not(:empty){position:fixed;bottom:env(safe-area-inset-bottom,0px);left:0;right:0;padding:6px 12px;background:var(--rail);border-top:1px solid var(--line);white-space:normal} |
| 908 | #split,#split>tbody,#split>tbody>tr,#split>tbody>tr>td{display:block;width:auto} |
| 909 | #tree{border-right:0;border-bottom:1px solid var(--line)} |
| 910 | #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}#rows:has(td[data-state]) td:nth-child(3),#rows:has(td[data-state]) th:nth-child(3){display:none}#rows:has(td[data-state]) td:last-child{width:80px}connect-list #rows td:nth-child(2),connect-list #rows th:nth-child(2){display:none}connect-list #rows td:nth-child(3),connect-list #rows td:last-child{width:auto}connect-list #rows td:last-child{white-space:normal}connect-list #rows td:last-child button{margin:2px 0} |
| 911 | @@ -411,8 +448,7 @@ relay-lists #rows td:last-child button{margin:2px 6px 2px 0} |
| 912 | room-message[data-own]{margin-left:12px;padding-right:10px} |
| 913 | /* Phones: each wiki row reads as a title line and one muted line of facts. */ |
| 914 | #wiki-list #rows thead{display:none}#wiki-list #rows,#wiki-list #rows tbody,#wiki-list #rows tr{display:block;width:auto}#wiki-list #rows tr{padding:8px 0;border-bottom:1px solid var(--line)}#wiki-list #rows td{display:inline;padding:0;border:0;width:auto;height:auto;line-height:1.4;font:12px var(--mono);color:var(--muted)}#wiki-list #rows td:first-child{display:block;font:14px var(--sans);color:var(--ink);margin:0 0 2px}#wiki-list #rows td:first-child small{display:block;margin:2px 0 0}#wiki-list #rows td:nth-child(3){display:none}#wiki-list #rows td:nth-child(2)::after,#wiki-list #rows td:nth-child(4)::after{content:" | "} |
| 915 | -room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:8px 12px;z-index:1} |
| 916 | -body:has(#room) #layout>tbody>tr>td#aside,body:has(#thread) #layout>tbody>tr>td#aside{display:none} |
| 917 | +room-compose{bottom:0;margin:8px -2.5ch 0;padding:8px 12px calc(8px + env(safe-area-inset-bottom,0px));z-index:1} |
| 918 | } |
| 919 | |
| 920 | /* Social: a calm reading stream that keeps short notes quick and gives |
| 921 | @@ -517,7 +553,7 @@ direct-settings button{width:100%;min-height:40px;white-space:normal;font-size:1 |
| 922 | direct-thread{max-height:calc(100dvh - 340px);min-height:160px} |
| 923 | direct-thread>article{margin-right:0;padding-left:48px} |
| 924 | direct-thread>article[data-own=""]{margin-left:10px} |
| 925 | -direct-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));padding-bottom:8px} |
| 926 | +direct-compose{bottom:0;padding-bottom:calc(8px + env(safe-area-inset-bottom,0px))} |
| 927 | } |
| 928 | direct-thread article>blockquote{margin:4px 0 9px;padding:6px 10px;border-left:2px solid var(--muted);color:var(--muted);font:12px/1.5 var(--sans);white-space:pre-wrap} |
| 929 | direct-thread article>footer{margin-top:8px;font:12px var(--mono);color:var(--muted)} |
| 930 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 931 | index 425795d..bb1ae66 100644 |
| 932 | --- a/internal/webui/templates.go |
| 933 | +++ b/internal/webui/templates.go |
| 934 | @@ -303,6 +303,7 @@ func parseTemplates(a *App) (*template.Template, error) { |
| 935 | "socialMediaURL": socialMediaURL, |
| 936 | "avatarURL": avatarURL, |
| 937 | "roomAvatarURL": roomAvatarURL, |
| 938 | + "mobileHeader": mobileHeader, |
| 939 | "markdown": a.markdown, |
| 940 | "chatMarkdown": a.chatMarkdown, |
| 941 | "hasPrefix": strings.HasPrefix, |
| 942 | diff --git a/scripts/qa/signer-resume-fixture.mjs b/scripts/qa/signer-resume-fixture.mjs |
| 943 | new file mode 100644 |
| 944 | index 0000000..1546b68 |
| 945 | --- /dev/null |
| 946 | +++ b/scripts/qa/signer-resume-fixture.mjs |
| 947 | @@ -0,0 +1,159 @@ |
| 948 | +#!/usr/bin/env node |
| 949 | +// Local-only NIP-46 signer fixture for browser suspension and recovery checks. |
| 950 | +// It exposes a tiny Nostr relay and a deterministic remote signer. The control |
| 951 | +// endpoint deliberately drops transport connections so a browser can exercise |
| 952 | +// its lost-signer and reconnect states without a phone or external relay. |
| 953 | +import { createServer } from "node:http"; |
| 954 | +import { WebSocketServer, WebSocket } from "ws"; |
| 955 | +import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools/pure"; |
| 956 | +import { BunkerSigner } from "nostr-tools/nip46"; |
| 957 | +import * as nip44 from "nostr-tools/nip44"; |
| 958 | + |
| 959 | +const relayPort = Number(process.env.SIGNER_RELAY_PORT ?? 18471); |
| 960 | +const controlPort = Number(process.env.SIGNER_CONTROL_PORT ?? 18472); |
| 961 | +if (!Number.isInteger(relayPort) || !Number.isInteger(controlPort)) throw Error("ports must be integers"); |
| 962 | + |
| 963 | +const key = n => Uint8Array.from(Buffer.from(String(n).padStart(64, "0"), "hex")); |
| 964 | +// These are the public QA identities used by scripts/qa/buzz-chat-fixture.mjs. |
| 965 | +const phoneSecret = key(process.env.SIGNER_PHONE_SECRET ?? "1"); |
| 966 | +const serviceSecret = key(process.env.SIGNER_SERVICE_SECRET ?? "4"); |
| 967 | +const clientSecret = key(process.env.SIGNER_CLIENT_SECRET ?? "5"); |
| 968 | +const phonePubkey = getPublicKey(phoneSecret); |
| 969 | +const servicePubkey = getPublicKey(serviceSecret); |
| 970 | +const clientPubkey = getPublicKey(clientSecret); |
| 971 | +const relayURL = `ws://127.0.0.1:${relayPort}`; |
| 972 | +const events = []; |
| 973 | +const peers = new Map(); |
| 974 | +let offline = false; |
| 975 | + |
| 976 | +const matches = (event, filter) => { |
| 977 | + if (filter.ids && !filter.ids.includes(event.id)) return false; |
| 978 | + if (filter.kinds && !filter.kinds.includes(event.kind)) return false; |
| 979 | + if (filter.authors && !filter.authors.includes(event.pubkey)) return false; |
| 980 | + if (filter.since && event.created_at < filter.since) return false; |
| 981 | + if (filter.until && event.created_at > filter.until) return false; |
| 982 | + for (const [name, values] of Object.entries(filter)) { |
| 983 | + if (name.startsWith("#") && !event.tags.some(tag => tag[0] === name.slice(1) && values.includes(tag[1]))) return false; |
| 984 | + } |
| 985 | + return true; |
| 986 | +}; |
| 987 | +const send = (socket, value) => { |
| 988 | + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(value)); |
| 989 | +}; |
| 990 | +const broadcast = event => { |
| 991 | + events.push(event); |
| 992 | + for (const [socket, subscriptions] of peers) { |
| 993 | + for (const [id, filters] of subscriptions) { |
| 994 | + if (filters.some(filter => matches(event, filter))) send(socket, ["EVENT", id, event]); |
| 995 | + } |
| 996 | + } |
| 997 | +}; |
| 998 | + |
| 999 | +const relay = new WebSocketServer({ host: "127.0.0.1", port: relayPort }); |
| 1000 | +relay.on("connection", socket => { |
| 1001 | + if (offline) { |
| 1002 | + socket.close(1013, "fixture offline"); |
| 1003 | + return; |
| 1004 | + } |
| 1005 | + const subscriptions = new Map(); |
| 1006 | + peers.set(socket, subscriptions); |
| 1007 | + socket.on("message", raw => { |
| 1008 | + if (offline) return; |
| 1009 | + let message; |
| 1010 | + try { message = JSON.parse(raw); } catch { return; } |
| 1011 | + if (message[0] === "REQ") { |
| 1012 | + const [id, ...filters] = message.slice(1); |
| 1013 | + subscriptions.set(id, filters); |
| 1014 | + for (const event of events) if (filters.some(filter => matches(event, filter))) send(socket, ["EVENT", id, event]); |
| 1015 | + send(socket, ["EOSE", id]); |
| 1016 | + } else if (message[0] === "EVENT" && message[1]) { |
| 1017 | + const event = message[1]; |
| 1018 | + if (verifyEvent(event)) { |
| 1019 | + broadcast(event); |
| 1020 | + send(socket, ["OK", event.id, true, ""]); |
| 1021 | + } else send(socket, ["OK", event.id, false, "invalid event"]); |
| 1022 | + } else if (message[0] === "CLOSE") subscriptions.delete(message[1]); |
| 1023 | + }); |
| 1024 | + socket.on("close", () => peers.delete(socket)); |
| 1025 | +}); |
| 1026 | + |
| 1027 | +const reply = (requestEvent, request) => { |
| 1028 | + const conversation = nip44.getConversationKey(serviceSecret, requestEvent.pubkey); |
| 1029 | + const result = (() => { |
| 1030 | + switch (request.method) { |
| 1031 | + case "connect": return "ack"; |
| 1032 | + case "ping": return "pong"; |
| 1033 | + case "get_public_key": return phonePubkey; |
| 1034 | + case "sign_event": return JSON.stringify(finalizeEvent(JSON.parse(request.params[0]), phoneSecret)); |
| 1035 | + case "nip44_encrypt": return nip44.encrypt(request.params[1], nip44.getConversationKey(phoneSecret, request.params[0])); |
| 1036 | + case "nip44_decrypt": return nip44.decrypt(request.params[1], nip44.getConversationKey(phoneSecret, request.params[0])); |
| 1037 | + default: throw Error("unsupported method " + request.method); |
| 1038 | + } |
| 1039 | + })(); |
| 1040 | + broadcast(finalizeEvent({ |
| 1041 | + kind: 24133, |
| 1042 | + tags: [["p", requestEvent.pubkey]], |
| 1043 | + content: nip44.encrypt(JSON.stringify({ id: request.id, result }), conversation), |
| 1044 | + created_at: Math.floor(Date.now() / 1000) |
| 1045 | + }, serviceSecret)); |
| 1046 | +}; |
| 1047 | + |
| 1048 | +const handled = new Set(); |
| 1049 | +const requestPoller = setInterval(() => { |
| 1050 | + if (offline) return; |
| 1051 | + for (const event of events) { |
| 1052 | + if (event.kind !== 24133 || event.pubkey !== clientPubkey || !event.tags.some(tag => tag[0] === "p" && tag[1] === servicePubkey) || handled.has(event)) continue; |
| 1053 | + handled.add(event); |
| 1054 | + try { |
| 1055 | + const conversation = nip44.getConversationKey(serviceSecret, event.pubkey); |
| 1056 | + reply(event, JSON.parse(nip44.decrypt(event.content, conversation))); |
| 1057 | + } catch (error) { console.error("signer request failed:", error.message); } |
| 1058 | + } |
| 1059 | +}, 25); |
| 1060 | + |
| 1061 | +const control = createServer((request, response) => { |
| 1062 | + if (request.method !== "POST" || !["/offline", "/online"].includes(request.url)) { |
| 1063 | + response.writeHead(404, { "content-type": "text/plain" }); |
| 1064 | + response.end("use POST /offline or POST /online\n"); |
| 1065 | + return; |
| 1066 | + } |
| 1067 | + offline = request.url === "/offline"; |
| 1068 | + if (offline) for (const socket of peers.keys()) socket.close(1013, "fixture offline"); |
| 1069 | + response.writeHead(200, { "content-type": "application/json" }); |
| 1070 | + response.end(JSON.stringify({ offline })); |
| 1071 | +}); |
| 1072 | + |
| 1073 | +const clientPointer = { pubkey: servicePubkey, relays: [relayURL] }; |
| 1074 | +const runSelfTest = async () => { |
| 1075 | + const signer = BunkerSigner.fromBunker(clientSecret, clientPointer); |
| 1076 | + await signer.connect({ name: "tinyrelay signer resume fixture" }); |
| 1077 | + if ((await signer.getPublicKey()) !== phonePubkey) throw Error("unexpected signer identity"); |
| 1078 | + const signed = await signer.signEvent({ kind: 1, content: "local signer fixture", tags: [], created_at: 1 }); |
| 1079 | + if (!verifyEvent(signed) || signed.pubkey !== phonePubkey) throw Error("signer returned invalid event"); |
| 1080 | + await signer.close(); |
| 1081 | + signer.pool.destroy(); |
| 1082 | + console.log("self-test: connect, get_public_key and sign_event passed"); |
| 1083 | +}; |
| 1084 | + |
| 1085 | +control.listen(controlPort, "127.0.0.1", async () => { |
| 1086 | + console.log(JSON.stringify({ |
| 1087 | + relay: relayURL, |
| 1088 | + control: `http://127.0.0.1:${controlPort}`, |
| 1089 | + bunker: `bunker://${servicePubkey}?relay=${encodeURIComponent(relayURL)}`, |
| 1090 | + phonePubkey, |
| 1091 | + servicePubkey, |
| 1092 | + clientPubkey, |
| 1093 | + localOnly: true, |
| 1094 | + controls: ["POST /offline", "POST /online"] |
| 1095 | + }, null, 2)); |
| 1096 | + try { await runSelfTest(); } catch (error) { console.error("self-test failed:", error); process.exitCode = 1; } |
| 1097 | +}); |
| 1098 | + |
| 1099 | +const stop = () => { |
| 1100 | + clearInterval(requestPoller); |
| 1101 | + for (const socket of peers.keys()) socket.terminate(); |
| 1102 | + control.close(); |
| 1103 | + relay.close(); |
| 1104 | +}; |
| 1105 | +process.once("SIGINT", stop); |
| 1106 | +process.once("SIGTERM", stop); |
| 1107 | diff --git a/scripts/shell-navigation.test.mjs b/scripts/shell-navigation.test.mjs |
| 1108 | new file mode 100644 |
| 1109 | index 0000000..77b46fb |
| 1110 | --- /dev/null |
| 1111 | +++ b/scripts/shell-navigation.test.mjs |
| 1112 | @@ -0,0 +1,90 @@ |
| 1113 | +// Navigation keeps in-place refreshes comfortable while making a new route |
| 1114 | +// start at a predictable position on small screens. |
| 1115 | +import test from "node:test"; |
| 1116 | +import assert from "node:assert/strict"; |
| 1117 | +import fs from "node:fs"; |
| 1118 | +import vm from "node:vm"; |
| 1119 | + |
| 1120 | +const source = fs.readFileSync("internal/webui/components.js", "utf8"); |
| 1121 | +const start = source.indexOf(" const swapShell = (text, url, push) => {"); |
| 1122 | +const end = source.indexOf(" const load = async (url, push) => {", start); |
| 1123 | +const swapSource = source.slice(start, end); |
| 1124 | + |
| 1125 | +function setup({url = "https://relay.test/rooms/general", active = "content", contentScroll = 0, pageScroll = 0} = {}) { |
| 1126 | + const calls = {scroll: [], history: [], focus: []}; |
| 1127 | + const elements = { |
| 1128 | + "#topbar": {outerHTML: "<header id=topbar></header>"}, |
| 1129 | + "#railbox": {outerHTML: "<aside id=railbox></aside>"}, |
| 1130 | + "#content": {outerHTML: "<main id=content></main>", scrollTop: contentScroll, focus(options) { calls.focus.push({id: "content", options}); }}, |
| 1131 | + "#panel": {outerHTML: "<aside id=panel></aside>"}, |
| 1132 | + "#footer": {outerHTML: "<footer id=footer></footer>"} |
| 1133 | + }; |
| 1134 | + const nav = {open: false, removeAttribute(name) { if (name === "open") this.open = false; }}; |
| 1135 | + const context = {open: false, removeAttribute(name) { if (name === "open") this.open = false; }}; |
| 1136 | + const draft = {id: "draft", focus(options) { calls.focus.push({id: this.id, options}); }}; |
| 1137 | + const document = { |
| 1138 | + activeElement: active === "draft" ? draft : elements["#content"], |
| 1139 | + title: "old", |
| 1140 | + querySelector(selector) { return elements[selector] || null; }, |
| 1141 | + getElementById(id) { return id === "nav-menu" ? nav : id === "context-menu" ? context : id === "content" ? elements["#content"] : id === "draft" ? draft : null; }, |
| 1142 | + dispatchEvent() {} |
| 1143 | + }; |
| 1144 | + const parsed = { |
| 1145 | + title: "new", |
| 1146 | + querySelector(selector) { return elements[selector] || null; } |
| 1147 | + }; |
| 1148 | + const sandbox = { |
| 1149 | + document, |
| 1150 | + DOMParser: class { parseFromString() { return parsed; } }, |
| 1151 | + CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init.detail; } }, |
| 1152 | + history: {pushState(_state, _title, href) { calls.history.push(href); }}, |
| 1153 | + window: {scrollY: pageScroll, scrollTo(...args) { calls.scroll.push(args); }}, |
| 1154 | + location: {href: url}, |
| 1155 | + morph() {}, |
| 1156 | + navTargets: ["#topbar", "#railbox", "#content", "#panel", "#footer"], |
| 1157 | + navigationSerial: 0, |
| 1158 | + closeStreams() {}, |
| 1159 | + repairComponents() {} |
| 1160 | + }; |
| 1161 | + // Keep the production closure intact, while supplying only its DOM shell |
| 1162 | + // dependencies. The returned function is the implementation under test. |
| 1163 | + const swap = vm.runInNewContext(`(() => { let renderedURL = ${JSON.stringify(url)}; const navTargets = ${JSON.stringify(sandbox.navTargets)}; let navigationSerial = 0; const closeStreams = () => {}; const repairComponents = () => {}; const ensureModules = () => {}; const decorate = () => {}; const decorateForms = () => {}; ${swapSource.replace(" const swapShell", "const swapShell")} return swapShell; })()`, sandbox); |
| 1164 | + nav.open = true; |
| 1165 | + context.open = true; |
| 1166 | + return {swap, nav, context, content: elements["#content"], document, calls}; |
| 1167 | +} |
| 1168 | + |
| 1169 | +test("a changed route resets both menu drawers and scroll", () => { |
| 1170 | + const s = setup({contentScroll: 420, pageScroll: 880}); |
| 1171 | + s.swap("<html></html>", new URL("https://relay.test/rooms/general/thread/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), true); |
| 1172 | + assert.equal(s.nav.open, false); |
| 1173 | + assert.equal(s.context.open, false); |
| 1174 | + assert.equal(s.content.scrollTop, 0); |
| 1175 | + assert.deepEqual(s.calls.scroll, [[0, 0]]); |
| 1176 | + assert.equal(s.calls.focus.length, 1); |
| 1177 | + assert.equal(s.calls.focus[0].id, "content"); |
| 1178 | + assert.equal(s.calls.focus[0].options.preventScroll, true); |
| 1179 | + assert.equal(s.calls.history.length, 1); |
| 1180 | +}); |
| 1181 | + |
| 1182 | +test("an in-place refresh preserves scroll and the active draft", () => { |
| 1183 | + const s = setup({active: "draft", contentScroll: 315, pageScroll: 640}); |
| 1184 | + s.nav.open = false; |
| 1185 | + s.context.open = false; |
| 1186 | + s.swap("<html></html>", new URL("https://relay.test/rooms/general"), false); |
| 1187 | + assert.equal(s.content.scrollTop, 315); |
| 1188 | + assert.deepEqual(s.calls.scroll, []); |
| 1189 | + assert.equal(s.calls.focus.length, 1); |
| 1190 | + assert.equal(s.calls.focus[0].id, "draft"); |
| 1191 | + assert.equal(s.calls.focus[0].options.preventScroll, true); |
| 1192 | + assert.equal(s.nav.open, false); |
| 1193 | + assert.equal(s.context.open, false); |
| 1194 | +}); |
| 1195 | + |
| 1196 | +test("a browser-back route change resets position without pushing history", () => { |
| 1197 | + const s = setup({url: "https://relay.test/rooms/general/thread/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", contentScroll: 190, pageScroll: 230}); |
| 1198 | + s.swap("<html></html>", new URL("https://relay.test/rooms/general"), false); |
| 1199 | + assert.equal(s.content.scrollTop, 0); |
| 1200 | + assert.deepEqual(s.calls.scroll, [[0, 0]]); |
| 1201 | + assert.deepEqual(s.calls.history, []); |
| 1202 | +}); |
| 1203 | diff --git a/scripts/signer-recovery.test.mjs b/scripts/signer-recovery.test.mjs |
| 1204 | new file mode 100644 |
| 1205 | index 0000000..0afa04a |
| 1206 | --- /dev/null |
| 1207 | +++ b/scripts/signer-recovery.test.mjs |
| 1208 | @@ -0,0 +1,178 @@ |
| 1209 | +import assert from "node:assert/strict"; |
| 1210 | +import {webcrypto} from "node:crypto"; |
| 1211 | +import {readFile} from "node:fs/promises"; |
| 1212 | +import test from "node:test"; |
| 1213 | +import vm from "node:vm"; |
| 1214 | + |
| 1215 | +const bridge = await readFile(new URL("../internal/webui/bridge.js", import.meta.url), "utf8"); |
| 1216 | +const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8"); |
| 1217 | + |
| 1218 | +class Element { |
| 1219 | + constructor(tag = "div") { this.tagName = tag.toUpperCase(); this.children = []; this.attributes = new Map(); this.hidden = false; } |
| 1220 | + setAttribute(k, v) { this.attributes.set(k, String(v)); } |
| 1221 | + getAttribute(k) { return this.attributes.get(k) ?? null; } |
| 1222 | + hasAttribute(k) { return this.attributes.has(k); } |
| 1223 | + append(...items) { this.children.push(...items); } |
| 1224 | + replaceChildren(...items) { this.children = items; } |
| 1225 | + addEventListener(name, fn) { this.events ||= {}; this.events[name] = fn; } |
| 1226 | + dispatch(name, event = {}) { return this.events?.[name]?.({...event, currentTarget: this}); } |
| 1227 | + matches() { return false; } |
| 1228 | + querySelector() { return null; } |
| 1229 | + get textContent() { return this.children.map(child => child.textContent ?? "").join(""); } |
| 1230 | + set textContent(value) { this.children = [{textContent: String(value)}]; } |
| 1231 | +} |
| 1232 | + |
| 1233 | +async function page({actor = "a".repeat(64), native = null, stored = false, signerFactory = () => null, now = Date.now()} = {}) { |
| 1234 | + const nodes = new Map(); |
| 1235 | + for (const id of ["session-status", "session-login", "session-logout", "signer-status", "bunker", "bunker-url"]) nodes.set(id, new Element(id)); |
| 1236 | + const connection = new Element("signer-connection"); connection.setAttribute("actor", actor); nodes.set("signer-connection", connection); |
| 1237 | + const docEvents = new Map(), winEvents = new Map(), session = new Map(), local = new Map(), requests = []; |
| 1238 | + const on = (map, name, fn) => map.set(name, [...(map.get(name) || []), fn]); |
| 1239 | + const fire = (map, name, event = {}) => Promise.all((map.get(name) || []).map(fn => fn(event))); |
| 1240 | + const nativeObject = native; |
| 1241 | + const signer = nativeObject; |
| 1242 | + let clock = now; |
| 1243 | + const sandbox = { |
| 1244 | + tiny: {}, document: { |
| 1245 | + visibilityState: "visible", referrer: "", documentElement: {dataset: {}}, |
| 1246 | + getElementById: id => nodes.get(id), querySelector: selector => selector === "signer-connection" ? nodes.get("signer-connection") : null, |
| 1247 | + querySelectorAll: () => [], createElement: tag => new Element(tag), |
| 1248 | + addEventListener: on.bind(null, docEvents), dispatchEvent: event => fire(docEvents, event.type, event) |
| 1249 | + }, |
| 1250 | + crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer, Date: class extends Date { static now() { return clock; } }, setTimeout, clearTimeout, |
| 1251 | + btoa: value => Buffer.from(value).toString("base64"), |
| 1252 | + location: {pathname: "/r/work/rooms/general", href: "https://tiny.example/r/work/rooms/general?x=1#part", origin: "https://tiny.example", assign(url) { this.assigned = url; }, replace(url) { this.replaced = url; }, reload() { this.reloaded = true; }}, |
| 1253 | + navigator: {clipboard: {writeText: async () => {}}}, getComputedStyle: () => ({getPropertyValue: () => ""}), |
| 1254 | + matchMedia: () => ({matches: false, addEventListener() {}}), addEventListener: on.bind(null, winEvents), |
| 1255 | + localStorage: {getItem: k => local.get(k) ?? null, setItem: (k, v) => local.set(k, String(v)), removeItem: k => local.delete(k)}, |
| 1256 | + sessionStorage: {getItem: k => session.get(k) ?? null, setItem: (k, v) => session.set(k, String(v)), removeItem: k => session.delete(k)}, |
| 1257 | + NostrSigner: { |
| 1258 | + hexToBytes: () => new Uint8Array(32), bytesToHex: () => "key", generateSecretKey: () => new Uint8Array(32), getPublicKey: () => "b".repeat(64), |
| 1259 | + parseBunkerInput: async () => ({pubkey: "c".repeat(64), relays: ["wss://relay.test"]}), BunkerSigner: {fromBunker: signerFactory} |
| 1260 | + }, |
| 1261 | + fetch: async (url, options) => { requests.push({url, options}); return new Response("{}"); } |
| 1262 | + }; |
| 1263 | + sandbox.window = sandbox; |
| 1264 | + if (signer) sandbox.nostr = signer; |
| 1265 | + vm.runInNewContext(shared + bridge, sandbox); |
| 1266 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 1267 | + if (stored) { session.set("tiny.bunker/r/work.uri", "bunker://saved"); session.set("tiny.bunker/r/work.sk", "key"); session.set("tiny.bunker/r/work.identity", "c".repeat(64)); session.set("tiny.bunker/r/work.remote", JSON.stringify({pubkey: "c".repeat(64), relays: ["wss://relay.test"]})); } |
| 1268 | + return {sandbox, nodes, docEvents, winEvents, session, local, requests, setClock: value => { clock = value; }, fire: (map, name, event) => fire(map, name, event)}; |
| 1269 | +} |
| 1270 | + |
| 1271 | +const saved = (session, actor = "c".repeat(64)) => { |
| 1272 | + session.set("tiny.bunker/r/work.uri", "bunker://saved"); session.set("tiny.bunker/r/work.sk", "key"); session.set("tiny.bunker/r/work.identity", actor); session.set("tiny.bunker/r/work.remote", JSON.stringify({pubkey: actor, relays: ["wss://relay.test"]})); |
| 1273 | +}; |
| 1274 | + |
| 1275 | +test("recovery checks a late native signer without replacing window.nostr", async () => { |
| 1276 | + const native = {getPublicKey: async () => "a".repeat(64), signEvent: async event => event}; |
| 1277 | + const {sandbox, winEvents} = await page(); |
| 1278 | + sandbox.nostr = native; |
| 1279 | + await sandbox.tiny.reconnectSigner(); |
| 1280 | + assert.equal(sandbox.nostr, native); |
| 1281 | + assert.equal(sandbox.tiny.signerState(), "ready"); |
| 1282 | + assert.equal(sandbox.location.replaced, undefined); |
| 1283 | + assert.ok(winEvents.get("pageshow")); |
| 1284 | +}); |
| 1285 | + |
| 1286 | +test("missing remote credentials gives a connect path and navigation restores it", async () => { |
| 1287 | + const {sandbox, nodes, docEvents} = await page(); |
| 1288 | + await sandbox.tiny.reconnectSigner({force: true}).catch(() => {}); |
| 1289 | + assert.equal(nodes.get("signer-connection").hidden, false); |
| 1290 | + assert.equal(nodes.get("signer-connection").children[1].textContent, "Connect signer"); |
| 1291 | + nodes.get("signer-connection").children[1].dispatch("click"); |
| 1292 | + assert.match(sandbox.location.assigned, /signin\?connect=1&next=/); |
| 1293 | + const replacement = new Element("signer-connection"); replacement.setAttribute("actor", "a".repeat(64)); nodes.set("signer-connection", replacement); |
| 1294 | + await Promise.all((docEvents.get("tiny:navigation") || []).map(fn => fn())); |
| 1295 | + assert.equal(replacement.hidden, false); |
| 1296 | +}); |
| 1297 | + |
| 1298 | +test("remote ping keeps the same signer and failed replacement closes both pools", async () => { |
| 1299 | + let oldClose = 0, oldDestroy = 0, candidateClose = 0, candidateDestroy = 0, ping = 0; |
| 1300 | + const pool = () => ({destroy: () => { oldDestroy++; }}); |
| 1301 | + const old = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, pool: pool(), connect: async () => {}, ping: async () => { ping++; }, close: () => { oldClose++; }, getPublicKey: async () => "c".repeat(64), signEvent: async e => e}; |
| 1302 | + const candidate = {bp: old.bp, pool: {destroy: () => { candidateDestroy++; }}, connect: async () => {}, close: () => { candidateClose++; }, getPublicKey: async () => "c".repeat(64)}; |
| 1303 | + const failedCandidate = {...candidate, connect: async () => { throw Error("offline"); }}; |
| 1304 | + const {sandbox, session, setClock} = await page({actor: "c".repeat(64), signerFactory: () => old, now: 1000}); |
| 1305 | + saved(session); |
| 1306 | + await sandbox.tiny.reconnectSigner({force: true}); |
| 1307 | + const first = sandbox.tinySigner; |
| 1308 | + setClock(20000); |
| 1309 | + await sandbox.tiny.reconnectSigner(); |
| 1310 | + assert.equal(sandbox.tinySigner, first); assert.equal(ping, 1); |
| 1311 | + // Force a replacement after ping fails. |
| 1312 | + old.ping = async () => { throw Error("offline"); }; |
| 1313 | + sandbox.NostrSigner.BunkerSigner.fromBunker = () => failedCandidate; |
| 1314 | + await assert.rejects(sandbox.tiny.reconnectSigner({force: true}), /offline/); |
| 1315 | + assert.equal(sandbox.tiny.signerState(), "lost"); |
| 1316 | + assert.ok(oldClose > 0 && oldDestroy > 0 && candidateClose > 0 && candidateDestroy > 0); |
| 1317 | + assert.equal(session.get("tiny.bunker/r/work.sk"), "key"); |
| 1318 | +}); |
| 1319 | + |
| 1320 | +test("signer denial stays ready while transport failure reports lost", async () => { |
| 1321 | + let reject = false; |
| 1322 | + const remote = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, getPublicKey: async () => "c".repeat(64), signEvent: async () => { throw Error(reject ? "user rejected" : "connection closed"); }}; |
| 1323 | + const {sandbox, session} = await page({actor: "c".repeat(64), signerFactory: () => remote}); saved(session); |
| 1324 | + await sandbox.tiny.reconnectSigner({force: true}); |
| 1325 | + await assert.rejects(sandbox.tinySigner.signEvent({}), /connection closed/); assert.equal(sandbox.tiny.signerState(), "lost"); |
| 1326 | + reject = true; |
| 1327 | + await sandbox.tiny.reconnectSigner({force: true}); |
| 1328 | + await assert.rejects(sandbox.tinySigner.signEvent({}), /user rejected/); assert.equal(sandbox.tiny.signerState(), "ready"); |
| 1329 | +}); |
| 1330 | + |
| 1331 | +test("a brief background suspension forces a fresh remote health check", async () => { |
| 1332 | + let pings = 0; |
| 1333 | + const remote = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, ping: async () => { pings++; }, getPublicKey: async () => "c".repeat(64)}; |
| 1334 | + const {sandbox, session, fire, docEvents, setClock} = await page({actor: "c".repeat(64), signerFactory: () => remote, now: 1000}); saved(session); |
| 1335 | + await sandbox.tiny.reconnectSigner({force: true}); |
| 1336 | + setClock(2000); |
| 1337 | + sandbox.document.visibilityState = "hidden"; |
| 1338 | + await fire(docEvents, "visibilitychange"); |
| 1339 | + sandbox.document.visibilityState = "visible"; |
| 1340 | + await fire(docEvents, "visibilitychange"); |
| 1341 | + await sandbox.tiny.reconnectSigner(); |
| 1342 | + assert.equal(sandbox.tiny.signerState(), "ready"); |
| 1343 | + assert.equal(pings, 1); |
| 1344 | +}); |
| 1345 | + |
| 1346 | +test("successful remote replacement closes the old signer and changes the facade", async () => { |
| 1347 | + let oldClose = 0, oldDestroy = 0; |
| 1348 | + const old = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, pool: {destroy: () => { oldDestroy++; }}, close: () => { oldClose++; }, getPublicKey: async () => "c".repeat(64), ping: async () => { throw Error("offline"); }}; |
| 1349 | + const next = {bp: old.bp, connect: async () => {}, pool: {destroy() {}}, close() {}, getPublicKey: async () => "c".repeat(64)}; |
| 1350 | + const {sandbox, session} = await page({actor: "c".repeat(64), signerFactory: () => old}); saved(session); |
| 1351 | + await sandbox.tiny.reconnectSigner({force: true}); const previous = sandbox.tinySigner; |
| 1352 | + sandbox.NostrSigner.BunkerSigner.fromBunker = () => next; |
| 1353 | + await sandbox.tiny.reconnectSigner({force: true}); |
| 1354 | + assert.notEqual(sandbox.tinySigner, previous); assert.equal(sandbox.tiny.signerState(), "ready"); |
| 1355 | + assert.ok(oldClose > 0 && oldDestroy > 0); |
| 1356 | +}); |
| 1357 | + |
| 1358 | +test("an old pending signer operation cannot clear a replacement", async () => { |
| 1359 | + let release; |
| 1360 | + const old = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, close() {}, pool: {destroy() {}}, getPublicKey: async () => "c".repeat(64), signEvent: async () => new Promise(resolve => { release = resolve; }), ping: async () => { throw Error("offline"); }}; |
| 1361 | + const next = {bp: old.bp, connect: async () => {}, close() {}, pool: {destroy() {}}, getPublicKey: async () => "c".repeat(64)}; |
| 1362 | + const {sandbox, session} = await page({actor: "c".repeat(64), signerFactory: () => old}); saved(session); |
| 1363 | + await sandbox.tiny.reconnectSigner({force: true}); |
| 1364 | + const pending = sandbox.tinySigner.signEvent({}); |
| 1365 | + sandbox.NostrSigner.BunkerSigner.fromBunker = () => next; |
| 1366 | + await sandbox.tiny.reconnectSigner({force: true}); |
| 1367 | + await assert.rejects(pending, /connection changed/); |
| 1368 | + release({}); |
| 1369 | + assert.equal(sandbox.tiny.signerState(), "ready"); |
| 1370 | +}); |
| 1371 | + |
| 1372 | +test("native signer identity mismatch reports loss", async () => { |
| 1373 | + const native = {getPublicKey: async () => "b".repeat(64), signEvent: async event => event}; |
| 1374 | + const {sandbox} = await page({actor: "a".repeat(64), native}); |
| 1375 | + sandbox.nostr = native; |
| 1376 | + await assert.rejects(sandbox.tiny.reconnectSigner({force: true}), /signed-in account/); |
| 1377 | + assert.equal(sandbox.tiny.signerState(), "lost"); assert.equal(sandbox.nostr, native); |
| 1378 | +}); |
| 1379 | + |
| 1380 | +test("NIP-44 transport failure reports loss", async () => { |
| 1381 | + const remote = {bp: {pubkey: "c".repeat(64), relays: ["wss://relay.test"]}, connect: async () => {}, getPublicKey: async () => "c".repeat(64), nip44Decrypt: async () => { throw Error("connection closed"); }}; |
| 1382 | + const {sandbox, session} = await page({actor: "c".repeat(64), signerFactory: () => remote}); saved(session); |
| 1383 | + await sandbox.tiny.reconnectSigner({force: true}); |
| 1384 | + await assert.rejects(sandbox.tinySigner.nip44Decrypt("peer", "ciphertext"), /connection closed/); |
| 1385 | + assert.equal(sandbox.tiny.signerState(), "lost"); |
| 1386 | +}); |
| 1387 | diff --git a/scripts/signer.test.mjs b/scripts/signer.test.mjs |
| 1388 | index d7a47b2..35db69b 100644 |
| 1389 | --- a/scripts/signer.test.mjs |
| 1390 | +++ b/scripts/signer.test.mjs |
| 1391 | @@ -7,7 +7,7 @@ import vm from "node:vm"; |
| 1392 | const bridge = await readFile(new URL("../internal/webui/bridge.js", import.meta.url), "utf8"); |
| 1393 | const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8"); |
| 1394 | |
| 1395 | -async function page({signedIn = false, bunkerResult = null, href = "https://tiny.example/r/work/signin", referrer = ""} = {}) { |
| 1396 | +async function page({signedIn = false, bunkerResult = null, href = "https://tiny.example/r/work/signin", referrer = "", connect = async () => {}} = {}) { |
| 1397 | const elements = new Map(); |
| 1398 | for (const id of ["session-login", "nostrconnect", "nostrconnect-open", "signer-qr", "signer-status", "bunker", "bunker-url", ...(signedIn ? ["session-logout"] : [])]) { |
| 1399 | elements.set(id, {hidden: true, value: "", addEventListener(event, handler) { this[event] = handler; }, setAttribute() {}, removeAttribute() {}}); |
| 1400 | @@ -15,11 +15,11 @@ async function page({signedIn = false, bunkerResult = null, href = "https://tiny |
| 1401 | const saved = new Map(); |
| 1402 | const documentEvents = new Map(); |
| 1403 | const requests = []; |
| 1404 | - const signer = {bp: {pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]}, connect: async () => {}, getPublicKey: async () => "a".repeat(64), signEvent: async event => ({...event, pubkey: "a".repeat(64), sig: "test"})}; |
| 1405 | + const signer = {bp: {pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]}, connect, getPublicKey: async () => "a".repeat(64), signEvent: async event => ({...event, pubkey: "a".repeat(64), sig: "test"})}; |
| 1406 | const sandbox = { |
| 1407 | tiny: {}, |
| 1408 | document: {getElementById: id => elements.get(id), querySelector: () => null, referrer, addEventListener(name, handler) {documentEvents.set(name, handler);}, dispatchEvent() {}, documentElement: {dataset: {}}}, |
| 1409 | - crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer, |
| 1410 | + crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer, setTimeout, clearTimeout, |
| 1411 | btoa: value => Buffer.from(value).toString("base64"), |
| 1412 | location: {pathname: new URL(href).pathname, href, origin: new URL(href).origin, assign(url) { sandbox.opened = url; }, replace(url) { sandbox.opened = url; sandbox.replaced = true; }, reload() { sandbox.reloaded = true; }}, |
| 1413 | navigator: {clipboard: {writeText: async () => {} }}, |
| 1414 | @@ -61,11 +61,13 @@ test("invalid bunker input reports a useful error without connecting", async () |
| 1415 | |
| 1416 | test("NIP-07 signer can sign in directly", async () => { |
| 1417 | const {elements, requests, sandbox} = await page(); |
| 1418 | + assert.equal(sandbox.tiny.signerState(), "disconnected"); |
| 1419 | sandbox.nostr = {signEvent: async event => ({...event, pubkey: "c".repeat(64), sig: "extension"})}; |
| 1420 | await elements.get("session-login").click({currentTarget: elements.get("session-login")}); |
| 1421 | assert.equal(requests.length, 1); |
| 1422 | assert.equal(requests[0].url, "/r/work/session"); |
| 1423 | assert.match(elements.get("signer-status").textContent, /Signing in|Signed in/); |
| 1424 | + assert.equal(sandbox.tiny.signerState(), "ready"); |
| 1425 | assert.equal(sandbox.opened, "/r/work/"); |
| 1426 | }); |
| 1427 | |
| 1428 | @@ -125,3 +127,25 @@ test("sign-in links carry private fragments only in the browser fragment", async |
| 1429 | assert.equal(new URLSearchParams(login.hash.slice(1)).get("return"), target); |
| 1430 | assert.equal(attrs.get("fx-action"), link.href); |
| 1431 | }); |
| 1432 | + |
| 1433 | +test("a suspended remembered signer can reconnect without revoking the web session", async () => { |
| 1434 | + const {saved, sandbox} = await page(); |
| 1435 | + saved.set("tiny.bunker/r/work.uri", "bunker://remembered"); |
| 1436 | + saved.set("tiny.bunker/r/work.sk", "test-key"); |
| 1437 | + saved.set("tiny.bunker/r/work.identity", "a".repeat(64)); |
| 1438 | + saved.set("tiny.bunker/r/work.remote", JSON.stringify({pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]})); |
| 1439 | + await sandbox.tiny.reconnectSigner({force: true}); |
| 1440 | + assert.equal(sandbox.tiny.signerState(), "ready"); |
| 1441 | + assert.equal(saved.get("tiny.bunker/r/work.identity"), "a".repeat(64)); |
| 1442 | +}); |
| 1443 | + |
| 1444 | +test("failed signer recovery reports loss and preserves reconnect credentials", async () => { |
| 1445 | + const {saved, sandbox} = await page({connect: async () => { throw Error("suspended"); }}); |
| 1446 | + saved.set("tiny.bunker/r/work.uri", "bunker://remembered"); |
| 1447 | + saved.set("tiny.bunker/r/work.sk", "test-key"); |
| 1448 | + saved.set("tiny.bunker/r/work.identity", "a".repeat(64)); |
| 1449 | + saved.set("tiny.bunker/r/work.remote", JSON.stringify({pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]})); |
| 1450 | + await assert.rejects(sandbox.tiny.reconnectSigner({force: true}), /suspended/); |
| 1451 | + assert.equal(sandbox.tiny.signerState(), "lost"); |
| 1452 | + assert.equal(saved.get("tiny.bunker/r/work.sk"), "test-key"); |
| 1453 | +}); |