900 lines
31 KiB
Rust
900 lines
31 KiB
Rust
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Error, anyhow};
|
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
|
use nostr_connect::prelude::*;
|
|
use nostr_sdk::client::SyncSummary;
|
|
use nostr_sdk::prelude::*;
|
|
use signed_core::filters;
|
|
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
|
|
|
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`
|
|
/// with an embedded `?master=<nsec>` NIP-46 session key).
|
|
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
|
/// Timeout for NIP-46 signer responses.
|
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
|
|
|
/// Relays connected at startup, before any user-specific relay config is known.
|
|
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
|
"wss://relay.primal.net",
|
|
"wss://relay.ditto.pub",
|
|
"wss://index.ngit.dev",
|
|
"wss://profiles.nostr1.com",
|
|
];
|
|
|
|
/// Relays used for indexing user's relay list (NIP-65).
|
|
pub const INDEXER_RELAYS: [&str; 3] = [
|
|
"wss://indexer.coracle.social",
|
|
"wss://purplepag.es",
|
|
"wss://user.kindpag.es",
|
|
];
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum BackendEvent {
|
|
/// User has no signer configured.
|
|
SignerRequired,
|
|
/// The stored identity is NIP-49 encrypted (`ncryptsec1...`); a
|
|
/// passphrase is required to decrypt it before the session can resume.
|
|
PassphraseRequired,
|
|
/// The signer has changed (login/logout/account switch).
|
|
SignerChanged,
|
|
/// Relay bootstrap finished.
|
|
Connected,
|
|
/// A new event was received from a relay and stored in the database.
|
|
NostrUpdate(Update),
|
|
/// A negentropy sync completed; the database was updated directly,
|
|
/// so stores should re-query (no [`BackendEvent::NostrUpdate`] is fired
|
|
/// for synced events).
|
|
Synced,
|
|
/// A negentropy sync is in flight. Stores may re-query to render
|
|
/// incrementally; UI can show `current`/`total` progress.
|
|
SyncProgress {
|
|
/// Total events to process.
|
|
total: u64,
|
|
/// Events processed so far.
|
|
current: u64,
|
|
},
|
|
/// An event built locally was signed, broadcast and stored.
|
|
Published(Box<Event>),
|
|
/// An error occurred.
|
|
Error(String),
|
|
}
|
|
|
|
impl BackendEvent {
|
|
pub fn error<T>(error: T) -> Self
|
|
where
|
|
T: Into<String>,
|
|
{
|
|
Self::Error(error.into())
|
|
}
|
|
}
|
|
|
|
/// Global backend entity: owns the nostr client, the signer and the
|
|
/// notification pump. Stores subscribe to [`BackendEvent`] and re-query the
|
|
/// local database when relevant updates arrive.
|
|
pub struct Backend {
|
|
client: Client,
|
|
signer: UniversalSigner,
|
|
current_user: Option<PublicKey>,
|
|
connected: bool,
|
|
sync_progress: Option<(u64, u64)>,
|
|
/// Whether the stored credential is NIP-49 encrypted and a passphrase
|
|
/// is still needed to resume the session.
|
|
passphrase_required: bool,
|
|
tasks: Vec<Task<Result<(), Error>>>,
|
|
}
|
|
|
|
struct GlobalBackend(Entity<Backend>);
|
|
|
|
impl Global for GlobalBackend {}
|
|
|
|
impl EventEmitter<BackendEvent> for Backend {}
|
|
|
|
impl Backend {
|
|
/// Retrieve the global backend.
|
|
pub fn global(cx: &App) -> Entity<Self> {
|
|
cx.global::<GlobalBackend>().0.clone()
|
|
}
|
|
|
|
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
|
cx.set_global(GlobalBackend(entity));
|
|
}
|
|
|
|
pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context<Self>) -> Self {
|
|
let pump_client = client.clone();
|
|
|
|
let pump = cx.spawn(async move |this, cx| {
|
|
let mut notifications = pump_client.notifications();
|
|
|
|
while let Some(notification) = notifications.next().await {
|
|
let ClientNotification::Event { event, .. } = notification else {
|
|
continue;
|
|
};
|
|
|
|
let update = Update::from_event(&event);
|
|
|
|
if this
|
|
.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(update)))
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
});
|
|
|
|
let mut this = Self {
|
|
client,
|
|
signer,
|
|
current_user: None,
|
|
connected: false,
|
|
sync_progress: None,
|
|
passphrase_required: false,
|
|
tasks: vec![pump],
|
|
};
|
|
|
|
this.bootstrap(cx);
|
|
this
|
|
}
|
|
|
|
/// Bootstrap the client: connect to the default relays (indexers as
|
|
/// discovery-only) and restore the saved session, if any.
|
|
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
|
let client = self.client.clone();
|
|
|
|
let task = cx.background_spawn(async move {
|
|
for url in BOOTSTRAP_RELAYS {
|
|
client.add_relay(url).await?;
|
|
}
|
|
for url in INDEXER_RELAYS {
|
|
client
|
|
.add_relay(url)
|
|
.capabilities(RelayCapabilities::DISCOVERY)
|
|
.await?;
|
|
}
|
|
client.connect().await;
|
|
Ok::<(), Error>(())
|
|
});
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
match task.await {
|
|
Ok(()) => {
|
|
this.update(cx, |this, cx| {
|
|
this.connected = true;
|
|
cx.emit(BackendEvent::Connected);
|
|
cx.notify();
|
|
})?;
|
|
}
|
|
Err(e) => {
|
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}));
|
|
|
|
self.restore_session(cx);
|
|
}
|
|
|
|
/// Restore the saved session from the keyring. Emits
|
|
/// [`BackendEvent::SignerRequired`] if no credential is stored, or
|
|
/// [`BackendEvent::PassphraseRequired`] if the stored identity is
|
|
/// NIP-49 encrypted.
|
|
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
|
|
if cfg!(target_arch = "wasm32") {
|
|
cx.emit(BackendEvent::SignerRequired);
|
|
return;
|
|
}
|
|
|
|
let user = cx.read_credentials(USER_KEYRING);
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
let content = match user.await {
|
|
Ok(Some((_username, secret))) => String::from_utf8(secret)?,
|
|
_ => {
|
|
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let result = async {
|
|
if content.starts_with("nsec1") {
|
|
let keys = Keys::new(SecretKey::parse(&content)?);
|
|
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
|
} else if content.starts_with("bunker://") {
|
|
let (base, keys) = extract_master_key(&content);
|
|
let uri = NostrConnectUri::parse(base)?;
|
|
let mut signer = NostrConnect::new(
|
|
uri,
|
|
keys,
|
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
|
None,
|
|
)?;
|
|
signer.auth_url_handler(SignedAuthUrlHandler);
|
|
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
|
} else if content.starts_with("ncryptsec1") {
|
|
// Encrypted identity: a passphrase is required to
|
|
// decrypt it before the session can resume.
|
|
log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase");
|
|
this.update(cx, |this, cx| {
|
|
this.passphrase_required = true;
|
|
cx.emit(BackendEvent::PassphraseRequired);
|
|
})?;
|
|
} else {
|
|
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
|
|
}
|
|
|
|
Ok::<_, Error>(())
|
|
}
|
|
.await;
|
|
|
|
if let Err(e) = result {
|
|
this.update(cx, |_, cx| {
|
|
cx.emit(BackendEvent::error(e.to_string()));
|
|
cx.emit(BackendEvent::SignerRequired);
|
|
})?;
|
|
}
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Decrypt the NIP-49 encrypted credential stored in the keyring with
|
|
/// the given passphrase and resume the session.
|
|
///
|
|
/// The scrypt decryption runs off the UI thread. The returned receiver
|
|
/// yields the public key on success, or the failure reason (e.g. wrong
|
|
/// passphrase), so callers can render inline errors.
|
|
pub fn restore_with_passphrase(
|
|
&mut self,
|
|
password: &str,
|
|
cx: &mut Context<Self>,
|
|
) -> flume::Receiver<Result<PublicKey, Error>> {
|
|
let (tx, rx) = flume::bounded(1);
|
|
|
|
let password = password.to_owned();
|
|
let user = cx.read_credentials(USER_KEYRING);
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
let result = async {
|
|
let content = user
|
|
.await?
|
|
.map(|(_username, secret)| String::from_utf8(secret))
|
|
.transpose()?
|
|
.ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?;
|
|
|
|
if !content.starts_with("ncryptsec1") {
|
|
Err(anyhow!("stored credential is not passphrase-encrypted"))?;
|
|
}
|
|
|
|
let decrypt_task = cx.background_spawn(async move {
|
|
let encrypted = EncryptedSecretKey::from_bech32(&content)?;
|
|
let secret = encrypted.decrypt(&password)?;
|
|
Ok::<_, Error>(Keys::new(secret))
|
|
});
|
|
|
|
let keys = decrypt_task.await?;
|
|
let public_key = keys.public_key();
|
|
|
|
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
|
|
|
Ok::<_, Error>(public_key)
|
|
}
|
|
.await;
|
|
|
|
tx.send_async(result).await.ok();
|
|
Ok(())
|
|
}));
|
|
|
|
rx
|
|
}
|
|
|
|
/// Create a new identity: generate keys, encrypt the secret key with the
|
|
/// passphrase (NIP-49) and persist it in the keyring, then publish the
|
|
/// user's NIP-65 relay list, metadata and grasp list.
|
|
///
|
|
/// The heavy encryption runs off the UI thread. The returned receiver
|
|
/// yields the new public key on success, or the failure reason, so
|
|
/// callers can render progress and inline errors.
|
|
pub fn create_identity(
|
|
&mut self,
|
|
name: &str,
|
|
password: &str,
|
|
cx: &mut Context<Self>,
|
|
) -> flume::Receiver<Result<PublicKey, Error>> {
|
|
let (tx, rx) = flume::bounded(1);
|
|
|
|
let name = name.trim().to_owned();
|
|
let password = password.to_owned();
|
|
|
|
let validation_error = if name.is_empty() || name.len() > 255 {
|
|
Some("Name must be 1-255 characters")
|
|
} else if password.is_empty() {
|
|
Some("Passphrase must not be empty")
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(message) = validation_error {
|
|
tx.try_send(Err(anyhow!(message))).ok();
|
|
return rx;
|
|
}
|
|
|
|
let job = cx.background_spawn(async move {
|
|
let keys = Keys::generate();
|
|
let encrypted =
|
|
EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?;
|
|
let ncryptsec = encrypted.to_bech32()?;
|
|
Ok::<_, Error>((keys, ncryptsec))
|
|
});
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
let result = async {
|
|
let (keys, ncryptsec) = job.await?;
|
|
let public_key = keys.public_key();
|
|
|
|
// Persist the encrypted credential.
|
|
let write = cx.update(|cx| {
|
|
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
|
|
});
|
|
write.await?;
|
|
|
|
this.update(cx, |this, cx| {
|
|
// Become the new identity, so the publishes below are
|
|
// signed with the new keys.
|
|
this.signer.swap_inner(keys);
|
|
this.current_user = Some(public_key);
|
|
this.bootstrap_user(public_key, cx);
|
|
cx.emit(BackendEvent::SignerChanged);
|
|
cx.notify();
|
|
|
|
let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [
|
|
(
|
|
RelayUrl::parse("wss://relay.primal.net").unwrap(),
|
|
Some(RelayMetadata::Read),
|
|
),
|
|
(
|
|
RelayUrl::parse("wss://relay.ditto.pub").unwrap(),
|
|
Some(RelayMetadata::Read),
|
|
),
|
|
(
|
|
RelayUrl::parse("wss://relay.nostr.net").unwrap(),
|
|
Some(RelayMetadata::Write),
|
|
),
|
|
(
|
|
RelayUrl::parse("wss://nos.lol").unwrap(),
|
|
Some(RelayMetadata::Write),
|
|
),
|
|
]
|
|
.to_vec();
|
|
|
|
this.send(RelayList::new(relays).into_event_builder(), cx);
|
|
|
|
let metadata = Metadata::new()
|
|
.name(&name)
|
|
.display_name(&name)
|
|
.into_event_builder();
|
|
|
|
this.send(metadata, cx);
|
|
|
|
let grasp_servers: Vec<RelayUrl> =
|
|
["wss://gitnostr.com", "wss://relay.ngit.dev"]
|
|
.into_iter()
|
|
.map(|url| RelayUrl::parse(url).expect("valid relay URL"))
|
|
.collect();
|
|
|
|
this.send(GitUserGraspList { grasp_servers }.into_event_builder(), cx);
|
|
})?;
|
|
|
|
Ok(public_key)
|
|
}
|
|
.await;
|
|
|
|
tx.send_async(result).await.ok();
|
|
|
|
Ok(())
|
|
}));
|
|
|
|
rx
|
|
}
|
|
|
|
/// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on
|
|
/// the credential's prefix.
|
|
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
|
|
let credential = credential.trim();
|
|
|
|
if credential.starts_with("nsec1") {
|
|
self.login_with_nsec(credential, cx);
|
|
} else if credential.starts_with("bunker://") {
|
|
self.login_with_bunker(credential, cx);
|
|
} else {
|
|
cx.emit(BackendEvent::error(
|
|
"Unsupported credential, expected nsec1... or bunker://...",
|
|
));
|
|
}
|
|
}
|
|
|
|
/// Create a fresh identity and login with it. The generated key is
|
|
/// persisted in the keyring like any other `nsec` credential.
|
|
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
|
|
let nsec = Keys::generate()
|
|
.secret_key()
|
|
.to_bech32()
|
|
.expect("infallible");
|
|
self.login_with_nsec(&nsec, cx);
|
|
}
|
|
|
|
/// Login with an `nsec1...` secret key. The credential is verified by
|
|
/// the signer flow and persisted in the keyring.
|
|
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
|
|
let nsec = nsec.trim().to_owned();
|
|
|
|
let keys = match SecretKey::parse(&nsec) {
|
|
Ok(secret) => Keys::new(secret),
|
|
Err(e) => {
|
|
cx.emit(BackendEvent::error(e.to_string()));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let write =
|
|
cx.write_credentials(USER_KEYRING, &keys.public_key().to_hex(), nsec.as_bytes());
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
if let Err(e) = write.await {
|
|
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
return Ok(());
|
|
}
|
|
|
|
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Login with a `bunker://...` URI (NIP-46). A fresh session key is
|
|
/// generated and embedded into the stored URI as `?master=<nsec>`, so
|
|
/// no separate keyring entry is needed. The auth URL, if any, is opened
|
|
/// in the default browser. The credential is persisted in the keyring
|
|
/// after the signer proves reachable.
|
|
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
|
|
let uri_string = uri.trim().to_owned();
|
|
|
|
let connect_uri = match NostrConnectUri::parse(&uri_string) {
|
|
Ok(uri) => uri,
|
|
Err(e) => {
|
|
cx.emit(BackendEvent::error(e.to_string()));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let keys = Keys::generate();
|
|
let credential = with_master_key(&uri_string, &keys);
|
|
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
let result = async {
|
|
let mut signer = NostrConnect::new(
|
|
connect_uri,
|
|
keys,
|
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
|
None,
|
|
)?;
|
|
signer.auth_url_handler(SignedAuthUrlHandler);
|
|
|
|
// Verify the signer before persisting the credential.
|
|
signer.get_public_key_async().await?;
|
|
write.await?;
|
|
|
|
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
|
|
|
Ok::<_, Error>(())
|
|
}
|
|
.await;
|
|
|
|
if let Err(e) = result {
|
|
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
}
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Remove the saved credential and reset to an anonymous session.
|
|
pub fn logout(&mut self, cx: &mut Context<Self>) {
|
|
let delete = cx.delete_credentials(USER_KEYRING);
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
delete.await.ok();
|
|
|
|
this.update(cx, |this, cx| {
|
|
this.signer.swap_inner(Keys::generate());
|
|
this.current_user = None;
|
|
this.passphrase_required = false;
|
|
cx.emit(BackendEvent::SignerChanged);
|
|
cx.emit(BackendEvent::SignerRequired);
|
|
cx.notify();
|
|
})?;
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Fetch the user's grasp list (kind `10317`) and add the listed grasp
|
|
/// servers as relays.
|
|
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
|
let client = self.client.clone();
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
let result = async {
|
|
let events = client.fetch_events(filters::grasp_list(public_key)).await?;
|
|
|
|
let urls: Vec<String> = events
|
|
.into_iter()
|
|
.max_by_key(|e| e.created_at)
|
|
.map(|e| {
|
|
e.tags
|
|
.iter()
|
|
.filter(|t| t.kind() == "g")
|
|
.filter_map(|t| t.content().map(str::to_owned))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
for url in urls {
|
|
client.add_relay(&url).await.ok();
|
|
}
|
|
client.connect().await;
|
|
|
|
Ok::<_, Error>(())
|
|
}
|
|
.await;
|
|
|
|
if let Err(e) = result {
|
|
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
}
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Get the nostr client.
|
|
pub fn client(&self) -> Client {
|
|
self.client.clone()
|
|
}
|
|
|
|
/// Get the current signer.
|
|
pub fn signer(&self) -> UniversalSigner {
|
|
self.signer.clone()
|
|
}
|
|
|
|
/// Get the current user's public key.
|
|
pub fn current_user(&self) -> Option<PublicKey> {
|
|
self.current_user
|
|
}
|
|
|
|
/// Whether the stored credential is NIP-49 encrypted and a passphrase
|
|
/// is still needed to resume the session.
|
|
pub fn passphrase_required(&self) -> bool {
|
|
self.passphrase_required
|
|
}
|
|
|
|
/// Surface an error message through [`BackendEvent::Error`].
|
|
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
|
|
cx.emit(BackendEvent::error(message));
|
|
}
|
|
|
|
/// Whether the relay bootstrap has completed.
|
|
pub fn is_connected(&self) -> bool {
|
|
self.connected
|
|
}
|
|
|
|
/// Progress of the in-flight negentropy sync, if any: `(total, current)`.
|
|
pub fn sync_progress(&self) -> Option<(u64, u64)> {
|
|
self.sync_progress
|
|
}
|
|
|
|
/// Update the signer (any type implementing the async signer traits,
|
|
/// e.g. `Keys`, `NostrConnect`, a browser extension proxy).
|
|
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
|
where
|
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
|
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
|
|
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
|
|
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
|
|
{
|
|
let task = cx.spawn(async move |this, cx| {
|
|
match new_signer.get_public_key_async().await {
|
|
Ok(public_key) => {
|
|
this.update(cx, |this, cx| {
|
|
this.signer.swap_inner(new_signer);
|
|
this.current_user = Some(public_key);
|
|
this.passphrase_required = false;
|
|
this.bootstrap_user(public_key, cx);
|
|
cx.emit(BackendEvent::SignerChanged);
|
|
cx.notify();
|
|
})?;
|
|
}
|
|
Err(e) => {
|
|
this.update(cx, |_this, cx| {
|
|
cx.emit(BackendEvent::error(e.to_string()));
|
|
})?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
});
|
|
self.tasks.push(task);
|
|
}
|
|
|
|
/// Add relays and connect to them.
|
|
pub fn add_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
|
|
let client = self.client.clone();
|
|
|
|
let task = cx.background_spawn(async move {
|
|
for url in urls {
|
|
client.add_relay(&url).await?;
|
|
}
|
|
client.connect().await;
|
|
Ok::<(), Error>(())
|
|
});
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
match task.await {
|
|
Ok(()) => {
|
|
this.update(cx, |this, cx| {
|
|
this.connected = true;
|
|
cx.emit(BackendEvent::Connected);
|
|
cx.notify();
|
|
})?;
|
|
}
|
|
Err(e) => {
|
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Add relays used only for discovery (e.g. NIP-65 indexers) and
|
|
/// connect to them. No subscriptions or writes are routed through them.
|
|
pub fn add_discovery_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
|
|
let client = self.client.clone();
|
|
|
|
let task = cx.background_spawn(async move {
|
|
for url in urls {
|
|
client
|
|
.add_relay(&url)
|
|
.capabilities(RelayCapabilities::DISCOVERY)
|
|
.await?;
|
|
}
|
|
client.connect().await;
|
|
Ok::<(), Error>(())
|
|
});
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
if let Err(e) = task.await {
|
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Start a persistent subscription. Matching events are stored in the
|
|
/// database automatically and surface as [`BackendEvent::NostrUpdate`].
|
|
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
|
let client = self.client.clone();
|
|
|
|
let task = cx.background_spawn(async move { client.subscribe(filter).await.map(|_| ()) });
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
if let Err(e) = task.await {
|
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Start a one-shot subscription targeted only at the bootstrap relays,
|
|
/// auto-closing after EOSE or a short timeout. Matching events are stored
|
|
/// in the database and surface as [`BackendEvent::NostrUpdate`] while the
|
|
/// subscription is open.
|
|
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
|
let client = self.client.clone();
|
|
|
|
let task =
|
|
cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await });
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
if let Err(e) = task.await {
|
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Negentropy-sync the given filter against the bootstrap relays:
|
|
/// reconciles the local database with the relays in both directions.
|
|
/// Emits [`BackendEvent::SyncProgress`] while running (throttled to
|
|
/// whole-percent changes) and [`BackendEvent::Synced`] on completion.
|
|
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
|
let client = self.client.clone();
|
|
|
|
self.sync_progress = Some((0, 0));
|
|
cx.notify();
|
|
|
|
let (tx, mut rx) = SyncProgress::channel();
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
let mut last_percent: u64 = 0;
|
|
|
|
while rx.changed().await.is_ok() {
|
|
let progress = *rx.borrow_and_update();
|
|
let percent = (progress.percentage() * 100.0) as u64;
|
|
|
|
if progress.current > 0 && percent != last_percent {
|
|
last_percent = percent;
|
|
|
|
let alive = this.update(cx, |this, cx| {
|
|
this.sync_progress = Some((progress.total, progress.current));
|
|
cx.emit(BackendEvent::SyncProgress {
|
|
total: progress.total,
|
|
current: progress.current,
|
|
});
|
|
cx.notify();
|
|
});
|
|
|
|
if alive.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}));
|
|
|
|
let task = cx.background_spawn(async move {
|
|
let opts = SyncOptions::default().progress(tx);
|
|
sync_bootstrap_only(&client, filter, opts).await
|
|
});
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
match task.await {
|
|
Ok(summary) => {
|
|
log::debug!(
|
|
"sync done: {} received, {} sent",
|
|
summary.received.len(),
|
|
summary.sent.len()
|
|
);
|
|
this.update(cx, |this, cx| {
|
|
this.sync_progress = None;
|
|
cx.emit(BackendEvent::Synced);
|
|
cx.notify();
|
|
})?;
|
|
}
|
|
Err(e) => {
|
|
this.update(cx, |this, cx| {
|
|
this.sync_progress = None;
|
|
cx.emit(BackendEvent::error(e.to_string()))
|
|
})?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Sign, broadcast and locally store an event. Emits
|
|
/// [`BackendEvent::Published`] on success so stores can refresh.
|
|
///
|
|
/// The returned receiver yields the outcome of this specific action,
|
|
/// so callers can show inline progress/errors instead of relying on
|
|
/// the global [`BackendEvent::Error`].
|
|
pub fn send(
|
|
&mut self,
|
|
builder: EventBuilder,
|
|
cx: &mut Context<Self>,
|
|
) -> flume::Receiver<Result<Event, Error>> {
|
|
let (tx, rx) = flume::bounded(1);
|
|
let client = self.client.clone();
|
|
let signer = self.signer.clone();
|
|
|
|
let task = cx.background_spawn(async move {
|
|
// Sign with the current signer, broadcast, and save locally so
|
|
// the event is immediately visible to database queries.
|
|
let event = builder.finalize_async(&signer).await?;
|
|
let output = client.send_event(&event).await?;
|
|
|
|
if output.success.is_empty() && !output.failed.is_empty() {
|
|
let reasons = output
|
|
.failed
|
|
.values()
|
|
.cloned()
|
|
.collect::<Vec<String>>()
|
|
.join(", ");
|
|
return Err(anyhow!("event not accepted by any relay: {reasons}"));
|
|
}
|
|
|
|
Ok(event)
|
|
});
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
let result = task.await;
|
|
|
|
match &result {
|
|
Ok(event) => {
|
|
this.update(cx, |_this, cx| {
|
|
cx.emit(BackendEvent::Published(Box::new(event.clone())));
|
|
})?;
|
|
}
|
|
Err(e) => {
|
|
this.update(cx, |_this, cx| {
|
|
cx.emit(BackendEvent::error(e.to_string()));
|
|
})?;
|
|
}
|
|
}
|
|
|
|
tx.send_async(result)
|
|
.await
|
|
.map_err(|_| anyhow!("action result receiver dropped"))
|
|
}));
|
|
|
|
rx
|
|
}
|
|
}
|
|
|
|
/// Subscribe only on the bootstrap relays, auto-closing after EOSE or a
|
|
/// short timeout. Use for one-shot data fetches (repo events, profiles)
|
|
/// instead of persistent gossip-routed subscriptions.
|
|
pub(crate) async fn subscribe_bootstrap_only(
|
|
client: &Client,
|
|
filters: Vec<Filter>,
|
|
) -> Result<(), Error> {
|
|
let opts = SubscribeAutoCloseOptions::default()
|
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
|
.timeout(Some(Duration::from_secs(10)));
|
|
|
|
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
|
.iter()
|
|
.map(|relay| (*relay, filters.clone()))
|
|
.collect();
|
|
|
|
client.subscribe(target).close_on(opts).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Negentropy-sync the filter against the bootstrap relays only.
|
|
pub(crate) async fn sync_bootstrap_only(
|
|
client: &Client,
|
|
filter: Filter,
|
|
opts: SyncOptions,
|
|
) -> Result<SyncSummary, Error> {
|
|
let output = client
|
|
.sync(filter)
|
|
.with(BOOTSTRAP_RELAYS)
|
|
.opts(opts)
|
|
.await?;
|
|
Ok(output.value)
|
|
}
|
|
|
|
/// Embed a NIP-46 session key into a bunker URI as `?master=<nsec>`.
|
|
fn with_master_key(uri: &str, keys: &Keys) -> String {
|
|
let separator = if uri.contains('?') { '&' } else { '?' };
|
|
let nsec = keys.secret_key().to_bech32().expect("infallible");
|
|
format!("{uri}{separator}master={nsec}")
|
|
}
|
|
|
|
/// Split a stored bunker credential into the plain URI and the session key.
|
|
/// Credentials without an embedded key (legacy) get a fresh one.
|
|
fn extract_master_key(credential: &str) -> (&str, Keys) {
|
|
match credential.split_once("master=") {
|
|
Some((base, nsec)) => {
|
|
let keys = SecretKey::parse(nsec)
|
|
.map(Keys::new)
|
|
.unwrap_or_else(|_| Keys::generate());
|
|
(base.trim_end_matches(['?', '&']), keys)
|
|
}
|
|
None => (credential, Keys::generate()),
|
|
}
|
|
}
|