add bootstrap flow
This commit is contained in:
Generated
+1
@@ -7785,6 +7785,7 @@ dependencies = [
|
|||||||
"flume 0.11.1",
|
"flume 0.11.1",
|
||||||
"gpui",
|
"gpui",
|
||||||
"nostr",
|
"nostr",
|
||||||
|
"nostr-connect",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"rustls",
|
"rustls",
|
||||||
"signed_core",
|
"signed_core",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub enum CloneTarget {
|
|||||||
UserRepo {
|
UserRepo {
|
||||||
/// `npub1...` or a NIP-05 identifier.
|
/// `npub1...` or a NIP-05 identifier.
|
||||||
user: String,
|
user: String,
|
||||||
relay_hint: Option<String>,
|
relay_hint: Option<RelayUrl>,
|
||||||
/// `d` tag identifier of the repository.
|
/// `d` tag identifier of the repository.
|
||||||
identifier: String,
|
identifier: String,
|
||||||
},
|
},
|
||||||
@@ -35,7 +35,10 @@ pub fn parse_clone_url(url: &str) -> Option<CloneTarget> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (relay_hint, identifier) = match third {
|
let (relay_hint, identifier) = match third {
|
||||||
Some(id) => (Some(percent_decode(second)), percent_decode(id)),
|
Some(id) => (
|
||||||
|
RelayUrl::parse(&percent_decode(second)).ok(),
|
||||||
|
percent_decode(id),
|
||||||
|
),
|
||||||
None => (None, percent_decode(second)),
|
None => (None, percent_decode(second)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,8 +81,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
target,
|
target,
|
||||||
CloneTarget::UserRepo {
|
CloneTarget::UserRepo {
|
||||||
user: "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr"
|
user: "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr".to_owned(),
|
||||||
.to_owned(),
|
|
||||||
relay_hint: None,
|
relay_hint: None,
|
||||||
identifier: "ngit".to_owned(),
|
identifier: "ngit".to_owned(),
|
||||||
}
|
}
|
||||||
@@ -88,15 +90,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_user_repo_with_relay_hint() {
|
fn parses_user_repo_with_relay_hint() {
|
||||||
let target = parse_clone_url(
|
let target = parse_clone_url("nostr://danconwaydev.com/relay.ngit.dev/ngit").unwrap();
|
||||||
"nostr://danconwaydev.com/relay.ngit.dev/ngit",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
target,
|
target,
|
||||||
CloneTarget::UserRepo {
|
CloneTarget::UserRepo {
|
||||||
user: "danconwaydev.com".to_owned(),
|
user: "danconwaydev.com".to_owned(),
|
||||||
relay_hint: Some("relay.ngit.dev".to_owned()),
|
relay_hint: RelayUrl::parse("relay.ngit.dev").ok(),
|
||||||
identifier: "ngit".to_owned(),
|
identifier: "ngit".to_owned(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -112,7 +111,7 @@ mod tests {
|
|||||||
target,
|
target,
|
||||||
CloneTarget::UserRepo {
|
CloneTarget::UserRepo {
|
||||||
user: "danconwaydev.com".to_owned(),
|
user: "danconwaydev.com".to_owned(),
|
||||||
relay_hint: Some("ws://localhost:7334".to_owned()),
|
relay_hint: RelayUrl::parse("ws://localhost:7334").ok(),
|
||||||
identifier: "my-local-only-repo".to_owned(),
|
identifier: "my-local-only-repo".to_owned(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use nostr_gossip_memory::prelude::*;
|
use nostr_gossip_memory::prelude::*;
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use nostr_lmdb::prelude::*;
|
use nostr_lmdb::prelude::*;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use nostr_memory::prelude::*;
|
use nostr_memory::prelude::*;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
use crate::signer::UniversalSigner;
|
use crate::signer::UniversalSigner;
|
||||||
|
|
||||||
@@ -51,6 +50,8 @@ impl NostrBackend {
|
|||||||
.gossip(NostrGossipMemory::unbounded())
|
.gossip(NostrGossipMemory::unbounded())
|
||||||
.gossip_config(GossipConfig::default().no_background_refresh())
|
.gossip_config(GossipConfig::default().no_background_refresh())
|
||||||
.connect_timeout(Duration::from_secs(10))
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.verify_subscriptions(true)
|
||||||
|
.ban_relay_on_mismatch(true)
|
||||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||||
timeout: Duration::from_secs(600),
|
timeout: Duration::from_secs(600),
|
||||||
})
|
})
|
||||||
@@ -72,6 +73,16 @@ impl NostrBackend {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a relay used only for discovery (e.g. NIP-65 indexer relays).
|
||||||
|
/// No subscriptions or writes are routed through it.
|
||||||
|
pub async fn add_discovery_relay(&self, url: &str) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.add_relay(url)
|
||||||
|
.capabilities(RelayCapabilities::DISCOVERY)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn connect(&self) {
|
pub async fn connect(&self) {
|
||||||
self.client.connect().await;
|
self.client.connect().await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ signed_nostr = { path = "../signed_nostr" }
|
|||||||
|
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
nostr-connect.workspace = true
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
|
|||||||
@@ -1,12 +1,38 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
||||||
|
use nostr_connect::prelude::*;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_nostr::{NostrBackend, UniversalSigner, Update};
|
use signed_core::filters;
|
||||||
|
use signed_nostr::{NostrBackend, SignedAuthUrlHandler, UniversalSigner, Update};
|
||||||
|
|
||||||
|
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`).
|
||||||
|
pub const USER_KEYRING: &str = "su.reya.signed#user";
|
||||||
|
/// Keyring entry holding the locally generated key for NIP-46 sessions.
|
||||||
|
pub const MASTER_KEYRING: &str = "su.reya.signed#master";
|
||||||
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum BackendEvent {
|
pub enum BackendEvent {
|
||||||
/// User has no signer configured.
|
/// User has no signer configured.
|
||||||
NoSigner,
|
SignerRequired,
|
||||||
/// The signer has changed (login/logout/account switch).
|
/// The signer has changed (login/logout/account switch).
|
||||||
SignerChanged,
|
SignerChanged,
|
||||||
/// Relay bootstrap finished.
|
/// Relay bootstrap finished.
|
||||||
@@ -77,11 +103,257 @@ impl Backend {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
let mut this = Self {
|
||||||
inner,
|
inner,
|
||||||
current_user: None,
|
current_user: None,
|
||||||
tasks: vec![pump],
|
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 backend = self.inner.clone();
|
||||||
|
|
||||||
|
let task = cx.background_spawn(async move {
|
||||||
|
for url in BOOTSTRAP_RELAYS {
|
||||||
|
backend.add_relay(url).await?;
|
||||||
|
}
|
||||||
|
for url in INDEXER_RELAYS {
|
||||||
|
backend.add_discovery_relay(url).await?;
|
||||||
|
}
|
||||||
|
backend.connect().await;
|
||||||
|
Ok::<(), Error>(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(()) => {
|
||||||
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?;
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
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);
|
||||||
|
let master = self.master_key(cx);
|
||||||
|
|
||||||
|
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 uri = NostrConnectUri::parse(&content)?;
|
||||||
|
let mut signer = NostrConnect::new(
|
||||||
|
uri,
|
||||||
|
master.await,
|
||||||
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||||
|
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
||||||
|
} 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(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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). 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 master = self.master_key(cx);
|
||||||
|
let write = cx.write_credentials(USER_KEYRING, "bunker", uri_string.as_bytes());
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let result = async {
|
||||||
|
let mut signer = NostrConnect::new(
|
||||||
|
connect_uri,
|
||||||
|
master.await,
|
||||||
|
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.inner.signer().swap_inner(Keys::generate());
|
||||||
|
this.current_user = None;
|
||||||
|
cx.emit(BackendEvent::SignerChanged);
|
||||||
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get (or generate and persist) the key used for NIP-46 sessions.
|
||||||
|
fn master_key(&self, cx: &App) -> Task<Keys> {
|
||||||
|
let task = cx.read_credentials(MASTER_KEYRING);
|
||||||
|
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
let (keys, new_key) = match task.await {
|
||||||
|
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
|
||||||
|
Ok(secret_key) => (Keys::new(secret_key), false),
|
||||||
|
_ => (Keys::generate(), true),
|
||||||
|
},
|
||||||
|
_ => (Keys::generate(), true),
|
||||||
|
};
|
||||||
|
|
||||||
|
if new_key {
|
||||||
|
let username = keys.public_key().to_hex();
|
||||||
|
let password = keys.secret_key().to_secret_bytes();
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
let task = cx.write_credentials(MASTER_KEYRING, &username, &password);
|
||||||
|
cx.background_spawn(async move { task.await.ok() }).detach();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 backend = self.inner.clone();
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let result = async {
|
||||||
|
let events = backend
|
||||||
|
.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 {
|
||||||
|
backend.add_relay(&url).await.ok();
|
||||||
|
}
|
||||||
|
backend.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.
|
/// Get the nostr client.
|
||||||
@@ -114,6 +386,7 @@ impl Backend {
|
|||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.inner.signer().swap_inner(new_signer);
|
this.inner.signer().swap_inner(new_signer);
|
||||||
this.current_user = Some(public_key);
|
this.current_user = Some(public_key);
|
||||||
|
this.bootstrap_user(public_key, cx);
|
||||||
cx.emit(BackendEvent::SignerChanged);
|
cx.emit(BackendEvent::SignerChanged);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
@@ -155,6 +428,27 @@ impl Backend {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 backend = self.inner.clone();
|
||||||
|
|
||||||
|
let task = cx.background_spawn(async move {
|
||||||
|
for url in urls {
|
||||||
|
backend.add_discovery_relay(&url).await?;
|
||||||
|
}
|
||||||
|
backend.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
|
/// Start a persistent subscription. Matching events are stored in the
|
||||||
/// database automatically and surface as [`BackendEvent::NostrUpdate`].
|
/// database automatically and surface as [`BackendEvent::NostrUpdate`].
|
||||||
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||||
|
|||||||
Reference in New Issue
Block a user