From 5ba437ed48303374ba636add8dfb965c543f5ec5 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 8 Aug 2026 16:38:25 +0700 Subject: [PATCH] update --- Cargo.lock | 1 + crates/signed_core/Cargo.toml | 1 + crates/signed_core/src/model.rs | 31 +- crates/signed_state/src/backend.rs | 276 +++++++++--------- crates/signed_state/src/profile.rs | 13 +- crates/signed_state/src/repo.rs | 15 +- crates/workspace/src/views/repo_list.rs | 1 - .../src/views/sidebar/onboarding_dialog.rs | 10 +- .../src/views/sidebar/passphrase_dialog.rs | 18 +- 9 files changed, 180 insertions(+), 186 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8015da2..c8a5c98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7784,6 +7784,7 @@ dependencies = [ name = "signed_core" version = "1.0.0" dependencies = [ + "gpui", "nostr", ] diff --git a/crates/signed_core/Cargo.toml b/crates/signed_core/Cargo.toml index 5b1e5ca..bd77e36 100644 --- a/crates/signed_core/Cargo.toml +++ b/crates/signed_core/Cargo.toml @@ -5,4 +5,5 @@ edition.workspace = true publish.workspace = true [dependencies] +gpui.workspace = true nostr.workspace = true diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 6d104f3..fbb7261 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -1,3 +1,4 @@ +use gpui::SharedString; use nostr::prelude::*; /// Parsed NIP-34 repository announcement (plain data, ready for the UI). @@ -9,14 +10,14 @@ pub struct Announcement { pub created_at: Timestamp, /// Repository ID (`d` tag). pub id: String, - pub name: Option, - pub description: Option, + pub name: Option, + pub description: Option, /// Webpage URLs for browsing. pub web: Vec, /// URLs for `git clone`. pub clone: Vec, /// Relays the repository monitors for patches and issues. - pub relays: Vec, + pub relays: Vec, /// Earliest unique commit ID (`r` tag with `euc` marker). pub euc: Option, /// Other recognized maintainers. @@ -33,11 +34,11 @@ impl Announcement { } let mut id: Option = None; - let mut name: Option = None; - let mut description: Option = None; + let mut name: Option = None; + let mut description: Option = None; let mut web: Vec = Vec::new(); let mut clone: Vec = Vec::new(); - let mut relays: Vec = Vec::new(); + let mut relays: Vec = Vec::new(); let mut euc: Option = None; let mut maintainers: Vec = Vec::new(); let mut hashtags: Vec = Vec::new(); @@ -56,15 +57,13 @@ impl Announcement { } match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Name(value)) => name = Some(value), - Ok(Nip34Tag::Description(value)) => description = Some(value), + Ok(Nip34Tag::Name(value)) => name = Some(value.into()), + Ok(Nip34Tag::Description(value)) => description = Some(value.into()), Ok(Nip34Tag::Web(urls)) => web.extend(urls.into_iter().map(|url| url.to_string())), Ok(Nip34Tag::Clone(urls)) => { clone.extend(urls.into_iter().map(|url| url.to_string())) } - Ok(Nip34Tag::Relays(urls)) => { - relays.extend(urls.into_iter().map(|url| url.to_string())) - } + Ok(Nip34Tag::Relays(urls)) => relays.extend(urls), Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()), 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.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!( announcement.euc.as_deref(), Some("aa231c4c6a5777dc89b42207b499891a344add5c") @@ -185,7 +187,10 @@ mod tests { // An invalid URL keeps the whole clone tag from being parsed. 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()); } diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index f5172dd..82128b0 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -243,160 +243,135 @@ impl Backend { /// 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 + /// The scrypt decryption runs off the UI thread. The returned task /// 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, - ) -> flume::Receiver> { - let (tx, rx) = flume::bounded(1); - + ) -> Task> { 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"))?; + cx.spawn(async move |this, cx| { + 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) + if !content.starts_with("ncryptsec1") { + return Err(anyhow!("stored credential is not passphrase-encrypted")); } - .await; - tx.send_async(result).await.ok(); - Ok(()) - })); + let decrypt_task = cx.background_spawn(async move { + 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 /// 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. + /// The heavy encryption runs off the UI thread. The returned task 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, - ) -> flume::Receiver> { - let (tx, rx) = flume::bounded(1); - + ) -> Task> { 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; + if name.is_empty() || name.len() > 255 { + return Task::ready(Err(anyhow!("Name must be 1-255 characters"))); + } + if password.is_empty() { + return Task::ready(Err(anyhow!("Passphrase must not be empty"))); } - 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)) - }); + cx.spawn(async move |this, cx| { + 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(); + 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?; + // 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(); + 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)> = [ - ( - 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(); + let relays: Vec<(RelayUrl, Option)> = [ + ( + 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); + this.send_fire_and_forget(RelayList::new(relays).into_event_builder(), cx); - let metadata = Metadata::new() - .name(&name) - .display_name(&name) - .into_event_builder(); + let metadata = Metadata::new() + .name(&name) + .display_name(&name) + .into_event_builder(); - this.send(metadata, cx); + this.send_fire_and_forget(metadata, cx); - let grasp_servers: Vec = - ["wss://gitnostr.com", "wss://relay.ngit.dev"] - .into_iter() - .map(|url| RelayUrl::parse(url).expect("valid relay URL")) - .collect(); + let grasp_servers: Vec = ["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); - })?; + this.send_fire_and_forget( + GitUserGraspList { grasp_servers }.into_event_builder(), + cx, + ); + })?; - Ok(public_key) - } - .await; - - tx.send_async(result).await.ok(); - - Ok(()) - })); - - rx + Ok(public_key) + }) } /// 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 /// [`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`]. + /// The returned task yields the outcome of this specific action, so + /// callers can show inline progress/errors instead of relying on + /// the global [`BackendEvent::Error`]. The task is owned by the caller; + /// dropping it cancels the publish. pub fn send( &mut self, builder: EventBuilder, cx: &mut Context, - ) -> flume::Receiver> { - let (tx, rx) = flume::bounded(1); + ) -> Task> { let client = self.client.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 // the event is immediately visible to database queries. - let event = builder.finalize_async(&signer).await?; - let output = client.send_event(&event).await?; + let work = cx.background_spawn(async move { + 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::>() - .join(", "); - return Err(anyhow!("event not accepted by any relay: {reasons}")); - } + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + 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 = task.await; + let result = work.await; match &result { Ok(event) => { this.update(cx, |_this, cx| { cx.emit(BackendEvent::Published(Box::new(event.clone()))); - })?; + }) + .ok(); } Err(e) => { this.update(cx, |_this, cx| { cx.emit(BackendEvent::error(e.to_string())); - })?; + }) + .ok(); } } - tx.send_async(result) - .await - .map_err(|_| anyhow!("action result receiver dropped")) - })); + result + }) + } - 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) { + 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(()) + })); } } diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index cc2e714..00fa896 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -1,5 +1,5 @@ +use std::cell::RefCell; use std::collections::{HashMap, HashSet}; -use std::sync::RwLock; use std::time::{Duration, Instant}; use anyhow::Error; @@ -73,8 +73,8 @@ const BATCH_TIMEOUT: Duration = Duration::from_millis(500); /// data; the whole store notifies on change. pub struct ProfileStore { profiles: HashMap, - /// Public keys we've already requested this session. - seen: RwLock>, + /// Public keys we've already requested this session (main thread only). + seen: RefCell>, /// Sender for queuing fetch requests, batched by a background task. sender: Sender, tasks: Vec>>, @@ -133,7 +133,7 @@ impl ProfileStore { let mut store = Self { profiles: HashMap::new(), - seen: RwLock::new(HashSet::new()), + seen: RefCell::new(HashSet::new()), sender, tasks, _subscription: subscription, @@ -152,7 +152,7 @@ impl ProfileStore { 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) { 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 /// database (used after a sync, which produces no NostrUpdate events). fn apply_seen(&mut self, cx: &mut Context) { - let authors: Vec = self.seen.read().unwrap().iter().copied().collect(); + let authors: Vec = self.seen.borrow().iter().copied().collect(); + if authors.is_empty() { return; } diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 6c85cec..baecad4 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -94,9 +94,10 @@ impl RepoStore { /// Fetch this repository's events from the bootstrap relays (one-shot, /// auto-closing subscription). fn subscribe_remote(&mut self, cx: &mut Context) { + let backend = Backend::global(cx); let addr = self.addr.clone(); - Backend::global(cx).update(cx, |backend, cx| { + backend.update(cx, |backend, cx| { let mut repo_filters = vec![ filters::announcement(&addr), filters::state(&addr), @@ -309,20 +310,18 @@ impl RepoStore { fn send(&mut self, builder: EventBuilder, cx: &mut Context) { 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| { - if let Ok(Err(e)) = rx.recv_async().await { + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(e) = task.await { this.update(cx, |this, cx| { this.last_error = Some(e.to_string()); cx.notify(); })?; } - Ok(()) - }); - - self.tasks.push(task); + })); } } diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 251dab1..94775de 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -58,7 +58,6 @@ impl RepoListView { let name = announcement .name .clone() - .map(|s| SharedString::from(s.trim())) .unwrap_or_else(|| SharedString::from(announcement.id.clone())); let description = announcement.description.clone().unwrap_or_default(); diff --git a/crates/workspace/src/views/sidebar/onboarding_dialog.rs b/crates/workspace/src/views/sidebar/onboarding_dialog.rs index be9f5bb..20112f8 100644 --- a/crates/workspace/src/views/sidebar/onboarding_dialog.rs +++ b/crates/workspace/src/views/sidebar/onboarding_dialog.rs @@ -103,21 +103,20 @@ pub fn open( state.error = None; }); - let rx = backend.update(cx, |backend, cx| { + let task = backend.update(cx, |backend, cx| { backend.create_identity(&name, &pass, cx) }); - let handle = window.window_handle(); let state = state.clone(); - cx.spawn(async move |cx| match rx.recv_async().await { - Ok(Ok(_)) => { + cx.spawn(async move |cx| match task.await { + Ok(_) => { cx.update_window(handle, |_, window, cx| { window.close_dialog(cx); }) .ok(); } - Ok(Err(e)) => { + Err(e) => { cx.update_window(handle, |_, _window, cx| { state.update(cx, |state, _| { state.busy = false; @@ -126,7 +125,6 @@ pub fn open( }) .ok(); } - Err(_) => {} }) .detach(); } diff --git a/crates/workspace/src/views/sidebar/passphrase_dialog.rs b/crates/workspace/src/views/sidebar/passphrase_dialog.rs index 8738b96..e0670b3 100644 --- a/crates/workspace/src/views/sidebar/passphrase_dialog.rs +++ b/crates/workspace/src/views/sidebar/passphrase_dialog.rs @@ -118,18 +118,19 @@ fn unlock( 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 state = state.clone(); - cx.spawn(async move |cx| match rx.recv_async().await { - Ok(Ok(_)) => { - cx.update_window(handle, |_, window, cx| window.close_dialog(cx)) - .ok(); + cx.spawn(async move |cx| match task.await { + Ok(_) => { + cx.update_window(handle, |_this, window, cx| { + window.close_dialog(cx); + }) + .ok(); } - Ok(Err(e)) => { - cx.update_window(handle, |_, _window, cx| { + Err(e) => { + cx.update_window(handle, |_this, _window, cx| { state.update(cx, |state, _| { state.busy = false; state.error = Some(e.to_string().into()); @@ -137,7 +138,6 @@ fn unlock( }) .ok(); } - Err(_) => {} }) .detach(); }