chore: Improve Login Process (#70)

* wip

* simplify nostr connect uri logic

* improve wait for connection

* improve handle password

* .

* add countdown
This commit is contained in:
reya
2025-06-28 10:09:31 +07:00
committed by GitHub
parent 3c2eaabab2
commit 14076054c0
7 changed files with 337 additions and 212 deletions

1
Cargo.lock generated
View File

@@ -1122,6 +1122,7 @@ dependencies = [
"global",
"gpui",
"itertools 0.13.0",
"log",
"nostr",
"nostr-connect",
"nostr-sdk",

View File

@@ -56,11 +56,11 @@ impl ClientKeys {
// Update keys
this.update(cx, |this, cx| {
let Ok(secret_key) = SecretKey::from_slice(&secret) else {
this.set_keys(None, false, cx);
this.set_keys(None, false, true, cx);
return;
};
let keys = Keys::new(secret_key);
this.set_keys(Some(keys), false, cx);
this.set_keys(Some(keys), false, true, cx);
})
.ok();
} else if shared_state().first_run() {
@@ -71,7 +71,7 @@ impl ClientKeys {
.ok();
} else {
this.update(cx, |this, cx| {
this.set_keys(None, false, cx);
this.set_keys(None, false, true, cx);
})
.ok();
}
@@ -79,14 +79,18 @@ impl ClientKeys {
.detach();
}
pub(crate) fn set_keys(&mut self, keys: Option<Keys>, persist: bool, cx: &mut Context<Self>) {
if let Some(keys) = keys.clone() {
if persist {
let write_keys = cx.write_credentials(
KEYRING_URL,
keys.public_key().to_hex().as_str(),
keys.secret_key().as_secret_bytes(),
);
pub(crate) fn set_keys(
&mut self,
keys: Option<Keys>,
persist: bool,
notify: bool,
cx: &mut Context<Self>,
) {
if persist {
if let Some(keys) = keys.as_ref() {
let username = keys.public_key().to_hex();
let password = keys.secret_key().secret_bytes();
let write_keys = cx.write_credentials(KEYRING_URL, &username, &password);
cx.background_spawn(async move {
if let Err(e) = write_keys.await {
@@ -94,18 +98,22 @@ impl ClientKeys {
}
})
.detach();
cx.notify();
}
}
self.keys = keys;
// Make sure notify the UI after keys changes
cx.notify();
// Notify GPUI to reload UI
if notify {
cx.notify();
}
}
pub fn new_keys(&mut self, cx: &mut Context<Self>) {
self.set_keys(Some(Keys::generate()), true, cx);
self.set_keys(Some(Keys::generate()), true, true, cx);
}
pub fn force_new_keys(&mut self, cx: &mut Context<Self>) {
self.set_keys(Some(Keys::generate()), true, false, cx);
}
pub fn keys(&self) -> Keys {

View File

@@ -18,6 +18,7 @@ smallvec.workspace = true
smol.workspace = true
futures.workspace = true
reqwest.workspace = true
log.workspace = true
webbrowser = "1.0.4"
qrcode-generator = "5.0.0"

View File

@@ -6,6 +6,7 @@ pub struct CoopAuthUrlHandler;
impl AuthUrlHandler for CoopAuthUrlHandler {
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
Box::pin(async move {
log::info!("Received Auth URL: {auth_url}");
webbrowser::open(auth_url.as_str())?;
Ok(())
})

View File

@@ -22,6 +22,8 @@ use ui::notification::Notification;
use ui::popup_menu::PopupMenu;
use ui::{ContextModal, Disableable, Sizable, StyledExt};
const TIMEOUT: u64 = 30;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
Login::new(window, cx)
}
@@ -31,16 +33,15 @@ pub struct Login {
relay_input: Entity<InputState>,
connection_string: Entity<NostrConnectURI>,
qr_image: Entity<Option<Arc<Image>>>,
// Signer that created by Connection String
active_signer: Entity<Option<NostrConnect>>,
// Error for the key input
error: Entity<Option<SharedString>>,
is_logging_in: bool,
countdown: Entity<Option<u64>>,
logging_in: bool,
// Panel
name: SharedString,
focus_handle: FocusHandle,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 5]>,
subscriptions: SmallVec<[Subscription; 3]>,
}
impl Login {
@@ -66,15 +67,9 @@ impl Login {
NostrConnectURI::client(client_keys.public_key(), vec![relay], APP_NAME)
});
// Convert the Connection String into QR Image
let qr_image = cx.new(|_| None);
let async_qr_image = qr_image.downgrade();
// Keep track of the signer that created by Connection String
let active_signer = cx.new(|_| None);
let async_active_signer = active_signer.downgrade();
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
// Subscribe to key input events and process login when the user presses enter
@@ -95,36 +90,11 @@ impl Login {
}),
);
// Observe the Connect URI that changes when the relay is changed
subscriptions.push(cx.observe_new::<NostrConnectURI>(move |uri, _window, cx| {
let client_keys = ClientKeys::get_global(cx).keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
if let Ok(mut signer) = NostrConnect::new(uri.to_owned(), client_keys, timeout, None) {
// Automatically open auth url
signer.auth_url_handler(CoopAuthUrlHandler);
async_active_signer
.update(cx, |this, cx| {
*this = Some(signer);
cx.notify();
})
.ok();
}
// Update the QR Image with the new connection string
async_qr_image
.update(cx, |this, cx| {
*this = string_to_qr(&uri.to_string());
cx.notify();
})
.ok();
}));
// Observe changes to the Nostr Connect URI and wait for a connection
subscriptions.push(cx.observe_in(
&connection_string,
window,
|this, entity, _window, cx| {
|this, entity, window, cx| {
let connection_string = entity.read(cx).clone();
let client_keys = ClientKeys::get_global(cx).keys();
@@ -143,65 +113,86 @@ impl Login {
Ok(mut signer) => {
// Automatically open auth url
signer.auth_url_handler(CoopAuthUrlHandler);
this.active_signer.update(cx, |this, cx| {
*this = Some(signer);
cx.notify();
});
// Wait for connection in the background
this.wait_for_connection(signer, window, cx);
}
Err(_) => {
log::error!("Failed to create Nostr Connect")
Err(e) => {
window.push_notification(
Notification::error(e.to_string()).title("Nostr Connect"),
cx,
);
}
}
},
));
subscriptions.push(
cx.observe_in(&active_signer, window, |this, entity, window, cx| {
if let Some(signer) = entity.read(cx).as_ref() {
// Wait for connection from remote signer
this.wait_for_connection(signer.to_owned(), window, cx);
}
}),
);
// Create a Nostr Connect URI and QR Code 800ms after opening the login screen
cx.spawn_in(window, async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(800))
.await;
this.update(cx, |this, cx| {
this.connection_string.update(cx, |_, cx| {
cx.notify();
})
})
.ok();
})
.detach();
Self {
name: "Login".into(),
focus_handle: cx.focus_handle(),
is_logging_in: false,
logging_in: false,
countdown,
key_input,
relay_input,
connection_string,
qr_image,
error,
active_signer,
subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_logging_in {
if self.logging_in {
return;
};
// Prevent duplicate login requests
self.set_logging_in(true, cx);
// Disable the input
self.key_input.update(cx, |this, cx| {
this.set_loading(true, cx);
this.set_disabled(true, cx);
});
// Content can be secret key or bunker://
match self.key_input.read(cx).value().to_string() {
s if s.starts_with("nsec1") => self.ask_for_password(s, window, cx),
s if s.starts_with("ncryptsec1") => self.ask_for_password(s, window, cx),
s if s.starts_with("bunker://") => self.login_with_bunker(&s, window, cx),
_ => self.set_error("You must provide a valid Private Key or Bunker.", cx),
s if s.starts_with("bunker://") => self.login_with_bunker(s, window, cx),
_ => self.set_error(
"You must provide a valid Private Key or Bunker.",
window,
cx,
),
};
}
fn ask_for_password(&mut self, content: String, window: &mut Window, cx: &mut Context<Self>) {
let current_view = cx.entity().downgrade();
let pwd_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_input = pwd_input.downgrade();
let weak_pwd_input = pwd_input.downgrade();
let confirm_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_confirm_input = confirm_input.downgrade();
window.open_modal(cx, move |this, _window, cx| {
let weak_input = weak_input.clone();
let weak_pwd_input = weak_pwd_input.clone();
let weak_confirm_input = weak_confirm_input.clone();
let view_cancel = current_view.clone();
let view_ok = current_view.clone();
@@ -211,36 +202,38 @@ impl Login {
"Password to decrypt your key *".into()
};
let description: Option<SharedString> = if content.starts_with("ncryptsec1") {
Some("Coop will only stored the encrypted version".into())
let description: SharedString = if content.starts_with("ncryptsec1") {
"Coop will only store the encrypted version of your keys".into()
} else {
None
"Coop will use the password to encrypt your keys. \
You will need this password to decrypt your keys for future use."
.into()
};
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.on_cancel(move |_, _window, cx| {
.on_cancel(move |_, window, cx| {
view_cancel
.update(cx, |this, cx| {
this.set_error("Password is required", cx);
this.set_error("Password is required", window, cx);
})
.ok();
true
})
.on_ok(move |_, window, cx| {
let value = weak_input
let value = weak_pwd_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
let confirm = weak_confirm_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
view_ok
.update(cx, |this, cx| {
if let Some(password) = value {
this.login_with_keys(password.to_string(), window, cx);
} else {
this.set_error("Password is required", cx);
}
this.verify_password(value, confirm, window, cx);
})
.ok();
true
@@ -252,23 +245,78 @@ impl Login {
.w_full()
.flex()
.flex_col()
.gap_1()
.gap_2()
.text_sm()
.child(label)
.child(TextInput::new(&pwd_input).small())
.when_some(description, |this, description| {
.child(
div()
.flex()
.flex_col()
.gap_1()
.child(label)
.child(TextInput::new(&pwd_input).small()),
)
.when(content.starts_with("nsec1"), |this| {
this.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(description),
.flex()
.flex_col()
.gap_1()
.child("Confirm your password *")
.child(TextInput::new(&confirm_input).small()),
)
}),
})
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(description),
),
)
});
}
fn verify_password(
&mut self,
password: Option<SharedString>,
confirm: Option<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(password) = password else {
self.set_error("Password is required", window, cx);
return;
};
if password.is_empty() {
self.set_error("Password is required", window, cx);
return;
}
// Skip verification if password starts with "ncryptsec1"
if password.starts_with("ncryptsec1") {
self.login_with_keys(password.to_string(), window, cx);
return;
}
let Some(confirm) = confirm else {
self.set_error("You must confirm your password", window, cx);
return;
};
if confirm.is_empty() {
self.set_error("You must confirm your password", window, cx);
return;
}
if password != confirm {
self.set_error("Passwords do not match", window, cx);
return;
}
self.login_with_keys(password.to_string(), window, cx);
}
fn login_with_keys(&mut self, password: String, window: &mut Window, cx: &mut Context<Self>) {
let value = self.key_input.read(cx).value().to_string();
let secret_key = if value.starts_with("nsec1") {
@@ -283,59 +331,75 @@ impl Login {
if let Some(secret_key) = secret_key {
let keys = Keys::new(secret_key);
Identity::global(cx).update(cx, |this, cx| {
this.write_keys(&keys, password, cx);
this.set_signer(keys, window, cx);
});
} else {
self.set_error("Secret Key is invalid", cx);
self.set_error("Secret Key is invalid", window, cx);
}
}
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
fn login_with_bunker(&mut self, content: String, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectURI::parse(content) else {
self.set_error("Bunker URL is not valid", cx);
self.set_error("Bunker URL is not valid", window, cx);
return;
};
let client_keys = ClientKeys::get_global(cx).keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT / 2);
let Ok(mut signer) = NostrConnect::new(uri, client_keys, timeout, None) else {
self.set_error("Failed to create remote signer", cx);
return;
};
// Automatically open auth url
let timeout = Duration::from_secs(TIMEOUT);
// .unwrap() is fine here because there's no error handling for bunker uri
let mut signer = NostrConnect::new(uri, client_keys, timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
let (tx, rx) = oneshot::channel::<Option<(NostrConnect, NostrConnectURI)>>();
// Verify remote signer connection
cx.background_spawn(async move {
if let Ok(bunker_uri) = signer.bunker_uri().await {
tx.send(Some((signer, bunker_uri))).ok();
} else {
tx.send(None).ok();
// Start countdown
cx.spawn_in(window, async move |this, cx| {
for i in (0..=TIMEOUT).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})
.ok();
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
})
.detach();
// Handle connection
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some((signer, uri))) = rx.await {
cx.update(|window, cx| {
Identity::global(cx).update(cx, |this, cx| {
this.write_bunker(&uri, cx);
this.set_signer(signer, window, cx);
});
})
.ok();
} else {
this.update(cx, |this, cx| {
let msg = "Connection to the Remote Signer failed or timed out";
this.set_error(msg, cx);
})
.ok();
match signer.bunker_uri().await {
Ok(bunker_uri) => {
cx.update(|window, cx| {
window.push_notification("Logging in...", cx);
Identity::global(cx).update(cx, |this, cx| {
this.write_bunker(&bunker_uri, cx);
this.set_signer(signer, window, cx);
});
})
.ok();
}
Err(error) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
// Force reset the client keys without notify UI
ClientKeys::global(cx).update(cx, |this, cx| {
log::info!("Timeout occurred. Reset client keys");
this.force_new_keys(cx);
});
this.set_error(error.to_string(), window, cx);
})
.ok();
})
.ok();
}
}
})
.detach();
@@ -347,40 +411,30 @@ impl Login {
window: &mut Window,
cx: &mut Context<Self>,
) {
let (tx, rx) = oneshot::channel::<Option<(NostrConnectURI, NostrConnect)>>();
cx.background_spawn(async move {
match signer.bunker_uri().await {
Ok(bunker_uri) => {
tx.send(Some((bunker_uri, signer))).ok();
}
Err(e) => {
log::error!("Nostr Connect (Client): {e}");
tx.send(None).ok();
}
}
})
.detach();
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some((uri, signer))) = rx.await {
cx.update(|window, cx| {
Identity::global(cx).update(cx, |this, cx| {
this.write_bunker(&uri, cx);
this.set_signer(signer, window, cx);
});
})
.ok();
} else {
cx.update(|window, cx| {
// Refresh the active signer
this.update(cx, |this, cx| {
window.push_notification(Notification::error("Connection failed"), cx);
this.change_relay(window, cx);
match signer.bunker_uri().await {
Ok(uri) => {
cx.update(|window, cx| {
Identity::global(cx).update(cx, |this, cx| {
this.write_bunker(&uri, cx);
this.set_signer(signer, window, cx);
});
})
.ok();
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
// Only send notifications on the login screen
this.update(cx, |_, cx| {
window.push_notification(
Notification::error(e.to_string()).title("Nostr Connect"),
cx,
);
})
.ok();
})
.ok();
}
}
})
.detach();
@@ -402,13 +456,31 @@ impl Login {
});
}
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
fn set_error(
&mut self,
message: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Reset the log in state
self.set_logging_in(false, cx);
// Reset the countdown
self.set_countdown(None, cx);
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
// Re enable the input
self.key_input.update(cx, |this, cx| {
this.set_value("", window, cx);
this.set_loading(false, cx);
this.set_disabled(false, cx);
});
// Clear the error message after 3 secs
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
@@ -425,9 +497,16 @@ impl Login {
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_logging_in = status;
self.logging_in = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Panel for Login {
@@ -502,12 +581,24 @@ impl Render for Login {
Button::new("login")
.label("Continue")
.primary()
.loading(self.is_logging_in)
.disabled(self.is_logging_in)
.loading(self.logging_in)
.disabled(self.logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(SharedString::from(format!(
"Approve connection request from your signer in {} seconds",
i
))),
)
})
.when_some(self.error.read(cx).clone(), |this, error| {
this.child(
div()

View File

@@ -632,7 +632,7 @@ impl Globals {
}
fn is_first_run() -> Result<bool, anyhow::Error> {
let flag = support_dir().join(".coop_first_run");
let flag = support_dir().join(format!(".{}-first_run", env!("CARGO_PKG_VERSION")));
if !flag.exists() {
fs::write(&flag, "")?;

View File

@@ -9,7 +9,7 @@ use global::{
};
use gpui::{
div, prelude::FluentBuilder, red, App, AppContext, Context, Entity, Global, ParentElement,
SharedString, Styled, Subscription, Task, Window,
SharedString, Styled, Subscription, Task, WeakEntity, Window,
};
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
@@ -227,10 +227,15 @@ impl Identity {
) {
let pwd_input: Entity<InputState> = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_input = pwd_input.downgrade();
let error: Entity<Option<SharedString>> = cx.new(|_| None);
let weak_error = error.downgrade();
let entity = cx.weak_entity();
window.open_modal(cx, move |this, _window, cx| {
let entity = entity.clone();
let entity_clone = entity.clone();
let weak_input = weak_input.clone();
let weak_error = weak_error.clone();
@@ -239,56 +244,25 @@ impl Identity {
.keyboard(false)
.confirm()
.on_cancel(move |_, _window, cx| {
Identity::global(cx).update(cx, |this, cx| {
this.set_profile(None, cx);
});
entity
.update(cx, |this, cx| {
this.set_profile(None, cx);
})
.ok();
// Close modal
true
})
.on_ok(move |_, window, cx| {
let value = weak_input
.read_with(cx, |state, _cx| state.value().to_string())
let weak_error = weak_error.clone();
let password = weak_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
if let Some(password) = value {
if password.is_empty() {
weak_error
.update(cx, |this, cx| {
*this = Some("Password cannot be empty".into());
cx.notify();
})
.ok();
return false;
};
Identity::global(cx).update(cx, |_, cx| {
let weak_error = weak_error.clone();
let task: Task<Option<SecretKey>> = cx.background_spawn(async move {
// Decrypt the password in the background to prevent blocking the main thread
enc.decrypt(&password).ok()
});
cx.spawn_in(window, async move |this, cx| {
if let Some(secret) = task.await {
cx.update(|window, cx| {
window.close_modal(cx);
this.update(cx, |this, cx| {
this.set_signer(Keys::new(secret), window, cx);
})
.ok();
})
.ok();
} else {
weak_error
.update(cx, |this, cx| {
*this = Some("Invalid password".into());
cx.notify();
})
.ok();
}
})
.detach();
});
}
entity_clone
.update(cx, |this, cx| {
this.verify_keys(enc, password, weak_error, window, cx);
})
.ok();
false
})
@@ -316,6 +290,55 @@ impl Identity {
});
}
pub(crate) fn verify_keys(
&mut self,
enc: EncryptedSecretKey,
password: Option<SharedString>,
error: WeakEntity<Option<SharedString>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(password) = password else {
_ = error.update(cx, |this, cx| {
*this = Some("Password is required".into());
cx.notify();
});
return;
};
if password.is_empty() {
_ = error.update(cx, |this, cx| {
*this = Some("Password cannot be empty".into());
cx.notify();
});
return;
}
// Decrypt the password in the background to prevent blocking the main thread
let task: Task<Option<SecretKey>> =
cx.background_spawn(async move { enc.decrypt(&password).ok() });
cx.spawn_in(window, async move |this, cx| {
if let Some(secret) = task.await {
cx.update(|window, cx| {
window.close_modal(cx);
// Update user's signer with decrypted secret key
this.update(cx, |this, cx| {
this.set_signer(Keys::new(secret), window, cx);
})
.ok();
})
.ok();
} else {
_ = error.update(cx, |this, cx| {
*this = Some("Invalid password".into());
cx.notify();
});
}
})
.detach();
}
/// Sets a new signer for the client and updates user identity
pub fn set_signer<S>(&self, signer: S, window: &mut Window, cx: &mut Context<Self>)
where
@@ -453,7 +476,7 @@ impl Identity {
cx.background_spawn(async move {
if let Ok(enc_key) =
EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)
EncryptedSecretKey::new(keys.secret_key(), &password, 8, KeySecurity::Unknown)
{
let client = shared_state().client();
let keys = Keys::generate();