Compare commits

...

3 Commits

Author SHA1 Message Date
3a56464926 Improve Nostr connect UI 2026-04-20 13:09:51 +04:00
1dc0c4119b Refactor Nostr auth service 2026-04-20 12:34:44 +04:00
c57a665655 Add applesauce debug logs, fix aggressive connect timeout 2026-04-20 12:14:45 +04:00
3 changed files with 133 additions and 103 deletions

View File

@@ -7,6 +7,7 @@ import qrCode from '../modifiers/qr-code';
export default class NostrConnectComponent extends Component {
@service nostrAuth;
@service toast;
get hasExtension() {
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
@@ -15,7 +16,8 @@ export default class NostrConnectComponent extends Component {
@action
async connectExtension() {
try {
await this.nostrAuth.login('extension');
await this.nostrAuth.connectWithExtension();
this.toast.show('Nostr connected successfully');
if (this.args.onConnect) {
this.args.onConnect();
}
@@ -28,7 +30,8 @@ export default class NostrConnectComponent extends Component {
@action
async connectApp() {
try {
await this.nostrAuth.login('connect');
await this.nostrAuth.connectWithApp();
this.toast.show('Nostr connected successfully');
if (this.args.onConnect) {
this.args.onConnect();
}
@@ -53,7 +56,7 @@ export default class NostrConnectComponent extends Component {
</button>
{{else}}
<button
class="btn btn-secondary"
class="btn btn-outline"
type="button"
disabled
title="No Nostr extension found in your browser."
@@ -72,7 +75,7 @@ export default class NostrConnectComponent extends Component {
</div>
{{#if (eq this.nostrAuth.connectStatus "waiting")}}
<div class="alert alert-info nostr-connect-status">
<div class="nostr-connect-status">
{{#if this.nostrAuth.isMobile}}
<p>Waiting for you to approve the connection in your mobile signer
app...</p>

View File

@@ -48,7 +48,7 @@ export default class UserMenuComponent extends Component {
@action
disconnectNostr() {
this.nostrAuth.logout();
this.nostrAuth.disconnect();
}
<template>

View File

@@ -12,6 +12,8 @@ const STORAGE_KEY_CONNECT_LOCAL_KEY = 'marco:nostr_connect_local_key';
const STORAGE_KEY_CONNECT_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey';
const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
export default class NostrAuthService extends Service {
@service nostrRelay;
@@ -26,6 +28,12 @@ export default class NostrAuthService extends Service {
constructor() {
super(...arguments);
// Enable debug logging for applesauce packages
if (typeof localStorage !== 'undefined') {
localStorage.debug = 'applesauce:*';
}
const saved = localStorage.getItem(STORAGE_KEY);
const type = localStorage.getItem(STORAGE_KEY_TYPE);
if (saved) {
@@ -38,7 +46,7 @@ export default class NostrAuthService extends Service {
async _verifyPubkey() {
if (this.signerType === 'extension') {
if (typeof window.nostr === 'undefined') {
this.logout();
this.disconnect();
return;
}
try {
@@ -50,14 +58,14 @@ export default class NostrAuthService extends Service {
}
} catch (e) {
console.warn('Failed to verify extension nostr pubkey, logging out', e);
this.logout();
this.disconnect();
}
} else if (this.signerType === 'connect') {
try {
await this._initConnectSigner();
} catch (e) {
console.warn('Failed to verify connect nostr pubkey, logging out', e);
this.logout();
this.disconnect();
}
}
}
@@ -93,116 +101,135 @@ export default class NostrAuthService extends Service {
return null;
}
async login(type = 'extension') {
if (type === 'extension') {
if (typeof window.nostr === 'undefined') {
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
async connectWithExtension() {
if (typeof window.nostr === 'undefined') {
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
}
try {
this._signerInstance = new ExtensionSigner();
this.pubkey = await this._signerInstance.getPublicKey();
this.signerType = 'extension';
localStorage.setItem(STORAGE_KEY, this.pubkey);
localStorage.setItem(STORAGE_KEY_TYPE, 'extension');
return this.pubkey;
} catch (error) {
console.error('Failed to get public key from extension:', error);
throw error;
}
}
_getLocalSigner() {
let localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
let localSigner;
if (localKeyHex) {
localSigner = PrivateKeySigner.fromKey(localKeyHex);
} else {
localSigner = new PrivateKeySigner();
// Store the raw Uint8Array as hex string
localKeyHex = Array.from(localSigner.key)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
localStorage.setItem(STORAGE_KEY_CONNECT_LOCAL_KEY, localKeyHex);
}
return localSigner;
}
async connectWithApp() {
this.connectStatus = 'waiting';
try {
const localSigner = this._getLocalSigner();
// We use a specific relay for the connection handshake.
const relay = DEFAULT_CONNECT_RELAY;
localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
// Override aggressive 10s EOSE timeout to allow time for QR scanning
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
this._signerInstance = new NostrConnectSigner({
pool: this.nostrRelay.pool,
relays: [relay],
signer: localSigner,
onAuth: async (url) => {
// NIP-46 auth callback. Normally the signer app does this natively via notification.
// But if it requires an explicit browser window:
if (
confirm(
`Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
)
) {
window.open(url, '_blank');
}
},
});
// Set the uri for display (e.g., to redirect via intent)
this.connectUri = this._signerInstance.getNostrConnectURI({
name: 'Marco',
url: window.location.origin,
description: 'An unhosted maps application.',
icons: [],
});
// Trigger the deep link intent immediately for the user if on mobile
if (this.isMobile) {
console.debug('Mobile detected, triggering deep link intent.');
window.location.href = this.connectUri;
}
// Start listening to the relay
console.debug('Opening signer connection to relay...');
await this._signerInstance.open();
console.debug('Signer connection opened successfully.');
// Wait for the remote signer to reply with their pubkey
console.debug('Waiting for remote signer to ack via relay...');
try {
this._signerInstance = new ExtensionSigner();
this.pubkey = await this._signerInstance.getPublicKey();
this.signerType = 'extension';
localStorage.setItem(STORAGE_KEY, this.pubkey);
localStorage.setItem(STORAGE_KEY_TYPE, 'extension');
return this.pubkey;
} catch (error) {
console.error('Failed to get public key from extension:', error);
throw error;
}
} else if (type === 'connect') {
this.connectStatus = 'waiting';
try {
// Generate or retrieve a local ephemeral keypair
let localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
let localSigner;
if (localKeyHex) {
localSigner = PrivateKeySigner.fromKey(localKeyHex);
} else {
localSigner = new PrivateKeySigner();
// Store the raw Uint8Array as hex string
localKeyHex = Array.from(localSigner.key)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
localStorage.setItem(STORAGE_KEY_CONNECT_LOCAL_KEY, localKeyHex);
}
// We use a specific relay for the connection handshake.
const relay = 'wss://relay.nsec.app';
localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
this._signerInstance = new NostrConnectSigner({
pool: this.nostrRelay.pool,
relays: [relay],
signer: localSigner,
onAuth: async (url) => {
// NIP-46 auth callback. Normally the signer app does this natively via notification.
// But if it requires an explicit browser window:
if (
confirm(
`Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
)
) {
window.open(url, '_blank');
}
},
});
// Set the uri for display (e.g., to redirect via intent)
this.connectUri = this._signerInstance.getNostrConnectURI({
name: 'Marco',
url: window.location.origin,
description: 'A privacy-respecting maps application.',
icons: [],
});
// Trigger the deep link intent immediately for the user if on mobile
if (this.isMobile) {
window.location.href = this.connectUri;
}
// Start listening to the relay
await this._signerInstance.open();
// Wait for the remote signer to reply with their pubkey
await this._signerInstance.waitForSigner();
// Once connected, get the actual user pubkey
this.pubkey = await this._signerInstance.getPublicKey();
this.signerType = 'connect';
this.connectStatus = 'connected';
// Save connection state
localStorage.setItem(STORAGE_KEY, this.pubkey);
localStorage.setItem(STORAGE_KEY_TYPE, 'connect');
localStorage.setItem(
STORAGE_KEY_CONNECT_REMOTE_PUBKEY,
this._signerInstance.remote
);
return this.pubkey;
} catch (error) {
this.connectStatus = null;
console.error('Failed to connect via Nostr Connect:', error);
throw error;
console.debug('Remote signer ack received!');
} catch (waitErr) {
console.error('Error while waiting for remote signer ack:', waitErr);
throw waitErr;
}
// Once connected, get the actual user pubkey
this.pubkey = await this._signerInstance.getPublicKey();
this.signerType = 'connect';
this.connectStatus = 'connected';
// Save connection state
localStorage.setItem(STORAGE_KEY, this.pubkey);
localStorage.setItem(STORAGE_KEY_TYPE, 'connect');
localStorage.setItem(
STORAGE_KEY_CONNECT_REMOTE_PUBKEY,
this._signerInstance.remote
);
return this.pubkey;
} catch (error) {
this.connectStatus = null;
console.error('Failed to connect via Nostr Connect:', error);
throw error;
}
}
async _initConnectSigner() {
const localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
const remotePubkey = localStorage.getItem(
STORAGE_KEY_CONNECT_REMOTE_PUBKEY
);
const relay =
localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || 'wss://relay.nsec.app';
localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || DEFAULT_CONNECT_RELAY;
if (!localKeyHex || !remotePubkey) {
throw new Error('Missing Nostr Connect local state.');
if (!remotePubkey) {
throw new Error('Missing Nostr Connect remote pubkey.');
}
const localSigner = PrivateKeySigner.fromKey(localKeyHex);
const localSigner = this._getLocalSigner();
// Override aggressive 10s EOSE timeout to allow time for QR scanning
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
this._signerInstance = new NostrConnectSigner({
pool: this.nostrRelay.pool,
@@ -237,7 +264,7 @@ export default class NostrAuthService extends Service {
return await this.signer.signEvent(event);
}
async logout() {
async disconnect() {
this.pubkey = null;
this.signerType = null;
this.connectStatus = null;