This commit is contained in:
2026-08-08 16:38:38 +07:00
parent b3b0824e83
commit 5ba437ed48
9 changed files with 180 additions and 186 deletions
Generated
+1
View File
@@ -7784,6 +7784,7 @@ dependencies = [
name = "signed_core" name = "signed_core"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"gpui",
"nostr", "nostr",
] ]
+1
View File
@@ -5,4 +5,5 @@ edition.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
gpui.workspace = true
nostr.workspace = true nostr.workspace = true
+18 -13
View File
@@ -1,3 +1,4 @@
use gpui::SharedString;
use nostr::prelude::*; use nostr::prelude::*;
/// Parsed NIP-34 repository announcement (plain data, ready for the UI). /// Parsed NIP-34 repository announcement (plain data, ready for the UI).
@@ -9,14 +10,14 @@ pub struct Announcement {
pub created_at: Timestamp, pub created_at: Timestamp,
/// Repository ID (`d` tag). /// Repository ID (`d` tag).
pub id: String, pub id: String,
pub name: Option<String>, pub name: Option<SharedString>,
pub description: Option<String>, pub description: Option<SharedString>,
/// Webpage URLs for browsing. /// Webpage URLs for browsing.
pub web: Vec<String>, pub web: Vec<String>,
/// URLs for `git clone`. /// URLs for `git clone`.
pub clone: Vec<String>, pub clone: Vec<String>,
/// Relays the repository monitors for patches and issues. /// Relays the repository monitors for patches and issues.
pub relays: Vec<String>, pub relays: Vec<RelayUrl>,
/// Earliest unique commit ID (`r` tag with `euc` marker). /// Earliest unique commit ID (`r` tag with `euc` marker).
pub euc: Option<String>, pub euc: Option<String>,
/// Other recognized maintainers. /// Other recognized maintainers.
@@ -33,11 +34,11 @@ impl Announcement {
} }
let mut id: Option<String> = None; let mut id: Option<String> = None;
let mut name: Option<String> = None; let mut name: Option<SharedString> = None;
let mut description: Option<String> = None; let mut description: Option<SharedString> = None;
let mut web: Vec<String> = Vec::new(); let mut web: Vec<String> = Vec::new();
let mut clone: Vec<String> = Vec::new(); let mut clone: Vec<String> = Vec::new();
let mut relays: Vec<String> = Vec::new(); let mut relays: Vec<RelayUrl> = Vec::new();
let mut euc: Option<String> = None; let mut euc: Option<String> = None;
let mut maintainers: Vec<PublicKey> = Vec::new(); let mut maintainers: Vec<PublicKey> = Vec::new();
let mut hashtags: Vec<String> = Vec::new(); let mut hashtags: Vec<String> = Vec::new();
@@ -56,15 +57,13 @@ impl Announcement {
} }
match Nip34Tag::parse(tag.as_slice()) { match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Name(value)) => name = Some(value), Ok(Nip34Tag::Name(value)) => name = Some(value.into()),
Ok(Nip34Tag::Description(value)) => description = Some(value), Ok(Nip34Tag::Description(value)) => description = Some(value.into()),
Ok(Nip34Tag::Web(urls)) => web.extend(urls.into_iter().map(|url| url.to_string())), Ok(Nip34Tag::Web(urls)) => web.extend(urls.into_iter().map(|url| url.to_string())),
Ok(Nip34Tag::Clone(urls)) => { Ok(Nip34Tag::Clone(urls)) => {
clone.extend(urls.into_iter().map(|url| url.to_string())) clone.extend(urls.into_iter().map(|url| url.to_string()))
} }
Ok(Nip34Tag::Relays(urls)) => { Ok(Nip34Tag::Relays(urls)) => relays.extend(urls),
relays.extend(urls.into_iter().map(|url| url.to_string()))
}
Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()), Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()),
Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys), Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys),
_ => {} _ => {}
@@ -144,7 +143,10 @@ mod tests {
); );
assert_eq!(announcement.web, vec!["https://example.com/repo"]); assert_eq!(announcement.web, vec!["https://example.com/repo"]);
assert_eq!(announcement.clone, vec!["https://example.com/repo.git"]); assert_eq!(announcement.clone, vec!["https://example.com/repo.git"]);
assert_eq!(announcement.relays, vec!["wss://relay.example.com"]); assert_eq!(
announcement.relays,
vec![RelayUrl::parse("wss://relay.example.com").unwrap()]
);
assert_eq!( assert_eq!(
announcement.euc.as_deref(), announcement.euc.as_deref(),
Some("aa231c4c6a5777dc89b42207b499891a344add5c") Some("aa231c4c6a5777dc89b42207b499891a344add5c")
@@ -185,7 +187,10 @@ mod tests {
// An invalid URL keeps the whole clone tag from being parsed. // An invalid URL keeps the whole clone tag from being parsed.
assert!(announcement.clone.is_empty()); assert!(announcement.clone.is_empty());
assert_eq!(announcement.relays, vec!["wss://good.example.com"]); assert_eq!(
announcement.relays,
vec![RelayUrl::parse("wss://good.example.com").unwrap()]
);
assert!(announcement.maintainers.is_empty()); assert!(announcement.maintainers.is_empty());
} }
+133 -143
View File
@@ -243,160 +243,135 @@ impl Backend {
/// Decrypt the NIP-49 encrypted credential stored in the keyring with /// Decrypt the NIP-49 encrypted credential stored in the keyring with
/// the given passphrase and resume the session. /// the given passphrase and resume the session.
/// ///
/// The scrypt decryption runs off the UI thread. The returned receiver /// The scrypt decryption runs off the UI thread. The returned task
/// yields the public key on success, or the failure reason (e.g. wrong /// yields the public key on success, or the failure reason (e.g. wrong
/// passphrase), so callers can render inline errors. /// passphrase), so callers can render inline errors.
pub fn restore_with_passphrase( pub fn restore_with_passphrase(
&mut self, &mut self,
password: &str, password: &str,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> flume::Receiver<Result<PublicKey, Error>> { ) -> Task<Result<PublicKey, Error>> {
let (tx, rx) = flume::bounded(1);
let password = password.to_owned(); let password = password.to_owned();
let user = cx.read_credentials(USER_KEYRING); let user = cx.read_credentials(USER_KEYRING);
self.tasks.push(cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let result = async { let content = user
let content = user .await?
.await? .map(|(_username, secret)| String::from_utf8(secret))
.map(|(_username, secret)| String::from_utf8(secret)) .transpose()?
.transpose()? .ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?;
.ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?;
if !content.starts_with("ncryptsec1") { if !content.starts_with("ncryptsec1") {
Err(anyhow!("stored credential is not passphrase-encrypted"))?; return 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(); let decrypt_task = cx.background_spawn(async move {
Ok(()) let encrypted = EncryptedSecretKey::from_bech32(&content)?;
})); let secret = encrypted.decrypt(&password)?;
Ok::<_, Error>(Keys::new(secret))
});
rx let keys = decrypt_task.await?;
let public_key = keys.public_key();
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
Ok(public_key)
})
} }
/// Create a new identity: generate keys, encrypt the secret key with the /// Create a new identity: generate keys, encrypt the secret key with the
/// passphrase (NIP-49) and persist it in the keyring, then publish the /// passphrase (NIP-49) and persist it in the keyring, then publish the
/// user's NIP-65 relay list, metadata and grasp list. /// user's NIP-65 relay list, metadata and grasp list.
/// ///
/// The heavy encryption runs off the UI thread. The returned receiver /// The heavy encryption runs off the UI thread. The returned task yields
/// yields the new public key on success, or the failure reason, so /// the new public key on success, or the failure reason, so callers can
/// callers can render progress and inline errors. /// render progress and inline errors.
pub fn create_identity( pub fn create_identity(
&mut self, &mut self,
name: &str, name: &str,
password: &str, password: &str,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> flume::Receiver<Result<PublicKey, Error>> { ) -> Task<Result<PublicKey, Error>> {
let (tx, rx) = flume::bounded(1);
let name = name.trim().to_owned(); let name = name.trim().to_owned();
let password = password.to_owned(); let password = password.to_owned();
let validation_error = if name.is_empty() || name.len() > 255 { if name.is_empty() || name.len() > 255 {
Some("Name must be 1-255 characters") return Task::ready(Err(anyhow!("Name must be 1-255 characters")));
} else if password.is_empty() { }
Some("Passphrase must not be empty") if password.is_empty() {
} else { return Task::ready(Err(anyhow!("Passphrase must not be empty")));
None
};
if let Some(message) = validation_error {
tx.try_send(Err(anyhow!(message))).ok();
return rx;
} }
let job = cx.background_spawn(async move { cx.spawn(async move |this, cx| {
let keys = Keys::generate(); let job = cx.background_spawn(async move {
let encrypted = let keys = Keys::generate();
EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?; let encrypted =
let ncryptsec = encrypted.to_bech32()?; EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?;
Ok::<_, Error>((keys, ncryptsec)) let ncryptsec = encrypted.to_bech32()?;
}); Ok::<_, Error>((keys, ncryptsec))
});
self.tasks.push(cx.spawn(async move |this, cx| { let (keys, ncryptsec) = job.await?;
let result = async { let public_key = keys.public_key();
let (keys, ncryptsec) = job.await?;
let public_key = keys.public_key();
// Persist the encrypted credential. // Persist the encrypted credential.
let write = cx.update(|cx| { let write = cx.update(|cx| {
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes()) cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
}); });
write.await?; write.await?;
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
// Become the new identity, so the publishes below are // Become the new identity, so the publishes below are
// signed with the new keys. // signed with the new keys.
this.signer.swap_inner(keys); this.signer.swap_inner(keys);
this.current_user = Some(public_key); this.current_user = Some(public_key);
this.bootstrap_user(public_key, cx); this.bootstrap_user(public_key, cx);
cx.emit(BackendEvent::SignerChanged); cx.emit(BackendEvent::SignerChanged);
cx.notify(); cx.notify();
let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [ let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [
( (
RelayUrl::parse("wss://relay.primal.net").unwrap(), RelayUrl::parse("wss://relay.primal.net").unwrap(),
Some(RelayMetadata::Read), Some(RelayMetadata::Read),
), ),
( (
RelayUrl::parse("wss://relay.ditto.pub").unwrap(), RelayUrl::parse("wss://relay.ditto.pub").unwrap(),
Some(RelayMetadata::Read), Some(RelayMetadata::Read),
), ),
( (
RelayUrl::parse("wss://relay.nostr.net").unwrap(), RelayUrl::parse("wss://relay.nostr.net").unwrap(),
Some(RelayMetadata::Write), Some(RelayMetadata::Write),
), ),
( (
RelayUrl::parse("wss://nos.lol").unwrap(), RelayUrl::parse("wss://nos.lol").unwrap(),
Some(RelayMetadata::Write), Some(RelayMetadata::Write),
), ),
] ]
.to_vec(); .to_vec();
this.send(RelayList::new(relays).into_event_builder(), cx); this.send_fire_and_forget(RelayList::new(relays).into_event_builder(), cx);
let metadata = Metadata::new() let metadata = Metadata::new()
.name(&name) .name(&name)
.display_name(&name) .display_name(&name)
.into_event_builder(); .into_event_builder();
this.send(metadata, cx); this.send_fire_and_forget(metadata, cx);
let grasp_servers: Vec<RelayUrl> = let grasp_servers: Vec<RelayUrl> = ["wss://gitnostr.com", "wss://relay.ngit.dev"]
["wss://gitnostr.com", "wss://relay.ngit.dev"] .into_iter()
.into_iter() .map(|url| RelayUrl::parse(url).expect("valid relay URL"))
.map(|url| RelayUrl::parse(url).expect("valid relay URL")) .collect();
.collect();
this.send(GitUserGraspList { grasp_servers }.into_event_builder(), cx); this.send_fire_and_forget(
})?; GitUserGraspList { grasp_servers }.into_event_builder(),
cx,
);
})?;
Ok(public_key) Ok(public_key)
} })
.await;
tx.send_async(result).await.ok();
Ok(())
}));
rx
} }
/// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on /// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on
@@ -786,59 +761,74 @@ impl Backend {
/// Sign, broadcast and locally store an event. Emits /// Sign, broadcast and locally store an event. Emits
/// [`BackendEvent::Published`] on success so stores can refresh. /// [`BackendEvent::Published`] on success so stores can refresh.
/// ///
/// The returned receiver yields the outcome of this specific action, /// The returned task yields the outcome of this specific action, so
/// so callers can show inline progress/errors instead of relying on /// callers can show inline progress/errors instead of relying on
/// the global [`BackendEvent::Error`]. /// the global [`BackendEvent::Error`]. The task is owned by the caller;
/// dropping it cancels the publish.
pub fn send( pub fn send(
&mut self, &mut self,
builder: EventBuilder, builder: EventBuilder,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> flume::Receiver<Result<Event, Error>> { ) -> Task<Result<Event, Error>> {
let (tx, rx) = flume::bounded(1);
let client = self.client.clone(); let client = self.client.clone();
let signer = self.signer.clone(); let signer = self.signer.clone();
let task = cx.background_spawn(async move { cx.spawn(async move |this, cx| {
// Sign with the current signer, broadcast, and save locally so // Sign with the current signer, broadcast, and save locally so
// the event is immediately visible to database queries. // the event is immediately visible to database queries.
let event = builder.finalize_async(&signer).await?; let work = cx.background_spawn(async move {
let output = client.send_event(&event).await?; let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).await?;
if output.success.is_empty() && !output.failed.is_empty() { if output.success.is_empty() && !output.failed.is_empty() {
let reasons = output let reasons = output
.failed .failed
.values() .values()
.cloned() .cloned()
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(", "); .join(", ");
return Err(anyhow!("event not accepted by any relay: {reasons}")); return Err(anyhow!("event not accepted by any relay: {reasons}"));
} }
Ok(event) Ok(event)
}); });
self.tasks.push(cx.spawn(async move |this, cx| { let result = work.await;
let result = task.await;
match &result { match &result {
Ok(event) => { Ok(event) => {
this.update(cx, |_this, cx| { this.update(cx, |_this, cx| {
cx.emit(BackendEvent::Published(Box::new(event.clone()))); cx.emit(BackendEvent::Published(Box::new(event.clone())));
})?; })
.ok();
} }
Err(e) => { Err(e) => {
this.update(cx, |_this, cx| { this.update(cx, |_this, cx| {
cx.emit(BackendEvent::error(e.to_string())); cx.emit(BackendEvent::error(e.to_string()));
})?; })
.ok();
} }
} }
tx.send_async(result) result
.await })
.map_err(|_| anyhow!("action result receiver dropped")) }
}));
rx /// Sign, broadcast and store an event without awaiting the result;
/// failures surface through [`BackendEvent::Error`]. The spawned task is
/// owned by the backend, so it is cancelled when the backend is dropped.
fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
let task = self.send(builder, cx);
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();
}
Ok(())
}));
} }
} }
+7 -6
View File
@@ -1,5 +1,5 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::Error; use anyhow::Error;
@@ -73,8 +73,8 @@ const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
/// data; the whole store notifies on change. /// data; the whole store notifies on change.
pub struct ProfileStore { pub struct ProfileStore {
profiles: HashMap<PublicKey, Profile>, profiles: HashMap<PublicKey, Profile>,
/// Public keys we've already requested this session. /// Public keys we've already requested this session (main thread only).
seen: RwLock<HashSet<PublicKey>>, seen: RefCell<HashSet<PublicKey>>,
/// Sender for queuing fetch requests, batched by a background task. /// Sender for queuing fetch requests, batched by a background task.
sender: Sender<PublicKey>, sender: Sender<PublicKey>,
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
@@ -133,7 +133,7 @@ impl ProfileStore {
let mut store = Self { let mut store = Self {
profiles: HashMap::new(), profiles: HashMap::new(),
seen: RwLock::new(HashSet::new()), seen: RefCell::new(HashSet::new()),
sender, sender,
tasks, tasks,
_subscription: subscription, _subscription: subscription,
@@ -152,7 +152,7 @@ impl ProfileStore {
let public_key = *public_key; let public_key = *public_key;
if self.seen.write().unwrap().insert(public_key) if self.seen.borrow_mut().insert(public_key)
&& let Err(e) = self.sender.send(public_key) && let Err(e) = self.sender.send(public_key)
{ {
log::warn!("failed to queue profile fetch: {e}"); log::warn!("failed to queue profile fetch: {e}");
@@ -232,7 +232,8 @@ impl ProfileStore {
/// Re-read the latest metadata of every requested author from the local /// Re-read the latest metadata of every requested author from the local
/// database (used after a sync, which produces no NostrUpdate events). /// database (used after a sync, which produces no NostrUpdate events).
fn apply_seen(&mut self, cx: &mut Context<Self>) { fn apply_seen(&mut self, cx: &mut Context<Self>) {
let authors: Vec<PublicKey> = self.seen.read().unwrap().iter().copied().collect(); let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
if authors.is_empty() { if authors.is_empty() {
return; return;
} }
+7 -8
View File
@@ -94,9 +94,10 @@ impl RepoStore {
/// Fetch this repository's events from the bootstrap relays (one-shot, /// Fetch this repository's events from the bootstrap relays (one-shot,
/// auto-closing subscription). /// auto-closing subscription).
fn subscribe_remote(&mut self, cx: &mut Context<Self>) { fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let addr = self.addr.clone(); let addr = self.addr.clone();
Backend::global(cx).update(cx, |backend, cx| { backend.update(cx, |backend, cx| {
let mut repo_filters = vec![ let mut repo_filters = vec![
filters::announcement(&addr), filters::announcement(&addr),
filters::state(&addr), filters::state(&addr),
@@ -309,20 +310,18 @@ impl RepoStore {
fn send(&mut self, builder: EventBuilder, cx: &mut Context<Self>) { fn send(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
let rx = Backend::global(cx).update(cx, |backend, cx| backend.send(builder, cx)); let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| backend.send(builder, cx));
let task = cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
if let Ok(Err(e)) = rx.recv_async().await { if let Err(e) = task.await {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.last_error = Some(e.to_string()); this.last_error = Some(e.to_string());
cx.notify(); cx.notify();
})?; })?;
} }
Ok(()) Ok(())
}); }));
self.tasks.push(task);
} }
} }
-1
View File
@@ -58,7 +58,6 @@ impl RepoListView {
let name = announcement let name = announcement
.name .name
.clone() .clone()
.map(|s| SharedString::from(s.trim()))
.unwrap_or_else(|| SharedString::from(announcement.id.clone())); .unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let description = announcement.description.clone().unwrap_or_default(); let description = announcement.description.clone().unwrap_or_default();
@@ -103,21 +103,20 @@ pub fn open(
state.error = None; state.error = None;
}); });
let rx = backend.update(cx, |backend, cx| { let task = backend.update(cx, |backend, cx| {
backend.create_identity(&name, &pass, cx) backend.create_identity(&name, &pass, cx)
}); });
let handle = window.window_handle(); let handle = window.window_handle();
let state = state.clone(); let state = state.clone();
cx.spawn(async move |cx| match rx.recv_async().await { cx.spawn(async move |cx| match task.await {
Ok(Ok(_)) => { Ok(_) => {
cx.update_window(handle, |_, window, cx| { cx.update_window(handle, |_, window, cx| {
window.close_dialog(cx); window.close_dialog(cx);
}) })
.ok(); .ok();
} }
Ok(Err(e)) => { Err(e) => {
cx.update_window(handle, |_, _window, cx| { cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| { state.update(cx, |state, _| {
state.busy = false; state.busy = false;
@@ -126,7 +125,6 @@ pub fn open(
}) })
.ok(); .ok();
} }
Err(_) => {}
}) })
.detach(); .detach();
} }
@@ -118,18 +118,19 @@ fn unlock(
state.error = None; state.error = None;
}); });
let rx = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx)); let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
let handle = *handle; let handle = *handle;
let state = state.clone(); let state = state.clone();
cx.spawn(async move |cx| match rx.recv_async().await { cx.spawn(async move |cx| match task.await {
Ok(Ok(_)) => { Ok(_) => {
cx.update_window(handle, |_, window, cx| window.close_dialog(cx)) cx.update_window(handle, |_this, window, cx| {
.ok(); window.close_dialog(cx);
})
.ok();
} }
Ok(Err(e)) => { Err(e) => {
cx.update_window(handle, |_, _window, cx| { cx.update_window(handle, |_this, _window, cx| {
state.update(cx, |state, _| { state.update(cx, |state, _| {
state.busy = false; state.busy = false;
state.error = Some(e.to_string().into()); state.error = Some(e.to_string().into());
@@ -137,7 +138,6 @@ fn unlock(
}) })
.ok(); .ok();
} }
Err(_) => {}
}) })
.detach(); .detach();
} }