diff --git a/crates/signed_core/src/builders.rs b/crates/signed_core/src/builders.rs new file mode 100644 index 0000000..5650448 --- /dev/null +++ b/crates/signed_core/src/builders.rs @@ -0,0 +1,29 @@ +use nostr::prelude::*; + +/// Build a NIP-34 user grasp list (kind `10317`). +pub fn grasp_list(grasp_servers: Vec) -> EventBuilder { + GitUserGraspList { grasp_servers }.into_event_builder() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grasp_list_tags() { + let servers = vec![ + RelayUrl::parse("wss://gitnostr.com").unwrap(), + RelayUrl::parse("wss://relay.ngit.dev").unwrap(), + ]; + + let builder = grasp_list(servers); + + let urls: Vec<&str> = builder + .tags + .iter() + .filter_map(|t| t.content()) + .collect(); + + assert_eq!(urls, vec!["wss://gitnostr.com", "wss://relay.ngit.dev"]); + } +} diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index f4958fd..e103493 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -1,4 +1,5 @@ pub mod addr; +pub mod builders; pub mod clone_url; pub mod filters; pub mod model; diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 4b2c6d8..f6121d7 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -6,7 +6,7 @@ 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_core::{builders, filters}; use signed_nostr::{NostrBackend, SignedAuthUrlHandler, UniversalSigner, Update}; /// Keyring entry holding the user credential (`nsec1...` or `bunker://...` @@ -199,6 +199,11 @@ impl Backend { )?; 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, which is not implemented yet. + log::warn!("stored identity is ncryptsec-encrypted; passphrase restore is not implemented"); + this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; } else { this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; } @@ -218,6 +223,115 @@ impl Backend { })); } + /// 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, + ) -> flume::Receiver> { + 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.inner.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(); + + 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 = + ["wss://gitnostr.com", "wss://relay.ngit.dev"] + .into_iter() + .map(|url| RelayUrl::parse(url).expect("valid relay URL")) + .collect(); + + this.send(builders::grasp_list(grasp_servers), 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) { @@ -394,6 +508,11 @@ impl Backend { self.current_user } + /// Surface an error message through [`BackendEvent::Error`]. + pub fn emit_error(&mut self, message: impl Into, cx: &mut Context) { + cx.emit(BackendEvent::error(message)); + } + /// Whether the relay bootstrap has completed. pub fn is_connected(&self) -> bool { self.connected @@ -602,7 +721,6 @@ impl Backend { cx: &mut Context, ) -> flume::Receiver> { let (tx, rx) = flume::bounded(1); - let backend = self.inner.clone(); let task = cx.background_spawn(async move { backend.send(builder).await }); @@ -616,7 +734,9 @@ impl Backend { })?; } Err(e) => { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::error(e.to_string())); + })?; } } diff --git a/crates/workspace/src/views/sidebar.rs b/crates/workspace/src/views/sidebar.rs index 235dd8f..1734e40 100644 --- a/crates/workspace/src/views/sidebar.rs +++ b/crates/workspace/src/views/sidebar.rs @@ -2,16 +2,26 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ - App, Context, EventEmitter, FocusHandle, Focusable, Render, Subscription, WeakEntity, Window, - div, + App, Context, EventEmitter, FocusHandle, Focusable, Render, SharedString, Subscription, + WeakEntity, Window, div, px, }; use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; -use gpui_component::{ActiveTheme, v_flex}; +use gpui_component::form::{field, v_form}; +use gpui_component::input::{Input, InputState}; +use gpui_component::{ActiveTheme, Disableable, WindowExt, v_flex}; use signed_state::{Backend, BackendEvent}; use crate::views::RepoListView; +/// Shared state for the Join Now dialog, so async results can be rendered. +#[derive(Default)] +struct JoinNowState { + busy: bool, + error: Option, +} + /// Left-dock panel with navigation entries. Entries open content panels in /// the dock area. pub struct SidebarPanel { @@ -68,6 +78,145 @@ impl SidebarPanel { dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx); }); } + + /// Show the Join Now dialog. + fn open_join_now(&mut self, window: &mut Window, cx: &mut Context) { + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name")); + let pass_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Passphrase to protect your keys") + .masked(true) + }); + let repass_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Repeat passphrase") + .masked(true) + }); + let state = cx.new(|_| JoinNowState::default()); + + window.open_dialog(cx, move |dialog, _window, _cx| { + let name_input = name_input.clone(); + let pass_input = pass_input.clone(); + let repass_input = repass_input.clone(); + let state = state.clone(); + + dialog + .width(px(520.)) + .margin_top(px(50.)) + .content(move |content, _window, cx| { + let busy = state.read(cx).busy; + let error = state.read(cx).error.clone(); + + content + .child( + DialogHeader::new() + .child(DialogTitle::new().child("Create identity")) + .child( + DialogDescription::new() + .child("Set up your Signed identity to get started."), + ), + ) + .child( + v_form() + .child( + field() + .label("Name") + .description("Max 255 characters") + .required(true) + .child(Input::new(&name_input)), + ) + .child( + field() + .label("Passphrase") + .required(true) + .child(Input::new(&pass_input)), + ) + .child(field().required(true).child(Input::new(&repass_input))), + ) + .children(error.map(|message| { + div().text_sm().text_color(cx.theme().danger).child(message) + })) + .child( + DialogFooter::new().justify_end().child( + Button::new("continue") + .primary() + .label("Create new identity") + .tooltip("Create identity") + .loading(busy) + .disabled(busy) + .on_click({ + let name_input = name_input.clone(); + let pass_input = pass_input.clone(); + let repass_input = repass_input.clone(); + let state = state.clone(); + + move |_ev, window, cx| { + let name = name_input.read(cx).value().to_string(); + let pass = pass_input.read(cx).value().to_string(); + let repass = repass_input.read(cx).value().to_string(); + + if pass != repass { + state.update(cx, |state, _| { + state.busy = false; + state.error = + Some("Passphrases do not match".into()); + }); + return; + } + + state.update(cx, |state, _| { + state.busy = true; + state.error = None; + }); + + let rx = + Backend::global(cx).update(cx, |backend, cx| { + backend.create_identity(&name, &pass, cx) + }); + + let window_handle = window.window_handle(); + let state = state.clone(); + + cx.spawn(async move |cx| match rx.recv_async().await { + Ok(Ok(_)) => { + cx.update_window( + window_handle, + |_, window, cx| { + window.close_dialog(cx); + }, + ) + .ok(); + } + Ok(Err(e)) => { + cx.update_window( + window_handle, + |_, _window, cx| { + state.update(cx, |state, _| { + state.busy = false; + state.error = + Some(e.to_string().into()); + }); + }, + ) + .ok(); + } + Err(_) => {} + }) + .detach(); + } + }), + ), + ) + }) + }); + } + + /// Show the Import Identity dialog. + fn open_import(&mut self, window: &mut Window, cx: &mut Context) { + window.open_dialog(cx, move |dialog, _window, _cx| { + dialog.title("Import identity").width(px(400.)) + }); + } } impl Panel for SidebarPanel { @@ -114,20 +263,27 @@ impl Render for SidebarPanel { .gap_2() .child( div() - .text_xs() + .text_sm() .text_color(cx.theme().muted_foreground) .child("Sign in to continue"), ) .child( - Button::new("get-started") - .label("Get started") + Button::new("join") + .label("Join Now") .primary() - .w_full(), + .w_full() + .on_click( + cx.listener(|this, _ev, window, cx| this.open_join_now(window, cx)), + ), ) .child( Button::new("import-identity") .label("Import identity") - .w_full(), + .secondary() + .w_full() + .on_click( + cx.listener(|this, _ev, window, cx| this.open_import(window, cx)), + ), ) } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 25cdcac..0dae153 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use gpui::prelude::*; use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; use gpui_component::dock::{DockArea, DockItem}; @@ -23,8 +25,8 @@ impl Workspace { dock.update(cx, |dock_area, cx| { dock_area.set_left_dock( - DockItem::tab(sidebar.clone(), &weak_dock, window, cx), - Some(px(240.)), + DockItem::panel(Arc::new(sidebar.clone())), + Some(px(260.)), true, window, cx, diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 8f3bb5c..61c93bf 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -17,7 +17,7 @@ fn main() { signed_state::init(paths::nostr_dir(), cx); // Set up the window bounds - let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx); + let bounds = Bounds::centered(None, size(px(980.0), px(740.0)), cx); // Set up the window options let opts = WindowOptions {