| 1 | // Signer bridge: session sign-in and sign-out, NIP-07 and NIP-46 signers, |
| 2 | // Nostr Connect and the NIP-98 signed fetch used by authenticated calls. |
| 3 | // It exposes tiny.localPath, tiny.authorization and tiny.signedFetch, and |
| 4 | // announces a connected signer with tiny:signer. |
| 5 | (() => { |
| 6 | "use strict" ; |
| 7 | |
| 8 | const root = (location.pathname.match(/^\/r\/[^/]+/) || ["" ])[0]; |
| 9 | const localPath = path => path && path.startsWith("/" ) ? (path === root || path.startsWith(root + "/" ) ? path : root + path) : path; |
| 10 | const storagePrefix = "tiny.bunker" + (root || "/" ); |
| 11 | const storage = { |
| 12 | uri: storagePrefix + ".uri" , |
| 13 | key: storagePrefix + ".sk" , |
| 14 | pubkey: storagePrefix + ".identity" , |
| 15 | remote: storagePrefix + ".remote" |
| 16 | }; |
| 17 | const say = text => { |
| 18 | const status = document.getElementById("signer-status" ) || document.getElementById("session-status" ); |
| 19 | if (status) status.textContent = text; |
| 20 | }; |
| 21 | const errorText = err => err instanceof Error ? err.message : String(err); |
| 22 | const {hex, sha256} = window.tiny.util; |
| 23 | const sha256hex = async bytes => hex(await sha256(bytes)); |
| 24 | const randomHex = () => { const b = new Uint8Array(32); crypto.getRandomValues(b); return hex(b); }; |
| 25 | const toBytes = body => body instanceof ArrayBuffer ? new Uint8Array(body) : body instanceof Uint8Array ? body : new TextEncoder().encode(body || "" ); |
| 26 | const validRelay = value => { |
| 27 | try { const parsed = new URL(value); return (parsed.protocol === "ws:" || parsed.protocol === "wss:" ) && parsed.hostname !== "" ; } catch { return false; } |
| 28 | }; |
| 29 | const validBunker = pointer => pointer && /^[0-9a-f]{64}$/i.test(pointer.pubkey || "" ) && Array.isArray(pointer.relays) && pointer.relays.length > 0 && pointer.relays.every(validRelay); |
| 30 | |
| 31 | // authorization builds a NIP-98 header for one request body. The body is |
| 32 | // hashed exactly once and the same bytes must be sent. |
| 33 | async function authorization(url, method, bytes) { |
| 34 | if (!window.nostr?.signEvent) throw Error("Connect a Nostr signer first." ); |
| 35 | const event = await window.nostr.signEvent({ |
| 36 | kind: 27235, |
| 37 | created_at: Math.floor(Date.now() / 1000), |
| 38 | tags: [["u" , url], ["method" , method], ["payload" , await sha256hex(bytes)], ["nonce" , randomHex()]], |
| 39 | content: "" |
| 40 | }); |
| 41 | return "Nostr " + btoa(JSON.stringify(event)); |
| 42 | } |
| 43 | |
| 44 | async function signedFetch(path, method, body, options = {}) { |
| 45 | path = localPath(path); |
| 46 | const bytes = toBytes(body); |
| 47 | const url = new URL(path, location.href).href; |
| 48 | const request = { |
| 49 | method, |
| 50 | signal: options.signal, |
| 51 | headers: {authorization: await authorization(url, method, bytes), "content-type" : options.contentType || "application/octet-stream" } |
| 52 | }; |
| 53 | if (method !== "GET" && method !== "HEAD" ) request.body = bytes; |
| 54 | const response = await fetch(path, request); |
| 55 | if (!response.ok) throw Error(await response.text()); |
| 56 | return response; |
| 57 | } |
| 58 | |
| 59 | const signedSession = path => signedFetch(path, "POST" , "" ); |
| 60 | |
| 61 | // Shell: theme toggle, the phone menu, and the installable app worker. |
| 62 | const root_ = document.documentElement; |
| 63 | const currentTheme = () => root_.dataset.theme || (matchMedia("(prefers-color-scheme: dark)" ).matches ? "dark" : "light" ); |
| 64 | const savedTheme = localStorage.getItem("tiny.theme" ); |
| 65 | if (savedTheme) root_.dataset.theme = savedTheme; |
| 66 | // The installed app's title bar follows the theme-color meta, so keep it |
| 67 | // in step with the rail colour of whichever theme is active. |
| 68 | const paintChrome = () => { |
| 69 | const rail = getComputedStyle(root_).getPropertyValue("--rail" ).trim(); |
| 70 | if (rail) document.querySelectorAll('meta[name="theme-color"]' ).forEach(meta => { meta.content = rail; }); |
| 71 | // The manifest is fetched at install and on later update checks, so |
| 72 | // point it at the variant whose launch colours match the active theme. |
| 73 | const manifest = document.querySelector('link[rel="manifest"]' ); |
| 74 | if (manifest) manifest.href = localPath("/manifest.webmanifest" ) + (currentTheme() === "dark" ? "?theme=dark" : "" ); |
| 75 | }; |
| 76 | paintChrome(); |
| 77 | matchMedia("(prefers-color-scheme: dark)" ).addEventListener("change" , paintChrome); |
| 78 | // Browsers that can install the app announce it; the footer then offers |
| 79 | // a quiet link and nothing else changes until it is pressed. |
| 80 | let installPrompt = null; |
| 81 | const installButton = () => document.getElementById("install" ); |
| 82 | window.addEventListener("beforeinstallprompt" , event => { |
| 83 | event.preventDefault(); |
| 84 | installPrompt = event; |
| 85 | const button = installButton(); |
| 86 | if (button) button.hidden = false; |
| 87 | }); |
| 88 | window.addEventListener("appinstalled" , () => { |
| 89 | installPrompt = null; |
| 90 | const button = installButton(); |
| 91 | if (button) button.hidden = true; |
| 92 | }); |
| 93 | const setMenu = open => { |
| 94 | const menu = document.getElementById("nav-menu" ); |
| 95 | if (menu) menu.open = open; |
| 96 | }; |
| 97 | const setContext = open => { |
| 98 | const menu = document.getElementById("context-menu" ); |
| 99 | if (menu) menu.open = open; |
| 100 | }; |
| 101 | const shellBindings = new WeakSet(); |
| 102 | let shellDocumentBound = false; |
| 103 | const bindShell = () => { |
| 104 | const rail = document.getElementById("rail" ); |
| 105 | const themeButton = document.getElementById("theme" ); |
| 106 | const navMenu = document.getElementById("nav-menu" ); |
| 107 | const contextMenu = document.getElementById("context-menu" ); |
| 108 | if (rail && !shellBindings.has(rail)) { |
| 109 | shellBindings.add(rail); |
| 110 | rail.addEventListener("click" , event => { if (event.target.closest("a" )) setMenu(false); }); |
| 111 | } |
| 112 | const install = installButton(); |
| 113 | if (install && !shellBindings.has(install)) { |
| 114 | shellBindings.add(install); |
| 115 | install.hidden = !installPrompt; |
| 116 | install.addEventListener("click" , async () => { |
| 117 | if (!installPrompt) return; |
| 118 | const prompt = installPrompt; |
| 119 | installPrompt = null; |
| 120 | install.hidden = true; |
| 121 | try { await prompt.prompt(); } catch {} |
| 122 | }); |
| 123 | } |
| 124 | if (themeButton && !shellBindings.has(themeButton)) { |
| 125 | shellBindings.add(themeButton); |
| 126 | const label = () => { themeButton.textContent = currentTheme() === "dark" ? "light" : "dark" ; }; |
| 127 | label(); |
| 128 | themeButton.addEventListener("click" , () => { |
| 129 | root_.dataset.theme = currentTheme() === "dark" ? "light" : "dark" ; |
| 130 | localStorage.setItem("tiny.theme" , root_.dataset.theme); |
| 131 | label(); |
| 132 | paintChrome(); |
| 133 | }); |
| 134 | } |
| 135 | if (navMenu && !shellBindings.has(navMenu)) { |
| 136 | shellBindings.add(navMenu); |
| 137 | navMenu.addEventListener("toggle" , () => { if (navMenu.open) setContext(false); }); |
| 138 | } |
| 139 | if (contextMenu && !shellBindings.has(contextMenu)) { |
| 140 | shellBindings.add(contextMenu); |
| 141 | contextMenu.addEventListener("toggle" , () => { if (contextMenu.open) setMenu(false); }); |
| 142 | } |
| 143 | if (!shellDocumentBound) { |
| 144 | shellDocumentBound = true; |
| 145 | document.addEventListener("click" , event => { |
| 146 | const nav = document.getElementById("nav-menu" ); |
| 147 | const context = document.getElementById("context-menu" ); |
| 148 | if ((!nav?.open && !context?.open) || event.target?.closest?.("#rail, #aside, #topbar, #footer" )) return; |
| 149 | setMenu(false); |
| 150 | setContext(false); |
| 151 | }); |
| 152 | } |
| 153 | }; |
| 154 | bindShell(); |
| 155 | document.addEventListener("keydown" , event => { |
| 156 | if (event.key !== "Escape" ) return; |
| 157 | const nav = document.getElementById("nav-menu" ); |
| 158 | const context = document.getElementById("context-menu" ); |
| 159 | if (nav?.open) { |
| 160 | setMenu(false); |
| 161 | setContext(false); |
| 162 | document.getElementById("menu" )?.focus(); |
| 163 | } else if (context?.open) { |
| 164 | setContext(false); |
| 165 | document.getElementById("context-toggle" )?.focus(); |
| 166 | } |
| 167 | }); |
| 168 | if ("serviceWorker" in navigator) navigator.serviceWorker.register(localPath("/sw.js" )).catch(() => {}); |
| 169 | // An installed app asks to keep its cached shell and parked shares. Browsers |
| 170 | // grant this silently for installed apps, so it never shows a prompt. |
| 171 | if (matchMedia("(display-mode: standalone)" ).matches) navigator.storage?.persist?.().catch(() => {}); |
| 172 | Object.assign(window.tiny, {root, localPath, sha256hex, authorization, signedFetch}); |
| 173 | window.tinySignedFetch = signedFetch; |
| 174 | |
| 175 | const isSignIn = url => url.pathname.replace(/\/+$/, "" ) === localPath("/signin" ); |
| 176 | const returnPath = value => { |
| 177 | if (!value?.startsWith("/" ) || value.startsWith("//" ) || /[\\\u0000-\u001f\u007f]/.test(value)) return null; |
| 178 | try { |
| 179 | const url = new URL(value, location.origin); |
| 180 | const tenant = (url.pathname.match(/^\/r\/[^/]+/) || ["" ])[0]; |
| 181 | if (url.origin !== location.origin || tenant !== root || isSignIn(url)) return null; |
| 182 | return url.pathname + url.search + url.hash; |
| 183 | } catch { return null; } |
| 184 | }; |
| 185 | const signInDestination = () => { |
| 186 | const current = new URL(location.href); |
| 187 | if (!isSignIn(current)) return returnPath(current.pathname + current.search + current.hash) || localPath("/" ); |
| 188 | const carried = new URLSearchParams(current.hash.slice(1)).get("return" ); |
| 189 | const next = returnPath(carried) || returnPath(current.searchParams.get("next" )); |
| 190 | if (next) return next; |
| 191 | try { |
| 192 | const previous = new URL(document.referrer); |
| 193 | if (previous.origin === location.origin) return returnPath(previous.pathname + previous.search) || localPath("/" ); |
| 194 | } catch {} |
| 195 | return localPath("/" ); |
| 196 | }; |
| 197 | |
| 198 | // Keep fragments client-side: file links may carry decryption keys. The |
| 199 | // server-rendered next query is still a usable fallback without JavaScript. |
| 200 | document.addEventListener("click" , event => { |
| 201 | const link = event.target?.closest?.("a[href]" ); |
| 202 | if (!link) return; |
| 203 | const current = new URL(location.href); |
| 204 | let login; |
| 205 | try { login = new URL(link.href, location.href); } catch { return; } |
| 206 | if (login.origin !== location.origin || !isSignIn(login) || isSignIn(current)) return; |
| 207 | const target = returnPath(current.pathname + current.search + current.hash); |
| 208 | if (!target) return; |
| 209 | login.searchParams.set("next" , current.pathname + current.search); |
| 210 | login.hash = current.hash ? "return=" + encodeURIComponent(target) : "" ; |
| 211 | link.href = login.href; |
| 212 | if (link.hasAttribute("fx-action" )) link.setAttribute("fx-action" , login.href); |
| 213 | }, true); |
| 214 | |
| 215 | const signIn = async () => { |
| 216 | const destination = signInDestination(); |
| 217 | say("Signing in…" ); |
| 218 | if (sessionActor() && window.tiny.signer()?.getPublicKey) verifySignerIdentity(await window.tiny.signer().getPublicKey()); |
| 219 | await signedSession("/session" ); |
| 220 | announceSigner("ready" ); |
| 221 | say("Signed in." ); |
| 222 | location.replace(destination); |
| 223 | }; |
| 224 | |
| 225 | // A remote signer session lives in this tab unless the person chose to |
| 226 | // remember the device; then it survives the installed app being closed. |
| 227 | const forgetRemote = () => { |
| 228 | for (const store of [sessionStorage, localStorage]) { |
| 229 | Object.values(storage).forEach(name => store.removeItem(name)); |
| 230 | store.removeItem("tiny.bunker" ); |
| 231 | } |
| 232 | }; |
| 233 | |
| 234 | const rememberRemote = async (uri, key, signer) => { |
| 235 | const store = document.getElementById("remember-signer" )?.checked ? localStorage : sessionStorage; |
| 236 | store.setItem(storage.uri, uri); |
| 237 | store.setItem(storage.key, window.NostrSigner.bytesToHex(key)); |
| 238 | store.setItem(storage.pubkey, await signer.getPublicKey()); |
| 239 | store.setItem(storage.remote, JSON.stringify(signer.bp)); |
| 240 | }; |
| 241 | |
| 242 | let activeSigner, activeRemote, remoteFacade, nativeSigner = window.nostr; |
| 243 | let signerStatus = "disconnected" , signerMessage = "Signer unavailable." ; |
| 244 | let reconnecting, lastCheck = 0, signerGeneration = 0; |
| 245 | const pendingCalls = new Map(); |
| 246 | const signerStatusText = { |
| 247 | disconnected: "Signer unavailable." , checking: "Checking signer…" , |
| 248 | reconnecting: "Reconnecting signer…" , lost: "Signer connection lost." , ready: "Signer connected." |
| 249 | }; |
| 250 | const sessionActor = () => document.querySelector?.("signer-connection" )?.getAttribute("actor" ) || "" ; |
| 251 | const nativeProvider = () => { |
| 252 | if (window.nostr && window.nostr !== remoteFacade) nativeSigner = window.nostr; |
| 253 | return nativeSigner; |
| 254 | }; |
| 255 | const withTimeout = (promise, ms, message = "Signer connection timed out." ) => { |
| 256 | let timer; |
| 257 | return Promise.race([promise, new Promise((_, reject) => { |
| 258 | timer = setTimeout(() => reject(Error(message)), ms); |
| 259 | })]).finally(() => clearTimeout(timer)); |
| 260 | }; |
| 261 | const closeRemote = signer => { |
| 262 | if (!signer) return; |
| 263 | for (const reject of pendingCalls.get(signer) || []) reject(Error("Signer connection changed. Retry when connected." )); |
| 264 | pendingCalls.delete(signer); |
| 265 | try { Promise.resolve(signer.close?.()).catch(() => {}); } catch {} |
| 266 | try { signer.pool?.destroy?.(); } catch {} |
| 267 | }; |
| 268 | const connectionURL = () => { |
| 269 | const current = new URL(location.href); |
| 270 | const url = new URL(localPath("/signin" ), location.href); |
| 271 | url.searchParams.set("connect" , "1" ); |
| 272 | url.searchParams.set("next" , current.pathname + current.search); |
| 273 | if (current.hash) url.hash = "return=" + encodeURIComponent(current.pathname + current.search + current.hash); |
| 274 | return url.href; |
| 275 | }; |
| 276 | const hasRememberedSigner = () => !!(sessionStorage.getItem(storage.key) || localStorage.getItem(storage.key)); |
| 277 | const updateSignerConnection = () => { |
| 278 | const host = document.querySelector?.("signer-connection" ); |
| 279 | if (!host || !document.createElement) return; |
| 280 | host.hidden = signerStatus === "ready" ; |
| 281 | host.setAttribute("data-state" , signerStatus); |
| 282 | host.setAttribute("role" , "status" ); |
| 283 | host.replaceChildren(); |
| 284 | if (host.hidden) return; |
| 285 | const message = document.createElement("span" ); |
| 286 | message.textContent = signerMessage; |
| 287 | host.append(message); |
| 288 | if (signerStatus === "checking" || signerStatus === "reconnecting" ) return; |
| 289 | const button = document.createElement("button" ); |
| 290 | button.type = "button" ; |
| 291 | button.textContent = hasRememberedSigner() || nativeProvider() ? "Reconnect" : "Connect signer" ; |
| 292 | button.addEventListener("click" , () => { |
| 293 | if (!hasRememberedSigner() && !nativeProvider()) { location.assign(connectionURL()); return; } |
| 294 | reconnectSigner({force: true}).catch(() => {}); |
| 295 | }); |
| 296 | host.append(button); |
| 297 | }; |
| 298 | const announceSigner = (status, message = signerStatusText[status]) => { |
| 299 | signerStatus = status; |
| 300 | signerMessage = message; |
| 301 | updateSignerConnection(); |
| 302 | const statusNode = document.getElementById("session-status" ); |
| 303 | if (statusNode) statusNode.textContent = status === "lost" ? message : "" ; |
| 304 | if (typeof CustomEvent === "function" ) document.dispatchEvent?.(new CustomEvent("tiny:signer-status" , { |
| 305 | detail: {status, text: message, canReconnect: status === "lost" || status === "disconnected" } |
| 306 | })); |
| 307 | }; |
| 308 | const loseRemote = (remote, message) => { |
| 309 | if (remote !== activeRemote) return; |
| 310 | activeRemote = activeSigner = undefined; |
| 311 | window.tinySigner = undefined; |
| 312 | if (window.nostr === remoteFacade) window.nostr = nativeSigner; |
| 313 | closeRemote(remote); |
| 314 | announceSigner("lost" , message || signerStatusText.lost); |
| 315 | }; |
| 316 | const guardSigner = signer => { |
| 317 | const methods = new Map(); |
| 318 | return new Proxy(signer, {get(target, property) { |
| 319 | const value = Reflect.get(target, property, target); |
| 320 | if (typeof value !== "function" ) return value; |
| 321 | if (!/^(?:signEvent|getPublicKey|nip(?:04|44)(?:Encrypt|Decrypt))$/.test(String(property))) return value.bind(target); |
| 322 | if (!methods.has(property)) methods.set(property, async (...args) => { |
| 323 | if (target !== activeRemote) throw Error("Signer connection changed. Retry when connected." ); |
| 324 | let cancel; |
| 325 | const pending = pendingCalls.get(target) || new Set(); |
| 326 | pendingCalls.set(target, pending); |
| 327 | const canceled = new Promise((_, reject) => { cancel = reject; pending.add(reject); }); |
| 328 | try { |
| 329 | const result = await withTimeout(Promise.race([Promise.resolve().then(() => value.apply(target, args)), canceled]), property === "signEvent" ? 120000 : 30000); |
| 330 | if (target !== activeRemote) throw Error("Signer connection changed. Retry when connected." ); |
| 331 | return result; |
| 332 | } catch (err) { |
| 333 | if (!/reject|denied|cancel|declin|unsupported|invalid|permission/i.test(errorText(err))) loseRemote(target); |
| 334 | throw err; |
| 335 | } finally { pending.delete(cancel); } |
| 336 | }); |
| 337 | return methods.get(property); |
| 338 | }}); |
| 339 | }; |
| 340 | const setSigner = signer => { |
| 341 | const previous = activeRemote; |
| 342 | signerGeneration++; |
| 343 | activeRemote = signer; |
| 344 | activeSigner = guardSigner(signer); |
| 345 | window.tinySigner = activeSigner; |
| 346 | nativeProvider(); |
| 347 | remoteFacade = {signEvent: event => activeSigner ? activeSigner.signEvent(event) : Promise.reject(Error("Reconnect your signer first." ))}; |
| 348 | window.nostr = remoteFacade; |
| 349 | if (previous !== signer) closeRemote(previous); |
| 350 | announceSigner("ready" ); |
| 351 | if (typeof CustomEvent === "function" ) document.dispatchEvent?.(new CustomEvent("tiny:signer" )); |
| 352 | }; |
| 353 | const storedRemote = async () => { |
| 354 | const store = sessionStorage.getItem(storage.key) ? sessionStorage : localStorage; |
| 355 | const uri = store.getItem(storage.uri), keyHex = store.getItem(storage.key); |
| 356 | if (!uri || !keyHex) return null; |
| 357 | const remote = store.getItem(storage.remote); |
| 358 | const pointer = remote ? JSON.parse(remote) : await withTimeout(window.NostrSigner.parseBunkerInput(uri), 5000); |
| 359 | if (!validBunker(pointer)) throw Error("Saved signer connection is invalid. Connect your signer again." ); |
| 360 | return {key: window.NostrSigner.hexToBytes(keyHex), pointer, expected: store.getItem(storage.pubkey)}; |
| 361 | }; |
| 362 | const verifySignerIdentity = (pubkey, expected) => { |
| 363 | if (!/^[0-9a-f]{64}$/i.test(pubkey || "" ) || (expected && pubkey.toLowerCase() !== expected.toLowerCase()) || (sessionActor() && pubkey.toLowerCase() !== sessionActor().toLowerCase())) |
| 364 | throw Error("Choose the signer for your signed-in account." ); |
| 365 | }; |
| 366 | const reconnectSigner = ({force = false} = {}) => { |
| 367 | if (reconnecting) return reconnecting; |
| 368 | if (!force && Date.now() - lastCheck < 15000) return Promise.resolve(activeSigner); |
| 369 | lastCheck = Date.now(); |
| 370 | const generation = signerGeneration; |
| 371 | // Deferring keeps even an immediately failed attempt in the single-flight slot. |
| 372 | const task = Promise.resolve().then(async () => { |
| 373 | if (!hasRememberedSigner()) { |
| 374 | const native = nativeProvider(); |
| 375 | if (!native?.getPublicKey) { announceSigner("disconnected" ); return; } |
| 376 | announceSigner("checking" ); |
| 377 | try { |
| 378 | verifySignerIdentity(await withTimeout(native.getPublicKey(), 5000)); |
| 379 | if (generation !== signerGeneration) return activeSigner; |
| 380 | announceSigner("ready" ); |
| 381 | if (typeof CustomEvent === "function" ) document.dispatchEvent?.(new CustomEvent("tiny:signer" )); |
| 382 | return native; |
| 383 | } catch (err) { |
| 384 | if (generation === signerGeneration) announceSigner("lost" , /signed-in account/.test(errorText(err)) ? errorText(err) : "Unlock or reconnect your signer." ); |
| 385 | throw err; |
| 386 | } |
| 387 | } |
| 388 | if (!force && activeRemote?.ping) { |
| 389 | const previous = activeRemote; |
| 390 | announceSigner("checking" ); |
| 391 | try { |
| 392 | await withTimeout(previous.ping(), 5000); |
| 393 | if (generation !== signerGeneration) return activeSigner; |
| 394 | announceSigner("ready" ); |
| 395 | return activeSigner; |
| 396 | } catch { if (generation !== signerGeneration) return activeSigner; } |
| 397 | } |
| 398 | announceSigner("reconnecting" ); |
| 399 | let candidate; |
| 400 | try { |
| 401 | const stored = await storedRemote(); |
| 402 | if (!stored) { announceSigner("disconnected" ); return; } |
| 403 | candidate = window.NostrSigner.BunkerSigner.fromBunker(stored.key, stored.pointer); |
| 404 | await withTimeout(candidate.connect(), 8000); |
| 405 | verifySignerIdentity(await withTimeout(candidate.getPublicKey(), 5000), stored.expected); |
| 406 | if (generation !== signerGeneration) { closeRemote(candidate); return activeSigner; } |
| 407 | setSigner(candidate); |
| 408 | return activeSigner; |
| 409 | } catch (err) { |
| 410 | closeRemote(candidate); |
| 411 | if (generation === signerGeneration) { |
| 412 | const previous = activeRemote; |
| 413 | activeRemote = activeSigner = undefined; |
| 414 | window.tinySigner = undefined; |
| 415 | if (window.nostr === remoteFacade) window.nostr = nativeSigner; |
| 416 | closeRemote(previous); |
| 417 | announceSigner("lost" , /signed-in account/.test(errorText(err)) ? errorText(err) : signerStatusText.lost); |
| 418 | } |
| 419 | throw err; |
| 420 | } |
| 421 | }); |
| 422 | reconnecting = task; |
| 423 | task.finally(() => { if (reconnecting === task) reconnecting = undefined; }).catch(() => {}); |
| 424 | return task; |
| 425 | }; |
| 426 | Object.assign(window.tiny, {signerState: () => signerStatus, reconnectSigner}); |
| 427 | document.addEventListener("tiny:navigation" , updateSignerConnection); |
| 428 | const resumeSigner = () => { |
| 429 | if (document.visibilityState && document.visibilityState !== "visible" ) return; |
| 430 | if (hasRememberedSigner() || nativeProvider()) reconnectSigner().catch(() => {}); |
| 431 | else updateSignerConnection(); |
| 432 | }; |
| 433 | document.addEventListener("visibilitychange" , () => { |
| 434 | if (document.visibilityState === "hidden" ) { lastCheck = -Infinity; return; } |
| 435 | resumeSigner(); |
| 436 | }); |
| 437 | window.addEventListener("pageshow" , event => { if (event.persisted) lastCheck = -Infinity; resumeSigner(); }); |
| 438 | document.addEventListener("resume" , () => { lastCheck = -Infinity; resumeSigner(); }); |
| 439 | window.addEventListener("nostr:ready" , () => { lastCheck = -Infinity; resumeSigner(); }); |
| 440 | window.addEventListener("focus" , resumeSigner); |
| 441 | window.addEventListener("online" , () => { lastCheck = -Infinity; resumeSigner(); }); |
| 442 | window.addEventListener("offline" , () => { if (activeRemote) loseRemote(activeRemote, "Offline. Reconnect when online." ); }); |
| 443 | updateSignerConnection(); |
| 444 | |
| 445 | // Fixi can morph the content column without reloading this script. Bind |
| 446 | // controls by element identity so navigation can safely discover new ones. |
| 447 | const boundControls = new WeakSet(); |
| 448 | const bindControl = (id, event, handler) => { |
| 449 | const element = document.getElementById(id); |
| 450 | if (!element || boundControls.has(element)) return; |
| 451 | boundControls.add(element); |
| 452 | element.addEventListener(event, handler); |
| 453 | }; |
| 454 | const busy = new WeakSet(); |
| 455 | const once = (element, action) => { |
| 456 | if (busy.has(element)) return; |
| 457 | busy.add(element); |
| 458 | const button = element.matches?.("button, input[type=submit]" ) ? element : element.querySelector?.("button, input[type=submit]" ); |
| 459 | if (button) button.disabled = true; |
| 460 | return Promise.resolve().then(action).finally(() => { busy.delete(element); if (button) button.disabled = false; }); |
| 461 | }; |
| 462 | |
| 463 | const bindControls = () => { |
| 464 | bindControl("session-login" , "click" , event => once(event.currentTarget, async () => { |
| 465 | try { await signIn(); } catch (err) { say("Sign-in error: " + errorText(err)); } |
| 466 | })); |
| 467 | |
| 468 | bindControl("session-logout" , "click" , event => once(event.currentTarget, async () => { |
| 469 | try { |
| 470 | say("Signing out…" ); |
| 471 | const response = await fetch(localPath("/session/logout" ), {method: "POST" , credentials: "same-origin" }); |
| 472 | if (!response.ok) throw Error(await response.text()); |
| 473 | signerGeneration++; |
| 474 | closeRemote(activeRemote); |
| 475 | activeRemote = activeSigner = undefined; |
| 476 | window.tinySigner = undefined; |
| 477 | forgetRemote(); |
| 478 | if (typeof CustomEvent === "function" ) document.dispatchEvent?.(new CustomEvent("tiny:logout" )); |
| 479 | say("Signed out." ); |
| 480 | location.reload(); |
| 481 | } catch (err) { |
| 482 | say("Sign-out error: " + errorText(err)); |
| 483 | } |
| 484 | })); |
| 485 | |
| 486 | // Nostr Connect: show a QR code and wait for a phone signer such as Amber. |
| 487 | bindControl("nostrconnect" , "click" , event => { |
| 488 | event.preventDefault(); |
| 489 | return once(event.currentTarget, async () => { |
| 490 | try { |
| 491 | const key = window.NostrSigner.generateSecretKey(); |
| 492 | const uri = window.NostrSigner.createNostrConnectURI({ |
| 493 | clientPubkey: window.NostrSigner.getPublicKey(key), |
| 494 | relays: [location.origin.replace(/^http/,"ws" )+root], |
| 495 | secret: randomHex(), |
| 496 | name: "tiny" , |
| 497 | url: location.origin, |
| 498 | perms: ["sign_event:27235" ] |
| 499 | }); |
| 500 | const open = document.getElementById("nostrconnect-open" ); |
| 501 | if (open) { open.href = uri; open.hidden = false; } |
| 502 | const qr = document.getElementById("signer-qr" ); |
| 503 | if (qr) { qr.src = root + "/qr.svg?text=" + encodeURIComponent(uri); qr.hidden = false; } |
| 504 | navigator.clipboard?.writeText(uri).catch(() => {}); |
| 505 | say("Scan the QR code or open the link in your signer." ); |
| 506 | window.tinyConnect = {key, uri}; |
| 507 | const signer = await window.NostrSigner.BunkerSigner.fromURI(key, uri); |
| 508 | setSigner(signer); |
| 509 | await rememberRemote(uri, key, signer); |
| 510 | await signIn(); |
| 511 | } catch (err) { |
| 512 | say("Nostr Connect error: " + errorText(err)); |
| 513 | } |
| 514 | }); |
| 515 | }); |
| 516 | |
| 517 | // Bunker URL: connect to a remote signer the user pastes in. |
| 518 | bindControl("bunker" , "submit" , event => { |
| 519 | event.preventDefault(); |
| 520 | return once(event.currentTarget, async () => { |
| 521 | const value = document.getElementById("bunker-url" )?.value.trim(); |
| 522 | if (!value) return; |
| 523 | try { |
| 524 | const secret = window.NostrSigner.generateSecretKey(); |
| 525 | const pointer = await window.NostrSigner.parseBunkerInput(value); |
| 526 | if (!validBunker(pointer)) throw Error("Enter a valid bunker URL with at least one relay." ); |
| 527 | const signer = window.NostrSigner.BunkerSigner.fromBunker(secret, pointer); |
| 528 | await signer.connect(); |
| 529 | setSigner(signer); |
| 530 | await rememberRemote(value, secret, signer); |
| 531 | await signIn(); |
| 532 | } catch (err) { |
| 533 | say("Remote signer error: " + errorText(err)); |
| 534 | } |
| 535 | }); |
| 536 | }); |
| 537 | }; |
| 538 | bindControls(); |
| 539 | document.addEventListener("tiny:navigation" , () => { bindShell(); bindControls(); }); |
| 540 | |
| 541 | // Resume a remote signer stored for this tab so page loads keep working. |
| 542 | (async () => { |
| 543 | try { |
| 544 | if (hasRememberedSigner() || nativeProvider()) await reconnectSigner({force: true}); |
| 545 | } catch {} |
| 546 | })(); |
| 547 | })(); |