From 071d863258cff3f06ab8c48447d3d6d8642fad98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Sun, 12 Oct 2025 16:53:41 +0200 Subject: [PATCH] Persist signer logins --- .../controllers/amber_connect_controller.js | 9 +++ .../controllers/nostr_comment_controller.js | 18 +++--- .../nostr_index_sign_controller.js | 28 +++------ .../nostr_single_sign_controller.js | 19 +++--- .../reading_list_dropdown_controller.js | 17 +++--- assets/controllers/signer_manager.js | 58 +++++++++++++++++++ 6 files changed, 107 insertions(+), 42 deletions(-) create mode 100644 assets/controllers/signer_manager.js diff --git a/assets/controllers/amber_connect_controller.js b/assets/controllers/amber_connect_controller.js index cb7bb3f..55f6a03 100644 --- a/assets/controllers/amber_connect_controller.js +++ b/assets/controllers/amber_connect_controller.js @@ -1,6 +1,7 @@ import { Controller } from '@hotwired/stimulus'; import { getPublicKey, SimplePool } from 'nostr-tools'; import { BunkerSigner } from "nostr-tools/nip46"; +import { setRemoteSignerSession, clearRemoteSignerSession } from './signer_manager.js'; export default class extends Controller { static targets = ['qr', 'status']; @@ -19,6 +20,7 @@ export default class extends Controller { disconnect() { try { this._signer?.close?.(); } catch (_) {} try { this._pool?.close?.([]); } catch (_) {} + clearRemoteSignerSession(); } async _init() { @@ -98,6 +100,13 @@ export default class extends Controller { headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) } }); if (resp.ok) { + // Persist remote signer session for reuse after reload + setRemoteSignerSession({ + privkey: this._localSecretKey, + uri: this._uri, + relays: this._relays, + secret: this._secret + }); this._setStatus('Authenticated. Reloading…'); setTimeout(() => window.location.reload(), 500); } else { diff --git a/assets/controllers/nostr_comment_controller.js b/assets/controllers/nostr_comment_controller.js index 6b3d7b9..c7b4665 100644 --- a/assets/controllers/nostr_comment_controller.js +++ b/assets/controllers/nostr_comment_controller.js @@ -1,4 +1,5 @@ import { Controller } from '@hotwired/stimulus'; +import { getSigner } from './signer_manager.js'; // NIP-22 Comment Publishing Controller // Usage: Attach to a form with data attributes for root/parent context @@ -28,8 +29,11 @@ export default class extends Controller { this.showError('Missing CSRF token'); return; } - if (!window.nostr) { - this.showError('Nostr extension not found'); + let signer; + try { + signer = await getSigner(); + } catch (e) { + this.showError('No Nostr signer available. Please connect Amber or install a Nostr signer extension.'); return; } @@ -52,10 +56,10 @@ export default class extends Controller { } // Create NIP-22 event - const nostrEvent = await this.createNip22Event(formData); + const nostrEvent = await this.createNip22Event(formData, signer); - this.showStatus('Requesting signature from Nostr extension...'); - const signedEvent = await window.nostr.signEvent(nostrEvent); + this.showStatus('Requesting signature from Nostr signer...'); + const signedEvent = await signer.signEvent(nostrEvent); this.showStatus('Publishing comment...'); await this.sendToBackend(signedEvent, formData); @@ -98,9 +102,9 @@ export default class extends Controller { return !(/[<>\*\_\`\[\]#]/.test(text)); } - async createNip22Event({ content, root, parent }) { + async createNip22Event({ content, root, parent }, signer) { // Get user's public key - const pubkey = await window.nostr.getPublicKey(); + const pubkey = await signer.getPublicKey(); const created_at = Math.floor(Date.now() / 1000); // Build tags according to NIP-22 const tags = []; diff --git a/assets/controllers/nostr_index_sign_controller.js b/assets/controllers/nostr_index_sign_controller.js index e00b51e..9840f0e 100644 --- a/assets/controllers/nostr_index_sign_controller.js +++ b/assets/controllers/nostr_index_sign_controller.js @@ -1,4 +1,5 @@ import { Controller } from '@hotwired/stimulus'; +import { getSigner } from './signer_manager.js'; export default class extends Controller { static targets = ['status', 'publishButton', 'computedPreview']; @@ -50,63 +51,53 @@ export default class extends Controller { async signAndPublish(event) { event.preventDefault(); - if (!window.nostr) { - this.showError('Nostr extension not found'); + let signer; + try { + signer = await getSigner(); + } catch (e) { + this.showError('No Nostr signer available. Please connect Amber or install a Nostr signer extension.'); return; } if (!this.publishUrlValue || !this.csrfTokenValue) { this.showError('Missing config'); return; } - this.publishButtonTarget.disabled = true; try { - const pubkey = await window.nostr.getPublicKey(); + const pubkey = await signer.getPublicKey(); const catSkeletons = JSON.parse(this.categoryEventsValue || '[]'); const magSkeleton = JSON.parse(this.magazineEventValue || '{}'); - const categoryCoordinates = []; - // 1) Publish each category index for (let i = 0; i < catSkeletons.length; i++) { const evt = catSkeletons[i]; this.ensureCreatedAt(evt); this.ensureContent(evt); evt.pubkey = pubkey; - const slug = this.extractSlug(evt.tags); if (!slug) throw new Error('Category missing slug (d tag)'); - this.showStatus(`Signing category ${i + 1}/${catSkeletons.length}…`); - const signed = await window.nostr.signEvent(evt); - + const signed = await signer.signEvent(evt); this.showStatus(`Publishing category ${i + 1}/${catSkeletons.length}…`); await this.publishSigned(signed); - // Coordinate for the category index (kind:pubkey:slug) const coord = `30040:${pubkey}:${slug}`; categoryCoordinates.push(coord); } - // 2) Build magazine event with 'a' tags referencing cats this.showStatus('Preparing magazine index…'); this.ensureCreatedAt(magSkeleton); this.ensureContent(magSkeleton); magSkeleton.pubkey = pubkey; - // Remove any pre-existing 'a' to avoid duplicates, then add new ones magSkeleton.tags = (magSkeleton.tags || []).filter(t => t[0] !== 'a'); categoryCoordinates.forEach(c => magSkeleton.tags.push(['a', c])); - // 3) Sign and publish magazine this.showStatus('Signing magazine index…'); - const signedMag = await window.nostr.signEvent(magSkeleton); - + const signedMag = await signer.signEvent(magSkeleton); this.showStatus('Publishing magazine index…'); await this.publishSigned(signedMag); - this.showSuccess('Published magazine and categories successfully'); - } catch (e) { console.error(e); this.showError(e.message || 'Publish failed'); @@ -163,4 +154,3 @@ export default class extends Controller { } } } - diff --git a/assets/controllers/nostr_single_sign_controller.js b/assets/controllers/nostr_single_sign_controller.js index 870438e..eaf629b 100644 --- a/assets/controllers/nostr_single_sign_controller.js +++ b/assets/controllers/nostr_single_sign_controller.js @@ -1,4 +1,5 @@ import { Controller } from '@hotwired/stimulus'; +import { getSigner } from './signer_manager.js'; export default class extends Controller { static targets = ['status', 'publishButton', 'computedPreview']; @@ -18,9 +19,10 @@ export default class extends Controller { try { const skeleton = JSON.parse(this.eventValue || '{}'); let pubkey = ''; - if (window.nostr && typeof window.nostr.getPublicKey === 'function') { - try { pubkey = await window.nostr.getPublicKey(); } catch (_) {} - } + try { + const signer = await getSigner(); + pubkey = await signer.getPublicKey(); + } catch (_) {} const preview = JSON.parse(JSON.stringify(skeleton)); preview.pubkey = pubkey; // Update content from textarea if present @@ -37,8 +39,11 @@ export default class extends Controller { async signAndPublish(event) { event.preventDefault(); - if (!window.nostr) { - this.showError('Nostr extension not found'); + let signer; + try { + signer = await getSigner(); + } catch (e) { + this.showError('No Nostr signer available. Please connect Amber or install a Nostr signer extension.'); return; } if (!this.publishUrlValue || !this.csrfTokenValue) { @@ -48,7 +53,7 @@ export default class extends Controller { this.publishButtonTarget.disabled = true; try { - const pubkey = await window.nostr.getPublicKey(); + const pubkey = await signer.getPublicKey(); const skeleton = JSON.parse(this.eventValue || '{}'); // Update content from textarea before signing const textarea = this.element.querySelector('textarea'); @@ -60,7 +65,7 @@ export default class extends Controller { skeleton.pubkey = pubkey; this.showStatus('Signing feedback…'); - const signed = await window.nostr.signEvent(skeleton); + const signed = await signer.signEvent(skeleton); this.showStatus('Publishing…'); await this.publishSigned(signed); diff --git a/assets/controllers/reading_list_dropdown_controller.js b/assets/controllers/reading_list_dropdown_controller.js index 0585901..830e9e9 100644 --- a/assets/controllers/reading_list_dropdown_controller.js +++ b/assets/controllers/reading_list_dropdown_controller.js @@ -1,4 +1,5 @@ import { Controller } from '@hotwired/stimulus'; +import { getSigner } from './signer_manager.js'; export default class extends Controller { static targets = ['dropdown', 'status', 'menu']; @@ -64,24 +65,24 @@ export default class extends Controller { const slug = event.currentTarget.dataset.slug; const title = event.currentTarget.dataset.title; - if (!window.nostr) { - this.showError('Nostr extension not found. Please install a Nostr signer extension.'); + let signer; + try { + signer = await getSigner(); + } catch (e) { + this.showError('No Nostr signer available. Please connect Amber or install a Nostr signer extension.'); return; } try { this.showStatus(`Adding to "${title}"...`); - // Parse the existing lists data + // Build the event skeleton for the updated reading list const lists = JSON.parse(this.listsValue || '[]'); const selectedList = lists.find(l => l.slug === slug); - if (!selectedList) { this.showError('Reading list not found'); return; } - - // Check if article is already in the list if (selectedList.articles && selectedList.articles.includes(this.coordinateValue)) { this.showSuccess(`Already in "${title}"`); setTimeout(() => { @@ -90,13 +91,11 @@ export default class extends Controller { }, 2000); return; } - - // Build the event skeleton for the updated reading list const eventSkeleton = await this.buildReadingListEvent(selectedList); // Sign the event this.showStatus(`Signing update to "${title}"...`); - const signedEvent = await window.nostr.signEvent(eventSkeleton); + const signedEvent = await signer.signEvent(eventSkeleton); // Publish the event this.showStatus(`Publishing update...`); diff --git a/assets/controllers/signer_manager.js b/assets/controllers/signer_manager.js new file mode 100644 index 0000000..5355db4 --- /dev/null +++ b/assets/controllers/signer_manager.js @@ -0,0 +1,58 @@ +// Shared signer manager for Nostr signers (remote and extension) +import { SimplePool } from 'nostr-tools'; +import { BunkerSigner } from 'nostr-tools/nip46'; + +const REMOTE_SIGNER_KEY = 'amber_remote_signer'; + +let remoteSigner = null; +let remoteSignerPromise = null; +let remoteSignerPool = null; + +export async function getSigner() { + // If remote signer session is active, use it + const session = getRemoteSignerSession(); + if (session) { + if (remoteSigner) return remoteSigner; + if (remoteSignerPromise) return remoteSignerPromise; + remoteSignerPromise = createRemoteSigner(session).then(signer => { + remoteSigner = signer; + return signer; + }); + return remoteSignerPromise; + } + // Fallback to browser extension + if (window.nostr && typeof window.nostr.signEvent === 'function') { + return window.nostr; + } + throw new Error('No signer available'); +} + +export function setRemoteSignerSession(session) { + localStorage.setItem(REMOTE_SIGNER_KEY, JSON.stringify(session)); +} + +export function clearRemoteSignerSession() { + localStorage.removeItem(REMOTE_SIGNER_KEY); + remoteSigner = null; + remoteSignerPromise = null; + if (remoteSignerPool) { + try { remoteSignerPool.close?.([]); } catch (_) {} + remoteSignerPool = null; + } +} + +export function getRemoteSignerSession() { + const raw = localStorage.getItem(REMOTE_SIGNER_KEY); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +async function createRemoteSigner(session) { + remoteSignerPool = new SimplePool(); + return await BunkerSigner.fromURI(session.privkey, session.uri, { pool: remoteSignerPool }); +} +