Redesign for the v1 stable release (#3)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m26s

Only half done. Will continue in another PR.

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-02-04 01:43:21 +00:00
parent 014757cfc9
commit 32201554ec
174 changed files with 6165 additions and 8112 deletions

View File

@@ -9,11 +9,18 @@ common = { path = "../common" }
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
nostr-connect.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
smol.workspace = true
reqwest.workspace = true
flume.workspace = true
log.workspace = true
anyhow.workspace = true
webbrowser.workspace = true
serde.workspace = true
serde_json.workspace = true
rustls = "0.23"
petname = "2.0.2"

View File

@@ -20,6 +20,9 @@ pub struct Identity {
/// The public key of the account
pub public_key: Option<PublicKey>,
/// Whether the identity is owned by the user
pub owned: bool,
/// Status of the current user NIP-65 relays
relay_list: RelayState,
@@ -37,11 +40,18 @@ impl Identity {
pub fn new() -> Self {
Self {
public_key: None,
owned: true,
relay_list: RelayState::default(),
messaging_relays: RelayState::default(),
}
}
/// Resets the relay states to their default values.
pub fn reset_relay_state(&mut self) {
self.relay_list = RelayState::default();
self.messaging_relays = RelayState::default();
}
/// Sets the state of the NIP-65 relays.
pub fn set_relay_list_state(&mut self, state: RelayState) {
self.relay_list = state;
@@ -83,4 +93,9 @@ impl Identity {
pub fn unset_public_key(&mut self) {
self.public_key = None;
}
/// Sets whether the identity is owned by the user.
pub fn set_owned(&mut self, owned: bool) {
self.owned = owned;
}
}

View File

@@ -1,9 +1,11 @@
use std::collections::HashSet;
use std::os::unix::fs::PermissionsExt;
use std::time::Duration;
use anyhow::Error;
use common::{config_dir, BOOTSTRAP_RELAYS, SEARCH_RELAYS};
use anyhow::{anyhow, Error};
use common::{config_dir, CLIENT_NAME};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_connect::prelude::*;
use nostr_lmdb::NostrLmdb;
use nostr_sdk::prelude::*;
@@ -11,23 +13,50 @@ mod device;
mod event;
mod gossip;
mod identity;
mod nip05;
pub use device::*;
pub use event::*;
pub use gossip::*;
pub use identity::*;
pub use nip05::*;
use crate::identity::Identity;
pub fn init(cx: &mut App) {
NostrRegistry::set_global(cx.new(NostrRegistry::new), cx);
}
/// Default timeout for subscription
pub const TIMEOUT: u64 = 3;
/// Default delay for searching
pub const FIND_DELAY: u64 = 600;
/// Default limit for searching
pub const FIND_LIMIT: usize = 20;
/// Default timeout for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default Nostr Connect relay
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
/// Default subscription id for device gift wrap events
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
/// Default subscription id for user gift wrap events
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
/// Default avatar for new users
pub const DEFAULT_AVATAR: &str = "https://image.nostr.build/93bb6084457a42620849b6827f3f34f111ae5a4ac728638a989d4ed4b4bb3ac8.png";
/// Default vertex relays
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
/// Default search relays
pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.noswhere.com"];
/// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nos.social",
"wss://user.kindpag.es",
];
/// Default subscription id for gift wrap events
pub const GIFTWRAP_SUBSCRIPTION: &str = "giftwrap-events";
pub fn init(cx: &mut App) {
// Initialize the tokio runtime
gpui_tokio::init(cx);
NostrRegistry::set_global(cx.new(NostrRegistry::new), cx);
}
struct GlobalNostrRegistry(Entity<NostrRegistry>);
@@ -168,6 +197,14 @@ impl NostrRegistry {
}),
);
cx.defer(|cx| {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.get_identity(cx);
});
});
Self {
client,
app_keys,
@@ -190,6 +227,11 @@ impl NostrRegistry {
client.add_relay(url).await?;
}
// Add wot relay to the relay pool
for url in WOT_RELAYS.into_iter() {
client.add_relay(url).await?;
}
// Connect to all added relays
client.connect().await;
@@ -300,9 +342,15 @@ impl NostrRegistry {
let keys = Keys::generate();
let secret_key = keys.secret_key();
// Create directory and write secret key
std::fs::create_dir_all(dir.parent().unwrap())?;
std::fs::write(&dir, secret_key.to_secret_bytes())?;
// Set permissions to readonly
let mut perms = std::fs::metadata(&dir)?.permissions();
perms.set_mode(0o400);
std::fs::set_permissions(&dir, perms)?;
return Ok(keys);
}
};
@@ -385,7 +433,7 @@ impl NostrRegistry {
}
/// Set the signer for the nostr client and verify the public key
pub fn set_signer<T>(&mut self, signer: T, cx: &mut Context<Self>)
pub fn set_signer<T>(&mut self, signer: T, owned: bool, cx: &mut Context<Self>)
where
T: NostrSigner + 'static,
{
@@ -409,6 +457,8 @@ impl NostrRegistry {
Ok(public_key) => {
identity.update(cx, |this, cx| {
this.set_public_key(public_key);
this.reset_relay_state();
this.set_owned(owned);
cx.notify();
})?;
}
@@ -466,6 +516,18 @@ impl NostrRegistry {
match res {
Ok(event) => {
log::info!("Received relay list event: {event:?}");
// Construct a filter to continuously receive relay list events
let filter = Filter::new()
.kind(Kind::RelayList)
.author(public_key)
.since(Timestamp::now());
// Subscribe to the relay list events
client
.subscribe_to(BOOTSTRAP_RELAYS, vec![filter], None)
.await?;
return Ok(RelayState::Set);
}
Err(e) => {
@@ -556,13 +618,23 @@ impl NostrRegistry {
// Stream events from the write relays
let mut stream = client
.stream_events_from(urls, vec![filter], Duration::from_secs(TIMEOUT))
.stream_events_from(&urls, vec![filter], Duration::from_secs(TIMEOUT))
.await?;
while let Some((_url, res)) = stream.next().await {
match res {
Ok(event) => {
log::info!("Received messaging relays event: {event:?}");
// Construct a filter to continuously receive relay list events
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.since(Timestamp::now());
// Subscribe to the relay list events
client.subscribe_to(&urls, vec![filter], None).await?;
return Ok(RelayState::Set);
}
Err(e) => {
@@ -592,4 +664,327 @@ impl NostrRegistry {
Ok(())
}));
}
/// Get contact list for the current user
pub fn get_contact_list(&self, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
let client = self.client();
let public_key = self.identity().read(cx).public_key();
cx.background_spawn(async move {
let contacts = client.database().contacts_public_keys(public_key).await?;
let results = contacts.into_iter().collect();
Ok(results)
})
}
/// Set the metadata for the current user
pub fn set_metadata(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
let client = self.client();
let public_key = self.identity().read(cx).public_key();
let write_relays = self.write_relays(&public_key, cx);
let metadata = metadata.clone();
cx.background_spawn(async move {
let urls = write_relays.await;
let signer = client.signer().await?;
// Sign the new metadata event
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
// Send event to user's write relayss
client.send_event_to(urls, &event).await?;
Ok(())
})
}
/// Get local stored identity
fn get_identity(&mut self, cx: &mut Context<Self>) {
let read_credential = cx.read_credentials(CLIENT_NAME);
self.tasks.push(cx.spawn(async move |this, cx| {
match read_credential.await {
Ok(Some((_, secret))) => {
let secret = SecretKey::from_slice(&secret)?;
let keys = Keys::new(secret);
this.update(cx, |this, cx| {
this.set_signer(keys, false, cx);
})
.ok();
}
_ => {
this.update(cx, |this, cx| {
this.get_bunker(cx);
})
.ok();
}
}
Ok(())
}));
}
/// Create a new identity
fn create_identity(&mut self, cx: &mut Context<Self>) {
let client = self.client();
// Generate new keys
let keys = Keys::generate();
// Get write credential task
let write_credential = cx.write_credentials(
CLIENT_NAME,
&keys.public_key().to_hex(),
&keys.secret_key().to_secret_bytes(),
);
// Update the signer
self.set_signer(keys, false, cx);
// Spawn a task to set metadata and write the credentials
cx.background_spawn(async move {
let name = petname::petname(2, "-").unwrap_or("Cooper".to_string());
let avatar = Url::parse(DEFAULT_AVATAR).unwrap();
// Construct metadata for the identity
let metadata = Metadata::new()
.display_name(&name)
.name(&name)
.picture(avatar);
// Set metadata for the identity
if let Err(e) = client.set_metadata(&metadata).await {
log::error!("Failed to set metadata: {}", e);
}
// Write the credentials
if let Err(e) = write_credential.await {
log::error!("Failed to write credentials: {}", e);
}
})
.detach();
}
/// Get local stored bunker connection
fn get_bunker(&mut self, cx: &mut Context<Self>) {
let client = self.client();
let app_keys = self.app_keys().clone();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let task: Task<Result<NostrConnect, Error>> = cx.background_spawn(async move {
log::info!("Getting bunker connection");
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier("coop:account")
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
let uri = NostrConnectUri::parse(event.content)?;
let signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None)?;
Ok(signer)
} else {
Err(anyhow!("No account found"))
}
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(signer) => {
this.update(cx, |this, cx| {
this.set_signer(signer, true, cx);
})
.ok();
}
Err(e) => {
log::warn!("Failed to get bunker: {e}");
// Create a new identity if no stored bunker exists
this.update(cx, |this, cx| {
this.create_identity(cx);
})
.ok();
}
}
Ok(())
}));
}
/// Store the bunker connection for the next login
pub fn persist_bunker(&mut self, uri: NostrConnectUri, cx: &mut App) {
let client = self.client();
let rng_keys = Keys::generate();
self.tasks.push(cx.background_spawn(async move {
// Construct the event for application-specific data
let event = EventBuilder::new(Kind::ApplicationSpecificData, uri.to_string())
.tag(Tag::identifier("coop:account"))
.sign(&rng_keys)
.await?;
// Store the event in the database
client.database().save_event(&event).await?;
Ok(())
}));
}
/// Generate a direct nostr connection initiated by the client
pub fn client_connect(&self, relay: Option<RelayUrl>) -> (NostrConnect, NostrConnectUri) {
let app_keys = self.app_keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
// Determine the relay will be used for Nostr Connect
let relay = match relay {
Some(relay) => relay,
None => RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap(),
};
// Generate the nostr connect uri
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
// Generate the nostr connect
let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap();
// Handle the auth request
signer.auth_url_handler(CoopAuthUrlHandler);
(signer, uri)
}
/// Get the public key of a NIP-05 address
pub fn get_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
let client = self.client();
let http_client = cx.http_client();
cx.background_spawn(async move {
let profile = addr.profile(&http_client).await?;
let public_key = profile.public_key;
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(3)));
// Construct the filter for the metadata event
let filter = Filter::new()
.kind(Kind::Metadata)
.author(public_key)
.limit(1);
// Subscribe to bootstrap relays
client
.subscribe_to(BOOTSTRAP_RELAYS, vec![filter], Some(opts))
.await?;
Ok(public_key)
})
}
/// Perform a NIP-50 global search for user profiles based on a given query
pub fn search(&self, query: &str, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
let client = self.client();
let query = query.to_string();
cx.background_spawn(async move {
let mut results: Vec<PublicKey> = Vec::with_capacity(FIND_LIMIT);
// Construct the filter for the search query
let filter = Filter::new()
.search(query.to_lowercase())
.kind(Kind::Metadata)
.limit(FIND_LIMIT);
// Stream events from the search relays
let mut stream = client
.stream_events_from(SEARCH_RELAYS, vec![filter], Duration::from_secs(3))
.await?;
// Collect the results
while let Some((_url, res)) = stream.next().await {
if let Ok(event) = res {
results.push(event.pubkey);
}
}
if results.is_empty() {
return Err(anyhow!("No results for query {query}"));
}
Ok(results)
})
}
/// Perform a WoT (via Vertex) search for a given query.
pub fn wot_search(&self, query: &str, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
let client = self.client();
let query = query.to_string();
cx.background_spawn(async move {
let signer = client.signer().await?;
// Construct a vertex request event
let event = EventBuilder::new(Kind::Custom(5315), "")
.tags(vec![
Tag::custom(TagKind::custom("param"), vec!["search", &query]),
Tag::custom(TagKind::custom("param"), vec!["limit", "10"]),
])
.sign(&signer)
.await?;
// Send the event to vertex relays
let output = client.send_event_to(WOT_RELAYS, &event).await?;
// Construct a filter to get the response or error from vertex
let filter = Filter::new()
.kinds(vec![Kind::Custom(6315), Kind::Custom(7000)])
.event(output.id().to_owned());
// Stream events from the search relays
let mut stream = client
.stream_events_from(WOT_RELAYS, vec![filter], Duration::from_secs(3))
.await?;
while let Some((_url, res)) = stream.next().await {
if let Ok(event) = res {
match event.kind {
Kind::Custom(6315) => {
let content: serde_json::Value = serde_json::from_str(&event.content)?;
let pubkeys: Vec<PublicKey> = content
.as_array()
.into_iter()
.flatten()
.filter_map(|item| item.as_object())
.filter_map(|obj| obj.get("pubkey").and_then(|v| v.as_str()))
.filter_map(|pubkey_str| PublicKey::parse(pubkey_str).ok())
.collect();
return Ok(pubkeys);
}
Kind::Custom(7000) => {
return Err(anyhow!("Search error"));
}
_ => {}
}
}
}
Err(anyhow!("No results for query: {query}"))
})
}
}
#[derive(Debug, Clone)]
pub struct CoopAuthUrlHandler;
impl AuthUrlHandler for CoopAuthUrlHandler {
#[allow(mismatched_lifetime_syntaxes)]
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
Box::pin(async move {
webbrowser::open(auth_url.as_str())?;
Ok(())
})
}
}

60
crates/state/src/nip05.rs Normal file
View File

@@ -0,0 +1,60 @@
use std::sync::Arc;
use anyhow::Error;
use gpui::http_client::{AsyncBody, HttpClient};
use nostr_sdk::prelude::*;
use smol::io::AsyncReadExt;
#[allow(async_fn_in_trait)]
pub trait NostrAddress {
/// Get the NIP-05 profile
async fn profile(&self, client: &Arc<dyn HttpClient>) -> Result<Nip05Profile, Error>;
/// Verify the NIP-05 address
async fn verify(
&self,
client: &Arc<dyn HttpClient>,
public_key: &PublicKey,
) -> Result<bool, Error>;
}
impl NostrAddress for Nip05Address {
async fn profile(&self, client: &Arc<dyn HttpClient>) -> Result<Nip05Profile, Error> {
let mut body = Vec::new();
let mut res = client
.get(self.url().as_str(), AsyncBody::default(), false)
.await?;
// Read the response body into a vector
res.body_mut().read_to_end(&mut body).await?;
// Parse the JSON response
let json: Value = serde_json::from_slice(&body)?;
let profile = Nip05Profile::from_json(self, &json)?;
Ok(profile)
}
async fn verify(
&self,
client: &Arc<dyn HttpClient>,
public_key: &PublicKey,
) -> Result<bool, Error> {
let mut body = Vec::new();
let mut res = client
.get(self.url().as_str(), AsyncBody::default(), false)
.await?;
// Read the response body into a vector
res.body_mut().read_to_end(&mut body).await?;
// Parse the JSON response
let json: Value = serde_json::from_slice(&body)?;
// Verify the NIP-05 address
let verified = nip05::verify_from_json(public_key, self, &json);
Ok(verified)
}
}