tinyrelayfile
tinyrelay/tinyclient/bridge.js/

tinyclient/bridge.js raw
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})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs