chore: refactor the login flow
This commit is contained in:
@@ -594,11 +594,10 @@ impl ChatRegistry {
|
|||||||
let id = room.read(cx).id;
|
let id = room.read(cx).id;
|
||||||
|
|
||||||
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
|
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
|
||||||
let entity = room.to_owned();
|
room.update(cx, |room, cx| room.ensure_directory(cx));
|
||||||
entity.update(cx, |room, cx| room.ensure_directory(cx));
|
e.insert(room.clone());
|
||||||
e.insert(entity.clone());
|
|
||||||
|
|
||||||
self.rooms.insert(0, entity);
|
self.rooms.insert(0, room.clone());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1752,7 +1752,7 @@ impl ChatPanel {
|
|||||||
auto,
|
auto,
|
||||||
)
|
)
|
||||||
.menu_with_check_and_disabled(
|
.menu_with_check_and_disabled(
|
||||||
"Decoupled Encryption Key",
|
"Encryption Key",
|
||||||
encryption,
|
encryption,
|
||||||
Box::new(Command::ChangeSigner(SignerKind::Encryption)),
|
Box::new(Command::ChangeSigner(SignerKind::Encryption)),
|
||||||
encryption,
|
encryption,
|
||||||
|
|||||||
@@ -1196,6 +1196,10 @@ impl Panel for GroupPanel {
|
|||||||
window.dispatch_action(Box::new(HideGroupMeta { relay, group: id }), cx);
|
window.dispatch_action(Box::new(HideGroupMeta { relay, group: id }), cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_removed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.set_active(false, window, cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for GroupPanel {}
|
impl EventEmitter<PanelEvent> for GroupPanel {}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ pub const CLIENT_NAME: &str = "Coop";
|
|||||||
pub const APP_ID: &str = "su.reya.coop";
|
pub const APP_ID: &str = "su.reya.coop";
|
||||||
|
|
||||||
/// Keyring name
|
/// Keyring name
|
||||||
pub(crate) const MASTER_KEYRING: &str = "Coop Master Key";
|
pub const USER_KEYRING: &str = "Coop Safe Storage";
|
||||||
pub const USER_KEYRING: &str = "Coop User Credential";
|
|
||||||
|
|
||||||
/// Default timeout for subscription
|
/// Default timeout for subscription
|
||||||
pub const TIMEOUT: u64 = 2;
|
pub const TIMEOUT: u64 = 2;
|
||||||
|
|
||||||
|
/// Default timeout for Nostr Connect
|
||||||
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
/// Default delay for searching
|
/// Default delay for searching
|
||||||
pub const FIND_DELAY: u64 = 600;
|
pub const FIND_DELAY: u64 = 600;
|
||||||
|
|
||||||
@@ -31,9 +33,8 @@ pub(crate) const INDEXER_RELAYS: [&str; 2] =
|
|||||||
pub(crate) const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
pub(crate) const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||||
|
|
||||||
/// Default bootstrap relays
|
/// Default bootstrap relays
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||||
"wss://relay.ditto.pub",
|
"wss://relay.ditto.pub",
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://relay.nostr.net",
|
|
||||||
"wss://profiles.nostr1.com",
|
"wss://profiles.nostr1.com",
|
||||||
];
|
];
|
||||||
|
|||||||
+230
-63
@@ -4,7 +4,7 @@ use anyhow::{Error, anyhow};
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use browser_signer_proxy::prelude::*;
|
use browser_signer_proxy::prelude::*;
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
@@ -53,6 +53,8 @@ pub enum StateEvent {
|
|||||||
NoSigner,
|
NoSigner,
|
||||||
/// The signer has changed
|
/// The signer has changed
|
||||||
SignerChanged,
|
SignerChanged,
|
||||||
|
/// A stored credential needs a passphrase to unlock
|
||||||
|
PassphraseRequired,
|
||||||
/// An error occurred
|
/// An error occurred
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
@@ -75,16 +77,12 @@ impl StateEvent {
|
|||||||
pub struct NostrRegistry {
|
pub struct NostrRegistry {
|
||||||
/// Nostr client
|
/// Nostr client
|
||||||
client: Client,
|
client: Client,
|
||||||
|
|
||||||
/// Universal signer
|
/// Universal signer
|
||||||
signer: UniversalSigner,
|
signer: UniversalSigner,
|
||||||
|
|
||||||
/// Current user's public key
|
/// Current user's public key
|
||||||
current_user: Option<PublicKey>,
|
current_user: Option<PublicKey>,
|
||||||
|
/// Whether the stored credential needs a passphrase to unlock
|
||||||
/// Whether the initial credential check has concluded
|
passphrase_required: bool,
|
||||||
ready: bool,
|
|
||||||
|
|
||||||
/// Tasks for asynchronous operations
|
/// Tasks for asynchronous operations
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
@@ -126,6 +124,8 @@ impl NostrRegistry {
|
|||||||
.gossip(NostrGossipMemory::unbounded())
|
.gossip(NostrGossipMemory::unbounded())
|
||||||
.gossip_config(GossipConfig::default().no_background_refresh())
|
.gossip_config(GossipConfig::default().no_background_refresh())
|
||||||
.connect_timeout(Duration::from_secs(10))
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.verify_subscriptions(true)
|
||||||
|
.ban_relay_on_mismatch(true)
|
||||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||||
timeout: Duration::from_secs(600),
|
timeout: Duration::from_secs(600),
|
||||||
})
|
})
|
||||||
@@ -137,14 +137,13 @@ impl NostrRegistry {
|
|||||||
this.connect_bootstrap_relays(cx);
|
this.connect_bootstrap_relays(cx);
|
||||||
|
|
||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
this.mark_ready(cx);
|
|
||||||
cx.emit(StateEvent::NoSigner);
|
cx.emit(StateEvent::NoSigner);
|
||||||
} else if let Some(secret) = cli_key {
|
} else if let Some(secret) = cli_key {
|
||||||
// Use CLI-provided key -- same path as get_user_credential
|
// Use CLI-provided key -- same path as restore_session
|
||||||
let keys = Keys::new(secret);
|
let keys = Keys::new(secret);
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
} else {
|
} else {
|
||||||
this.get_user_credential(cx);
|
this.restore_session(cx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -153,7 +152,7 @@ impl NostrRegistry {
|
|||||||
client,
|
client,
|
||||||
signer,
|
signer,
|
||||||
current_user: None,
|
current_user: None,
|
||||||
ready: false,
|
passphrase_required: false,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,17 +172,8 @@ impl NostrRegistry {
|
|||||||
self.current_user
|
self.current_user
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_ready(&mut self, cx: &mut Context<Self>) {
|
|
||||||
if self.ready {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.ready = true;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the signer
|
/// Update the signer
|
||||||
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||||
where
|
where
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
|
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
@@ -196,7 +186,8 @@ impl NostrRegistry {
|
|||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.signer.swap_inner(new_signer);
|
this.signer.swap_inner(new_signer);
|
||||||
this.current_user = Some(public_key);
|
this.current_user = Some(public_key);
|
||||||
this.mark_ready(cx);
|
this.passphrase_required = false;
|
||||||
|
|
||||||
cx.emit(StateEvent::SignerChanged);
|
cx.emit(StateEvent::SignerChanged);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
@@ -247,93 +238,249 @@ impl NostrRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check the user's credential and set the signer if valid
|
/// Restore the signer from the stored credential
|
||||||
fn get_user_credential(&mut self, cx: &mut Context<Self>) {
|
fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
let user_keyring = cx.read_credentials(USER_KEYRING);
|
||||||
let master_keyring = self.get_master_key(cx);
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let result = async {
|
||||||
match user_keyring.await {
|
match user_keyring.await {
|
||||||
Ok(Some((_username, secret))) => {
|
Ok(Some((_username, secret))) => {
|
||||||
let content = String::from_utf8(secret)?;
|
let content = String::from_utf8(secret)?;
|
||||||
|
|
||||||
if content.starts_with("nsec1") {
|
if content.starts_with("nsec1") {
|
||||||
let secret_key = SecretKey::parse(&content)?;
|
let keys = Keys::new(SecretKey::parse(&content)?);
|
||||||
let keys = Keys::new(secret_key);
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
cx.notify();
|
|
||||||
})?;
|
})?;
|
||||||
} else if content.starts_with("bunker://") {
|
} else if content.starts_with("bunker://") {
|
||||||
let keys = master_keyring.await;
|
// The master key used for Nostr Connect is embedded in
|
||||||
let timeout = Duration::from_secs(30);
|
// the stored URI so reconnects reuse the same local key
|
||||||
let uri = NostrConnectUri::parse(content)?;
|
let (base, keys) = extract_master_key(&content);
|
||||||
|
let uri = NostrConnectUri::parse(base)?;
|
||||||
|
|
||||||
// Construct the nostr connect signer
|
// Construct the nostr connect signer
|
||||||
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
|
let mut signer = NostrConnect::new(
|
||||||
|
uri,
|
||||||
|
keys,
|
||||||
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
// Handle auth url with the default browser
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(signer, cx);
|
this.set_signer(signer, cx);
|
||||||
cx.notify();
|
})?;
|
||||||
|
} else if content.starts_with("ncryptsec1") {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.passphrase_required = true;
|
||||||
|
cx.emit(StateEvent::PassphraseRequired);
|
||||||
})?;
|
})?;
|
||||||
} else if content == "proxy" {
|
} else if content == "proxy" {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.mark_ready(cx);
|
|
||||||
this.connect_proxy(cx);
|
this.connect_proxy(cx);
|
||||||
})?;
|
})?;
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |this, cx| this.mark_ready(cx))?;
|
this.update(cx, |_, cx| {
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.mark_ready(cx);
|
|
||||||
cx.emit(StateEvent::NoSigner);
|
cx.emit(StateEvent::NoSigner);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ => {
|
||||||
|
this.update(cx, |_, cx| {
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<(), Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
this.update(cx, |_, cx| {
|
||||||
|
cx.emit(StateEvent::error(e.to_string()));
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the master key that used for Nostr Connect
|
/// Unlock the stored encrypted key with its passphrase
|
||||||
pub fn get_master_key(&self, cx: &App) -> Task<Keys> {
|
pub fn restore_with_passphrase(
|
||||||
if cfg!(target_arch = "wasm32") {
|
&mut self,
|
||||||
return cx.background_spawn(async move { Keys::generate() });
|
password: &str,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
|
let password = password.to_owned();
|
||||||
|
let user_keyring = cx.read_credentials(USER_KEYRING);
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let content = user_keyring
|
||||||
|
.await?
|
||||||
|
.map(|(_username, secret)| String::from_utf8(secret))
|
||||||
|
.transpose()?
|
||||||
|
.ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?;
|
||||||
|
|
||||||
|
if !content.starts_with("ncryptsec1") {
|
||||||
|
return Err(anyhow!("stored credential is not passphrase-encrypted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let task = cx.read_credentials(MASTER_KEYRING);
|
let decrypt_task = cx.background_spawn(async move {
|
||||||
|
let encrypted = EncryptedSecretKey::from_bech32(&content)?;
|
||||||
|
let secret = encrypted.decrypt(&password)?;
|
||||||
|
Ok::<_, Error>(Keys::new(secret))
|
||||||
|
});
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
let keys = decrypt_task.await?;
|
||||||
let (keys, new_key) = match task.await {
|
let public_key = keys.public_key();
|
||||||
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
|
|
||||||
Ok(secret_key) => (Keys::new(secret_key), false),
|
this.update(cx, |this, cx| {
|
||||||
_ => (Keys::generate(), true),
|
this.set_signer(keys, cx);
|
||||||
},
|
})?;
|
||||||
_ => (Keys::generate(), true),
|
|
||||||
|
Ok(public_key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import an `nsec1...` secret key, storing it NIP-49 encrypted with `password`
|
||||||
|
pub fn import_nsec(
|
||||||
|
&mut self,
|
||||||
|
nsec: &str,
|
||||||
|
password: &str,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
|
let keys = match SecretKey::parse(nsec.trim()) {
|
||||||
|
Ok(secret) => Keys::new(secret),
|
||||||
|
Err(e) => return Task::ready(Err(anyhow!(e))),
|
||||||
};
|
};
|
||||||
|
|
||||||
if new_key {
|
if password.is_empty() {
|
||||||
let keys_clone = keys.clone();
|
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
|
||||||
let username = keys_clone.public_key().to_hex();
|
|
||||||
let password = keys_clone.secret_key().to_secret_bytes();
|
|
||||||
|
|
||||||
cx.update(|cx| {
|
|
||||||
let task = cx.write_credentials(MASTER_KEYRING, &username, &password);
|
|
||||||
cx.background_spawn(async move { task.await.ok() }).detach();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
keys
|
let password = password.to_owned();
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let job = cx.background_spawn(async move {
|
||||||
|
let encrypted =
|
||||||
|
EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?;
|
||||||
|
let ncryptsec = encrypted.to_bech32()?;
|
||||||
|
Ok::<_, Error>((keys, ncryptsec))
|
||||||
|
});
|
||||||
|
|
||||||
|
let (keys, ncryptsec) = job.await?;
|
||||||
|
let public_key = keys.public_key();
|
||||||
|
|
||||||
|
let write = cx.update(|cx| {
|
||||||
|
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
|
||||||
|
});
|
||||||
|
write.await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(public_key)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Import an NIP-49 encrypted secret key (`ncryptsec1...`), decrypting it with `password`
|
||||||
|
pub fn import_ncryptsec(
|
||||||
|
&mut self,
|
||||||
|
ncryptsec: &str,
|
||||||
|
password: &str,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
|
let ncryptsec = ncryptsec.trim().to_owned();
|
||||||
|
|
||||||
|
if password.is_empty() {
|
||||||
|
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = password.to_owned();
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let stored = ncryptsec.clone();
|
||||||
|
let decrypt_task = cx.background_spawn(async move {
|
||||||
|
let encrypted = EncryptedSecretKey::from_bech32(&ncryptsec)?;
|
||||||
|
let secret = encrypted.decrypt(&password)?;
|
||||||
|
Ok::<_, Error>(Keys::new(secret))
|
||||||
|
});
|
||||||
|
|
||||||
|
let keys = decrypt_task.await?;
|
||||||
|
let public_key = keys.public_key();
|
||||||
|
|
||||||
|
let write = cx.update(|cx| {
|
||||||
|
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes())
|
||||||
|
});
|
||||||
|
write.await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(public_key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a `bunker://...` URI (NIP-46), connecting to the remote signer
|
||||||
|
pub fn import_bunker(
|
||||||
|
&mut self,
|
||||||
|
uri: &str,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
|
let uri_string = uri.trim().to_owned();
|
||||||
|
|
||||||
|
let connect_uri = match NostrConnectUri::parse(&uri_string) {
|
||||||
|
Ok(uri) => uri,
|
||||||
|
Err(e) => return Task::ready(Err(anyhow!(e))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let credential = with_master_key(&uri_string, &keys);
|
||||||
|
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
// Construct the nostr connect signer
|
||||||
|
let mut signer = NostrConnect::new(
|
||||||
|
connect_uri,
|
||||||
|
keys,
|
||||||
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Handle auth url with the default browser
|
||||||
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
|
|
||||||
|
// Verify the bunker responds before persisting the credential
|
||||||
|
let public_key = signer.get_public_key_async().await?;
|
||||||
|
write.await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(signer, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(public_key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign out and remove the stored credential
|
||||||
|
pub fn sign_out(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.current_user = None;
|
||||||
|
self.passphrase_required = false;
|
||||||
|
|
||||||
|
cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx);
|
||||||
|
cx.emit(StateEvent::SignerChanged);
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
/// Start the browser proxy
|
/// Start the browser proxy
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
||||||
@@ -514,3 +661,23 @@ impl NostrRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract the base URI and the master key from a bunker credential with `master=<nsec>`
|
||||||
|
fn extract_master_key(credential: &str) -> (&str, Keys) {
|
||||||
|
match credential.split_once("master=") {
|
||||||
|
Some((base, nsec)) => {
|
||||||
|
let keys = SecretKey::parse(nsec)
|
||||||
|
.map(Keys::new)
|
||||||
|
.unwrap_or_else(|_| Keys::generate());
|
||||||
|
(base.trim_end_matches(['?', '&']), keys)
|
||||||
|
}
|
||||||
|
None => (credential, Keys::generate()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed the master key in a bunker URI so reconnects reuse the same local key
|
||||||
|
fn with_master_key(uri: &str, keys: &Keys) -> String {
|
||||||
|
let separator = if uri.contains('?') { '&' } else { '?' };
|
||||||
|
let nsec = keys.secret_key().to_bech32().expect("infallible");
|
||||||
|
format!("{uri}{separator}master={nsec}")
|
||||||
|
}
|
||||||
|
|||||||
+20
-43
@@ -5,32 +5,9 @@ use std::pin::Pin;
|
|||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use nostr_connect::client::AuthUrlHandler;
|
use nostr_connect::client::AuthUrlHandler;
|
||||||
|
use nostr_sdk::error::Error as SignerError;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
|
|
||||||
|
|
||||||
impl fmt::Display for UniversalSignerError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Error for UniversalSignerError {
|
|
||||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
|
||||||
Some(&*self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UniversalSignerError {
|
|
||||||
pub fn new<E>(err: E) -> Self
|
|
||||||
where
|
|
||||||
E: Error + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
UniversalSignerError(Box::new(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct UniversalSigner {
|
pub struct UniversalSigner {
|
||||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||||
@@ -50,7 +27,7 @@ impl UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Swap the inner signer in-place. All clones see the new signer.
|
/// Swap the inner signer in-place. All clones see the new signer.
|
||||||
pub(crate) fn swap_inner<T>(&self, new_signer: T)
|
pub fn swap_inner<T>(&self, new_signer: T)
|
||||||
where
|
where
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||||
@@ -65,7 +42,7 @@ impl UniversalSigner {
|
|||||||
&self,
|
&self,
|
||||||
public_key: &PublicKey,
|
public_key: &PublicKey,
|
||||||
content: &str,
|
content: &str,
|
||||||
) -> Result<String, UniversalSignerError> {
|
) -> Result<String, SignerError> {
|
||||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
inner.nip44_encrypt_async(public_key, content).await
|
inner.nip44_encrypt_async(public_key, content).await
|
||||||
}
|
}
|
||||||
@@ -75,7 +52,7 @@ impl UniversalSigner {
|
|||||||
&self,
|
&self,
|
||||||
public_key: &PublicKey,
|
public_key: &PublicKey,
|
||||||
payload: &str,
|
payload: &str,
|
||||||
) -> Result<String, UniversalSignerError> {
|
) -> Result<String, SignerError> {
|
||||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
inner.nip44_decrypt_async(public_key, payload).await
|
inner.nip44_decrypt_async(public_key, payload).await
|
||||||
}
|
}
|
||||||
@@ -84,21 +61,21 @@ impl UniversalSigner {
|
|||||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>>;
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
unsigned: UnsignedEvent,
|
unsigned: UnsignedEvent,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>>;
|
||||||
fn nip44_encrypt_async<'a>(
|
fn nip44_encrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
content: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||||
fn nip44_decrypt_async<'a>(
|
fn nip44_decrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
payload: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -113,22 +90,22 @@ where
|
|||||||
{
|
{
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncGetPublicKey::get_public_key_async(&self.0)
|
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
unsigned: UnsignedEvent,
|
unsigned: UnsignedEvent,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,11 +113,11 @@ where
|
|||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
content: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,17 +125,17 @@ where
|
|||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
payload: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncGetPublicKey for UniversalSigner {
|
impl AsyncGetPublicKey for UniversalSigner {
|
||||||
type Error = UniversalSignerError;
|
type Error = SignerError;
|
||||||
|
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
@@ -169,7 +146,7 @@ impl AsyncGetPublicKey for UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncSignEvent for UniversalSigner {
|
impl AsyncSignEvent for UniversalSigner {
|
||||||
type Error = UniversalSignerError;
|
type Error = SignerError;
|
||||||
|
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
@@ -181,7 +158,7 @@ impl AsyncSignEvent for UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncNip44 for UniversalSigner {
|
impl AsyncNip44 for UniversalSigner {
|
||||||
type Error = UniversalSignerError;
|
type Error = SignerError;
|
||||||
|
|
||||||
fn nip44_encrypt_async<'a>(
|
fn nip44_encrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
@@ -211,7 +188,7 @@ impl AuthUrlHandler for CoopAuthUrlHandler {
|
|||||||
auth_url: Url,
|
auth_url: Url,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
webbrowser::open(auth_url.as_str()).unwrap();
|
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::any::Any;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, ImageSource,
|
AnyElement, AnyView, App, Context, Element, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
Render, SharedString, Window,
|
ImageSource, Render, SharedString, Window,
|
||||||
};
|
};
|
||||||
use gpui_base::dock::{PanelId, PanelState};
|
use gpui_base::dock::{PanelId, PanelState};
|
||||||
|
|
||||||
@@ -39,6 +39,9 @@ pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
|||||||
/// Set active state of the panel.
|
/// Set active state of the panel.
|
||||||
fn set_active(&self, _active: bool, _window: &mut Window, _cx: &mut App) {}
|
fn set_active(&self, _active: bool, _window: &mut Window, _cx: &mut App) {}
|
||||||
|
|
||||||
|
/// Called when the panel leaves the dock.
|
||||||
|
fn on_removed(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
|
||||||
|
|
||||||
/// Set zoomed state of the panel.
|
/// Set zoomed state of the panel.
|
||||||
fn set_zoomed(&self, _zoomed: bool, _cx: &mut App) {}
|
fn set_zoomed(&self, _zoomed: bool, _cx: &mut App) {}
|
||||||
|
|
||||||
@@ -62,6 +65,7 @@ pub(crate) trait PanelView: 'static + Send + Sync {
|
|||||||
fn background(&self, cx: &App) -> Option<ImageSource>;
|
fn background(&self, cx: &App) -> Option<ImageSource>;
|
||||||
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App);
|
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App);
|
||||||
fn set_zoomed(&self, zoomed: bool, cx: &mut App);
|
fn set_zoomed(&self, zoomed: bool, cx: &mut App);
|
||||||
|
fn on_removed(&self, window: &mut Window, cx: &mut App);
|
||||||
fn view(&self) -> AnyView;
|
fn view(&self) -> AnyView;
|
||||||
fn focus_handle(&self, cx: &App) -> FocusHandle;
|
fn focus_handle(&self, cx: &App) -> FocusHandle;
|
||||||
}
|
}
|
||||||
@@ -103,6 +107,12 @@ impl<T: Panel> PanelView for Entity<T> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_removed(&self, window: &mut Window, cx: &mut App) {
|
||||||
|
self.update(cx, |this, cx| {
|
||||||
|
this.on_removed(window, cx);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn view(&self) -> AnyView {
|
fn view(&self) -> AnyView {
|
||||||
self.clone().into()
|
self.clone().into()
|
||||||
}
|
}
|
||||||
@@ -173,7 +183,9 @@ impl gpui_base::dock::PanelView for PanelHandle {
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_removed(&self, _: &mut Window, _: &mut App) {}
|
fn on_removed(&self, window: &mut Window, cx: &mut App) {
|
||||||
|
self.panel.on_removed(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
fn view(&self) -> AnyView {
|
fn view(&self) -> AnyView {
|
||||||
self.panel.view()
|
self.panel.view()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use anyhow::{Error, anyhow};
|
use anyhow::Error;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
@@ -6,11 +6,11 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
|
use state::NostrRegistry;
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius};
|
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{Input, InputEvent, InputState};
|
||||||
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
|
use ui::{Disableable, WindowExtension, v_flex};
|
||||||
|
|
||||||
pub fn open(window: &mut Window, cx: &mut App) {
|
pub fn open(window: &mut Window, cx: &mut App) {
|
||||||
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
||||||
@@ -25,146 +25,157 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a passphrase-only dialog to unlock the stored encrypted key
|
||||||
|
pub fn open_unlock(window: &mut Window, cx: &mut App) {
|
||||||
|
let import = cx.new(|cx| ImportIdentity::new_unlock(window, cx));
|
||||||
|
|
||||||
|
window.open_dialog(cx, move |this, _window, _cx| {
|
||||||
|
this.width(px(450.))
|
||||||
|
.show_close(false)
|
||||||
|
.overlay_closable(false)
|
||||||
|
.keyboard(false)
|
||||||
|
.title("Unlock your identity")
|
||||||
|
.child(import.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum Mode {
|
||||||
|
/// Import a credential (nsec, ncryptsec or bunker)
|
||||||
|
Import,
|
||||||
|
/// Unlock the stored encrypted key with its passphrase
|
||||||
|
Unlock,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ImportIdentity {
|
pub struct ImportIdentity {
|
||||||
|
mode: Mode,
|
||||||
/// Secret key input
|
/// Secret key input
|
||||||
key_input: Entity<InputState>,
|
key_input: Entity<InputState>,
|
||||||
/// Password input (if required)
|
/// Passphrase input
|
||||||
pass_input: Entity<InputState>,
|
pass_input: Entity<InputState>,
|
||||||
|
/// Repeated passphrase input when encrypting a plaintext key
|
||||||
|
repass_input: Entity<InputState>,
|
||||||
/// Error message
|
/// Error message
|
||||||
error: Entity<Option<SharedString>>,
|
error: Entity<Option<SharedString>>,
|
||||||
/// Whether the user is currently loading
|
/// Whether the user is currently loading
|
||||||
loading: bool,
|
loading: bool,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
_subscription: Option<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImportIdentity {
|
impl ImportIdentity {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("nsec or bunker://"));
|
Self::build(Mode::Import, window, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_unlock(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
Self::build(Mode::Unlock, window, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
|
||||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
|
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
let error = cx.new(|_| None);
|
let error = cx.new(|_| None);
|
||||||
|
|
||||||
let input_subscription =
|
let subscriptions = vec![
|
||||||
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
|
cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| {
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
this.login(window, cx);
|
this.login(cx);
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
|
cx.subscribe_in(&pass_input, window, |this, _input, event, _window, cx| {
|
||||||
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
|
this.login(cx);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
cx.subscribe_in(&repass_input, window, |this, _input, event, _window, cx| {
|
||||||
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
|
this.login(cx);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
mode,
|
||||||
key_input,
|
key_input,
|
||||||
pass_input,
|
pass_input,
|
||||||
|
repass_input,
|
||||||
error,
|
error,
|
||||||
loading: false,
|
loading: false,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscription: Some(input_subscription),
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn login(&mut self, cx: &mut Context<Self>) {
|
||||||
let value = self.key_input.read(cx).value();
|
let password = self.pass_input.read(cx).value().to_string();
|
||||||
let password = self.pass_input.read(cx).value();
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
// Set loading state
|
if self.mode == Mode::Unlock {
|
||||||
|
if password.is_empty() {
|
||||||
|
self.set_error("Passphrase is required", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_loading(true, cx);
|
||||||
|
let task = nostr.update(cx, |this, cx| this.restore_with_passphrase(&password, cx));
|
||||||
|
self.finish(task, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = self.key_input.read(cx).value().trim().to_string();
|
||||||
self.set_loading(true, cx);
|
self.set_loading(true, cx);
|
||||||
|
|
||||||
if value.starts_with("ncryptsec1") {
|
|
||||||
self.ncryptsec(value, password, window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if value.starts_with("bunker://") {
|
if value.starts_with("bunker://") {
|
||||||
match NostrConnectUri::parse(value) {
|
let task = nostr.update(cx, |this, cx| this.import_bunker(&value, cx));
|
||||||
Ok(uri) => {
|
self.finish(task, cx);
|
||||||
self.bunker(uri, window, cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
self.set_error(e.to_string(), cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(secret) = SecretKey::parse(&value) {
|
if value.starts_with("ncryptsec1") {
|
||||||
let keys = Keys::new(secret);
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
|
|
||||||
// Update the signer
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.set_signer(keys, cx);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
self.set_error("Invalid key", cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ncryptsec<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
|
|
||||||
where
|
|
||||||
S: Into<String>,
|
|
||||||
{
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let content: String = content.into();
|
|
||||||
let password: String = pwd.into();
|
|
||||||
|
|
||||||
if password.is_empty() {
|
if password.is_empty() {
|
||||||
self.set_error("Password is required", cx);
|
self.set_error("Passphrase is required", cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(enc) = EncryptedSecretKey::from_bech32(&content) else {
|
let task = nostr.update(cx, |this, cx| this.import_ncryptsec(&value, &password, cx));
|
||||||
self.set_error("Secret Key is invalid", cx);
|
self.finish(task, cx);
|
||||||
return;
|
return;
|
||||||
};
|
|
||||||
|
|
||||||
// Decrypt in the background to ensure it doesn't block the UI
|
|
||||||
let task = cx.background_spawn(async move {
|
|
||||||
if let Ok(content) = enc.decrypt(&password) {
|
|
||||||
Ok(Keys::new(content))
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("Invalid password"))
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
// A plaintext key is encrypted with the passphrase before it is stored
|
||||||
match task.await {
|
if value.starts_with("nsec1") || SecretKey::parse(&value).is_ok() {
|
||||||
Ok(keys) => {
|
if password.is_empty() {
|
||||||
nostr.update_in(cx, |this, window, cx| {
|
self.set_error("Passphrase is required", cx);
|
||||||
this.set_signer(keys, cx);
|
return;
|
||||||
window.close_dialog(cx);
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
|
let repass = self.repass_input.read(cx).value().to_string();
|
||||||
|
|
||||||
|
if password != repass {
|
||||||
|
self.set_error("Passphrases do not match", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = nostr.update(cx, |this, cx| this.import_nsec(&value, &password, cx));
|
||||||
|
self.finish(task, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_error("Enter an nsec, ncryptsec or bunker://", cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the import task, surfacing errors while success closes the dialog
|
||||||
|
/// through the `SignerChanged` event
|
||||||
|
fn finish(&mut self, task: Task<Result<PublicKey, Error>>, cx: &mut Context<Self>) {
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
if let Err(e) = task.await {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_error(e.to_string(), cx);
|
this.set_error(e.to_string(), cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bunker(&mut self, uri: NostrConnectUri, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let master_keys = nostr.read(cx).get_master_key(cx);
|
|
||||||
let password = uri.to_string();
|
|
||||||
let save = cx.write_credentials(USER_KEYRING, "bunker", password.as_bytes());
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
let keys = master_keys.await;
|
|
||||||
let timeout = Duration::from_secs(30);
|
|
||||||
|
|
||||||
// Construct the nostr connect signer
|
|
||||||
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
|
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
|
||||||
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.set_signer(signer, cx);
|
|
||||||
cx.background_spawn(async move { save.await.ok() }).detach();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
@@ -214,36 +225,62 @@ impl ImportIdentity {
|
|||||||
impl Render for ImportIdentity {
|
impl Render for ImportIdentity {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
||||||
const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
|
const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time.";
|
||||||
|
|
||||||
let is_wasm = cfg!(target_arch = "wasm32");
|
let is_wasm = cfg!(target_arch = "wasm32");
|
||||||
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
|
let is_unlock = self.mode == Mode::Unlock;
|
||||||
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
|
|
||||||
let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://");
|
let value = self.key_input.read(cx).value().trim().to_string();
|
||||||
|
let require_password = value.starts_with("ncryptsec1");
|
||||||
|
let require_encrypt = value.starts_with("nsec1");
|
||||||
|
let key_warning = require_password || require_encrypt;
|
||||||
|
let bunker_warning = value.starts_with("bunker://");
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.gap(px(theme::SPACE))
|
.gap_4()
|
||||||
.text_style(TextStyle::Callout)
|
.text_style(TextStyle::Callout)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap(px(theme::SPACE))
|
||||||
.child(
|
.when(!is_unlock, |this| {
|
||||||
|
this.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child("Use secret key or bunker connection")
|
.child("Use secret key or bunker connection")
|
||||||
.child(Input::new(&self.key_input)),
|
.child(Input::new(&self.key_input)),
|
||||||
)
|
)
|
||||||
.when(require_password, |this| {
|
})
|
||||||
|
.when(is_unlock || require_password, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child("Decrypt Password:")
|
.child(if is_unlock {
|
||||||
|
"Passphrase to unlock your identity:"
|
||||||
|
} else {
|
||||||
|
"Decrypt Password:"
|
||||||
|
})
|
||||||
.child(Input::new(&self.pass_input)),
|
.child(Input::new(&self.pass_input)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.when(require_encrypt, |this| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Passphrase to encrypt your key on this device:")
|
||||||
|
.child(Input::new(&self.pass_input)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Repeat Passphrase:")
|
||||||
|
.child(Input::new(&self.repass_input)),
|
||||||
|
)
|
||||||
|
})
|
||||||
.when(bunker_warning, |this| {
|
.when(bunker_warning, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
@@ -271,21 +308,19 @@ impl Render for ImportIdentity {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("login")
|
Button::new("login")
|
||||||
.label("Continue")
|
.label(if is_unlock { "Unlock" } else { "Continue" })
|
||||||
.primary()
|
.primary()
|
||||||
.font_semibold()
|
|
||||||
.loading(self.loading)
|
.loading(self.loading)
|
||||||
.disabled(self.loading)
|
.disabled(self.loading)
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
this.login(window, cx);
|
this.login(cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.when(!is_wasm, |this| {
|
.when(!is_wasm && !is_unlock, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("proxy")
|
Button::new("proxy")
|
||||||
.label("Connect via Web Extension (Experimental)")
|
.label("Connect via Web Extension (Experimental)")
|
||||||
.ghost()
|
.ghost()
|
||||||
.loading(self.loading)
|
|
||||||
.disabled(self.loading)
|
.disabled(self.loading)
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
this.proxy(cx);
|
this.proxy(cx);
|
||||||
|
|||||||
+27
-28
@@ -21,7 +21,7 @@ use ui::notification::{Notification, NotificationKind};
|
|||||||
use ui::{Root, WindowExtension, v_flex};
|
use ui::{Root, WindowExtension, v_flex};
|
||||||
|
|
||||||
use crate::dialogs::restore::RestoreEncryption;
|
use crate::dialogs::restore::RestoreEncryption;
|
||||||
use crate::dialogs::{join_group, new_chat, new_group, profile, relays, settings};
|
use crate::dialogs::{import, join_group, new_chat, new_group, profile, relays, settings};
|
||||||
use crate::panels::{backup, browse, contact_list, greeter, inbox};
|
use crate::panels::{backup, browse, contact_list, greeter, inbox};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
|
|
||||||
@@ -76,6 +76,7 @@ impl Workspace {
|
|||||||
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
||||||
let left_sidebar = sidebar.clone();
|
let left_sidebar = sidebar.clone();
|
||||||
|
|
||||||
|
let workspace = cx.entity().downgrade();
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
@@ -87,10 +88,10 @@ impl Workspace {
|
|||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe to the nostr events
|
// Subscribe to the nostr events
|
||||||
cx.subscribe_in(&nostr, window, move |_this, _state, event, window, cx| {
|
cx.subscribe_in(&nostr, window, move |_, _, event, window, cx| match event {
|
||||||
if let StateEvent::SignerChanged = event {
|
StateEvent::SignerChanged => window.close_all_dialogs(cx),
|
||||||
window.close_all_dialogs(cx);
|
StateEvent::PassphraseRequired => import::open_unlock(window, cx),
|
||||||
}
|
_ => {}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -183,6 +184,27 @@ impl Workspace {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
App::on_action(cx, {
|
||||||
|
let workspace = workspace.clone();
|
||||||
|
move |action, cx| {
|
||||||
|
let Some(workspace) = workspace.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
workspace.update(cx, |this, cx| {
|
||||||
|
this.show_group_meta(action, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
App::on_action(cx, move |action, cx| {
|
||||||
|
let Some(workspace) = workspace.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
workspace.update(cx, |this, cx| {
|
||||||
|
this.hide_group_meta(action, cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
cx.defer_in(window, move |this, window, cx| {
|
cx.defer_in(window, move |this, window, cx| {
|
||||||
let sidebar = PanelHandle::new(left_sidebar);
|
let sidebar = PanelHandle::new(left_sidebar);
|
||||||
let greeter = PanelHandle::new(greeter::init(window, cx));
|
let greeter = PanelHandle::new(greeter::init(window, cx));
|
||||||
@@ -198,29 +220,6 @@ impl Workspace {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen app-wide, so a group panel can show or hide the sidebar without holding it.
|
|
||||||
let workspace = cx.entity().downgrade();
|
|
||||||
|
|
||||||
App::on_action(cx, {
|
|
||||||
let workspace = workspace.clone();
|
|
||||||
|
|
||||||
move |action: &nip29_ui::ShowGroupMeta, cx| {
|
|
||||||
let Some(workspace) = workspace.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
workspace.update(cx, |this, cx| this.show_group_meta(action, cx));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
App::on_action(cx, move |action: &nip29_ui::HideGroupMeta, cx| {
|
|
||||||
let Some(workspace) = workspace.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
workspace.update(cx, |this, cx| this.hide_group_meta(action, cx));
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
dock,
|
dock,
|
||||||
sidebar,
|
sidebar,
|
||||||
|
|||||||
Reference in New Issue
Block a user