Compare commits
10
Commits
master
...
0c6d700395
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c6d700395 | ||
|
|
6d5d154486 | ||
|
|
627abbdcaf | ||
|
|
a97dfac23f | ||
|
|
6b13d8a8f7 | ||
|
|
8de6018e28 | ||
|
|
ed93d81a26 | ||
|
|
2ec7d14c33 | ||
|
|
fdf74327bb | ||
|
|
f497279886 |
Generated
+20
@@ -6055,6 +6055,13 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paths"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
@@ -7741,8 +7748,11 @@ dependencies = [
|
||||
"gpui_platform",
|
||||
"gpui_windows",
|
||||
"log",
|
||||
"paths",
|
||||
"reqwest_client",
|
||||
"signed_state",
|
||||
"tracing-subscriber",
|
||||
"workspace",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10294,6 +10304,16 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "workspace"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
"gpui-component",
|
||||
"signed_core",
|
||||
"signed_state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "workspace-hack"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "paths"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dirs = "6"
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Paths to locations used by Signed.
|
||||
//!
|
||||
//! Follows the same pattern as Zed's `paths` crate: platform-correct base
|
||||
//! directories, resolved once and cached, with an optional custom data dir
|
||||
//! override for portable/dev installs.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The application name, used to derive platform-specific data, config and
|
||||
/// cache directory paths.
|
||||
pub const APP_NAME: &str = "Signed";
|
||||
|
||||
/// Lowercased form of [`APP_NAME`], for use in XDG-style paths on
|
||||
/// Linux/FreeBSD and the macOS `~/.config` fallback.
|
||||
pub const APP_NAME_LOWERCASE: &str = "signed";
|
||||
|
||||
/// A custom data directory override, set only by [`set_custom_data_dir`].
|
||||
static CUSTOM_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// The resolved data directory.
|
||||
/// On macOS, this is `~/Library/Application Support/Signed`.
|
||||
/// On Linux/FreeBSD, this is `$XDG_DATA_HOME/signed`.
|
||||
/// On Windows, this is `%LOCALAPPDATA%\Signed`.
|
||||
static CURRENT_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// The resolved config directory.
|
||||
/// On macOS, this is `~/.config/signed`.
|
||||
/// On Linux/FreeBSD, this is `$XDG_CONFIG_HOME/signed`.
|
||||
/// On Windows, this is `%APPDATA%\Signed`.
|
||||
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Returns the current user's home directory.
|
||||
pub fn home_dir() -> PathBuf {
|
||||
dirs::home_dir().expect("failed to determine home directory")
|
||||
}
|
||||
|
||||
/// Sets a custom directory for all user data, overriding the default data
|
||||
/// directory. Must be called before any other path operation. The directory
|
||||
/// is created if it doesn't exist and canonicalized to an absolute path.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if called after [`data_dir`] or [`config_dir`] was initialized, or
|
||||
/// if the directory cannot be created/canonicalized.
|
||||
pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf {
|
||||
if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() {
|
||||
panic!("set_custom_data_dir called after data_dir or config_dir was initialized");
|
||||
}
|
||||
|
||||
CUSTOM_DATA_DIR.get_or_init(|| {
|
||||
let path = PathBuf::from(dir);
|
||||
std::fs::create_dir_all(&path).expect("failed to create custom data directory");
|
||||
path.canonicalize()
|
||||
.expect("failed to canonicalize custom data directory")
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the configuration directory.
|
||||
pub fn config_dir() -> &'static PathBuf {
|
||||
CONFIG_DIR.get_or_init(|| {
|
||||
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
|
||||
custom_dir.join("config")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
dirs::config_dir()
|
||||
.expect("failed to determine RoamingAppData directory")
|
||||
.join(APP_NAME)
|
||||
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
if let Ok(flatpak_xdg_config) = std::env::var("FLATPAK_XDG_CONFIG_HOME") {
|
||||
flatpak_xdg_config.into()
|
||||
} else {
|
||||
dirs::config_dir().expect("failed to determine XDG_CONFIG_HOME directory")
|
||||
}
|
||||
.join(APP_NAME_LOWERCASE)
|
||||
} else {
|
||||
home_dir().join(".config").join(APP_NAME_LOWERCASE)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the data directory.
|
||||
pub fn data_dir() -> &'static PathBuf {
|
||||
CURRENT_DATA_DIR.get_or_init(|| {
|
||||
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
|
||||
custom_dir.clone()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
home_dir()
|
||||
.join("Library/Application Support")
|
||||
.join(APP_NAME)
|
||||
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") {
|
||||
flatpak_xdg_data.into()
|
||||
} else {
|
||||
dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory")
|
||||
}
|
||||
.join(APP_NAME_LOWERCASE)
|
||||
} else if cfg!(target_os = "windows") {
|
||||
dirs::data_local_dir()
|
||||
.expect("failed to determine LocalAppData directory")
|
||||
.join(APP_NAME)
|
||||
} else {
|
||||
config_dir().clone()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the cache directory.
|
||||
pub fn cache_dir() -> &'static PathBuf {
|
||||
static CACHE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
CACHE_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs::cache_dir()
|
||||
.expect("failed to determine caches directory")
|
||||
.join(APP_NAME)
|
||||
} else if cfg!(target_os = "windows") {
|
||||
dirs::cache_dir()
|
||||
.expect("failed to determine LocalAppData directory")
|
||||
.join(APP_NAME)
|
||||
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
if let Ok(flatpak_xdg_cache) = std::env::var("FLATPAK_XDG_CACHE_HOME") {
|
||||
flatpak_xdg_cache.into()
|
||||
} else {
|
||||
dirs::cache_dir().expect("failed to determine XDG_CACHE_HOME directory")
|
||||
}
|
||||
.join(APP_NAME_LOWERCASE)
|
||||
} else {
|
||||
home_dir().join(".cache").join(APP_NAME_LOWERCASE)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the logs directory.
|
||||
pub fn logs_dir() -> &'static PathBuf {
|
||||
static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
LOGS_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
home_dir().join("Library/Logs").join(APP_NAME)
|
||||
} else {
|
||||
data_dir().join("logs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the nostr database directory (LMDB).
|
||||
pub fn nostr_dir() -> &'static PathBuf {
|
||||
static NOSTR_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
NOSTR_DIR.get_or_init(|| data_dir().join("nostr"))
|
||||
}
|
||||
|
||||
/// Returns the path to the local git clone cache (grasp mirrors).
|
||||
pub fn repos_dir() -> &'static PathBuf {
|
||||
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
|
||||
}
|
||||
|
||||
/// Returns the path to the `settings.json` file.
|
||||
pub fn settings_file() -> &'static PathBuf {
|
||||
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
|
||||
}
|
||||
|
||||
/// Returns the path to the `keymap.json` file.
|
||||
pub fn keymap_file() -> &'static PathBuf {
|
||||
static KEYMAP_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
KEYMAP_FILE.get_or_init(|| config_dir().join("keymap.json"))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Build a NIP-34 user grasp list (kind `10317`).
|
||||
pub fn grasp_list(grasp_servers: Vec<RelayUrl>) -> 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"]);
|
||||
}
|
||||
}
|
||||
@@ -68,8 +68,10 @@ pub fn announcements_by(public_key: PublicKey) -> Filter {
|
||||
}
|
||||
|
||||
/// All repository announcements (for global discovery).
|
||||
pub fn all_announcements(limit: usize) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitRepoAnnouncement)
|
||||
.limit(limit)
|
||||
///
|
||||
/// Unbounded: intended for negentropy sync, which reconciles sets
|
||||
/// efficiently regardless of size. Local database queries with this
|
||||
/// filter are served by LMDB, so they stay fast as the database grows.
|
||||
pub fn all_announcements() -> Filter {
|
||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod addr;
|
||||
pub mod builders;
|
||||
pub mod clone_url;
|
||||
pub mod filters;
|
||||
pub mod model;
|
||||
|
||||
@@ -104,13 +104,8 @@ impl NostrBackend {
|
||||
/// event is immediately visible to [`NostrBackend::query`].
|
||||
pub async fn send(&self, builder: EventBuilder) -> Result<Event> {
|
||||
let event = builder.finalize_async(&self.signer).await?;
|
||||
|
||||
let output = self.client.send_event(&event).await?;
|
||||
|
||||
// Keep our own events in the local database; the notification pump
|
||||
// only fires for events received from relays.
|
||||
self.client.database().save_event(&event).await?;
|
||||
|
||||
if output.success.is_empty() && !output.failed.is_empty() {
|
||||
let reasons = output
|
||||
.failed
|
||||
|
||||
@@ -7,7 +7,7 @@ use nostr_sdk::prelude::*;
|
||||
pub struct Update {
|
||||
pub kind: Kind,
|
||||
/// First `a` tag value of the event, if any (e.g. the repository coordinate).
|
||||
pub coordinate: Option<String>,
|
||||
pub coordinate: Option<Coordinate>,
|
||||
pub author: PublicKey,
|
||||
pub event_id: EventId,
|
||||
}
|
||||
@@ -15,12 +15,7 @@ pub struct Update {
|
||||
impl Update {
|
||||
/// Build an update from a received event.
|
||||
pub fn from_event(event: &Event) -> Self {
|
||||
let coordinate = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.kind() == "a")
|
||||
.and_then(|t| t.content())
|
||||
.map(str::to_owned);
|
||||
let coordinate = event.tags.coordinates().nth(0);
|
||||
|
||||
Self {
|
||||
kind: event.kind,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
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://...`).
|
||||
pub const USER_KEYRING: &str = "su.reya.signed#user";
|
||||
/// Keyring entry holding the locally generated key for NIP-46 sessions.
|
||||
pub const MASTER_KEYRING: &str = "su.reya.signed#master";
|
||||
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`
|
||||
/// with an embedded `?master=<nsec>` NIP-46 session key).
|
||||
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||
/// Timeout for NIP-46 signer responses.
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||
|
||||
@@ -39,6 +40,18 @@ pub enum BackendEvent {
|
||||
Connected,
|
||||
/// A new event was received from a relay and stored in the database.
|
||||
NostrUpdate(Update),
|
||||
/// A negentropy sync completed; the database was updated directly,
|
||||
/// so stores should re-query (no [`BackendEvent::NostrUpdate`] is fired
|
||||
/// for synced events).
|
||||
Synced,
|
||||
/// A negentropy sync is in flight. Stores may re-query to render
|
||||
/// incrementally; UI can show `current`/`total` progress.
|
||||
SyncProgress {
|
||||
/// Total events to process.
|
||||
total: u64,
|
||||
/// Events processed so far.
|
||||
current: u64,
|
||||
},
|
||||
/// An event built locally was signed, broadcast and stored.
|
||||
Published(Box<Event>),
|
||||
/// An error occurred.
|
||||
@@ -60,6 +73,8 @@ impl BackendEvent {
|
||||
pub struct Backend {
|
||||
inner: NostrBackend,
|
||||
current_user: Option<PublicKey>,
|
||||
connected: bool,
|
||||
sync_progress: Option<(u64, u64)>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
@@ -106,6 +121,8 @@ impl Backend {
|
||||
let mut this = Self {
|
||||
inner,
|
||||
current_user: None,
|
||||
connected: false,
|
||||
sync_progress: None,
|
||||
tasks: vec![pump],
|
||||
};
|
||||
|
||||
@@ -132,7 +149,11 @@ impl Backend {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(()) => {
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?;
|
||||
this.update(cx, |this, cx| {
|
||||
this.connected = true;
|
||||
cx.emit(BackendEvent::Connected);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
@@ -153,7 +174,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
let user = cx.read_credentials(USER_KEYRING);
|
||||
let master = self.master_key(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let content = match user.await {
|
||||
@@ -169,15 +189,21 @@ impl Backend {
|
||||
let keys = Keys::new(SecretKey::parse(&content)?);
|
||||
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
||||
} else if content.starts_with("bunker://") {
|
||||
let uri = NostrConnectUri::parse(&content)?;
|
||||
let (base, keys) = extract_master_key(&content);
|
||||
let uri = NostrConnectUri::parse(base)?;
|
||||
let mut signer = NostrConnect::new(
|
||||
uri,
|
||||
master.await,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
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))?;
|
||||
}
|
||||
@@ -197,6 +223,141 @@ 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<Self>,
|
||||
) -> flume::Receiver<Result<PublicKey, Error>> {
|
||||
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<RelayMetadata>)> = [
|
||||
(
|
||||
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<RelayUrl> =
|
||||
["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<Self>) {
|
||||
let credential = credential.trim();
|
||||
|
||||
if credential.starts_with("nsec1") {
|
||||
self.login_with_nsec(credential, cx);
|
||||
} else if credential.starts_with("bunker://") {
|
||||
self.login_with_bunker(credential, cx);
|
||||
} else {
|
||||
cx.emit(BackendEvent::error(
|
||||
"Unsupported credential, expected nsec1... or bunker://...",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fresh identity and login with it. The generated key is
|
||||
/// persisted in the keyring like any other `nsec` credential.
|
||||
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
|
||||
let nsec = Keys::generate()
|
||||
.secret_key()
|
||||
.to_bech32()
|
||||
.expect("infallible");
|
||||
self.login_with_nsec(&nsec, cx);
|
||||
}
|
||||
|
||||
/// Login with an `nsec1...` secret key. The credential is verified by
|
||||
/// the signer flow and persisted in the keyring.
|
||||
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
|
||||
@@ -224,9 +385,11 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Login with a `bunker://...` URI (NIP-46). The auth URL, if any, is
|
||||
/// opened in the default browser. The credential is persisted in the
|
||||
/// keyring after the signer proves reachable.
|
||||
/// Login with a `bunker://...` URI (NIP-46). A fresh session key is
|
||||
/// generated and embedded into the stored URI as `?master=<nsec>`, so
|
||||
/// no separate keyring entry is needed. The auth URL, if any, is opened
|
||||
/// in the default browser. The credential is persisted in the keyring
|
||||
/// after the signer proves reachable.
|
||||
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
|
||||
let uri_string = uri.trim().to_owned();
|
||||
|
||||
@@ -238,14 +401,15 @@ impl Backend {
|
||||
}
|
||||
};
|
||||
|
||||
let master = self.master_key(cx);
|
||||
let write = cx.write_credentials(USER_KEYRING, "bunker", uri_string.as_bytes());
|
||||
let keys = Keys::generate();
|
||||
let credential = with_master_key(&uri_string, &keys);
|
||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = async {
|
||||
let mut signer = NostrConnect::new(
|
||||
connect_uri,
|
||||
master.await,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
@@ -288,33 +452,6 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get (or generate and persist) the key used for NIP-46 sessions.
|
||||
fn master_key(&self, cx: &App) -> Task<Keys> {
|
||||
let task = cx.read_credentials(MASTER_KEYRING);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let (keys, new_key) = match task.await {
|
||||
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
|
||||
Ok(secret_key) => (Keys::new(secret_key), false),
|
||||
_ => (Keys::generate(), true),
|
||||
},
|
||||
_ => (Keys::generate(), true),
|
||||
};
|
||||
|
||||
if new_key {
|
||||
let username = keys.public_key().to_hex();
|
||||
let password = keys.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
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch the user's grasp list (kind `10317`) and add the listed grasp
|
||||
/// servers as relays.
|
||||
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
@@ -371,6 +508,21 @@ impl Backend {
|
||||
self.current_user
|
||||
}
|
||||
|
||||
/// Surface an error message through [`BackendEvent::Error`].
|
||||
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
|
||||
cx.emit(BackendEvent::error(message));
|
||||
}
|
||||
|
||||
/// Whether the relay bootstrap has completed.
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.connected
|
||||
}
|
||||
|
||||
/// Progress of the in-flight negentropy sync, if any: `(total, current)`.
|
||||
pub fn sync_progress(&self) -> Option<(u64, u64)> {
|
||||
self.sync_progress
|
||||
}
|
||||
|
||||
/// Update the signer (any type implementing the async signer traits,
|
||||
/// e.g. `Keys`, `NostrConnect`, a browser extension proxy).
|
||||
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||
@@ -418,7 +570,11 @@ impl Backend {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(()) => {
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?;
|
||||
this.update(cx, |this, cx| {
|
||||
this.connected = true;
|
||||
cx.emit(BackendEvent::Connected);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
@@ -464,6 +620,95 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Start a one-shot subscription targeted only at the bootstrap relays,
|
||||
/// auto-closing after EOSE or a short timeout. Matching events are stored
|
||||
/// in the database and surface as [`BackendEvent::NostrUpdate`] while the
|
||||
/// subscription is open.
|
||||
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||
let backend = self.inner.clone();
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
subscribe_bootstrap_only(&backend.client(), filters).await
|
||||
});
|
||||
|
||||
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(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Negentropy-sync the given filter against the bootstrap relays:
|
||||
/// reconciles the local database with the relays in both directions.
|
||||
/// Emits [`BackendEvent::SyncProgress`] while running (throttled to
|
||||
/// whole-percent changes) and [`BackendEvent::Synced`] on completion.
|
||||
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||
let backend = self.inner.clone();
|
||||
|
||||
self.sync_progress = Some((0, 0));
|
||||
cx.notify();
|
||||
|
||||
let (tx, mut rx) = SyncProgress::channel();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let mut last_percent: u64 = 0;
|
||||
|
||||
while rx.changed().await.is_ok() {
|
||||
let progress = *rx.borrow_and_update();
|
||||
let percent = (progress.percentage() * 100.0) as u64;
|
||||
|
||||
if progress.current > 0 && percent != last_percent {
|
||||
last_percent = percent;
|
||||
|
||||
let alive = this.update(cx, |this, cx| {
|
||||
this.sync_progress = Some((progress.total, progress.current));
|
||||
cx.emit(BackendEvent::SyncProgress {
|
||||
total: progress.total,
|
||||
current: progress.current,
|
||||
});
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
if alive.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
let opts = SyncOptions::default().progress(tx);
|
||||
sync_bootstrap_only(&backend.client(), filter, opts).await
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(summary) => {
|
||||
log::debug!(
|
||||
"sync done: {} received, {} sent",
|
||||
summary.received.len(),
|
||||
summary.sent.len()
|
||||
);
|
||||
this.update(cx, |this, cx| {
|
||||
this.sync_progress = None;
|
||||
cx.emit(BackendEvent::Synced);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.sync_progress = None;
|
||||
cx.emit(BackendEvent::error(e.to_string()))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Sign, broadcast and locally store an event. Emits
|
||||
/// [`BackendEvent::Published`] on success so stores can refresh.
|
||||
///
|
||||
@@ -476,7 +721,6 @@ impl Backend {
|
||||
cx: &mut Context<Self>,
|
||||
) -> flume::Receiver<Result<Event, Error>> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
|
||||
let backend = self.inner.clone();
|
||||
let task = cx.background_spawn(async move { backend.send(builder).await });
|
||||
|
||||
@@ -490,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()));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,3 +748,59 @@ impl Backend {
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe only on the bootstrap relays, auto-closing after EOSE or a
|
||||
/// short timeout. Use for one-shot data fetches (repo events, profiles)
|
||||
/// instead of persistent gossip-routed subscriptions.
|
||||
pub(crate) async fn subscribe_bootstrap_only(
|
||||
client: &Client,
|
||||
filters: Vec<Filter>,
|
||||
) -> Result<(), Error> {
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(Duration::from_secs(10)));
|
||||
|
||||
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
||||
.iter()
|
||||
.map(|relay| (*relay, filters.clone()))
|
||||
.collect();
|
||||
|
||||
client.subscribe(target).close_on(opts).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Negentropy-sync the filter against the bootstrap relays only.
|
||||
pub(crate) async fn sync_bootstrap_only(
|
||||
client: &Client,
|
||||
filter: Filter,
|
||||
opts: SyncOptions,
|
||||
) -> Result<SyncSummary, Error> {
|
||||
let output = client
|
||||
.sync(filter)
|
||||
.with(BOOTSTRAP_RELAYS)
|
||||
.opts(opts)
|
||||
.await?;
|
||||
Ok(output.value)
|
||||
}
|
||||
|
||||
/// Embed a NIP-46 session key into a bunker URI as `?master=<nsec>`.
|
||||
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}")
|
||||
}
|
||||
|
||||
/// Split a stored bunker credential into the plain URI and the session key.
|
||||
/// Credentials without an embedded key (legacy) get a fresh one.
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use anyhow::Error;
|
||||
use gpui::{App, Context, Entity, Global, SharedString, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
||||
|
||||
/// A user profile (kind `0` metadata), as plain data for the UI.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -181,6 +181,50 @@ impl ProfileStore {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// 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<Self>) {
|
||||
if self.seen.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let client = Backend::global(cx).read(cx).client();
|
||||
let authors: Vec<PublicKey> = self.seen.iter().copied().collect();
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
||||
for event in events {
|
||||
match latest.get(&event.pubkey) {
|
||||
Some((ts, _)) if *ts >= event.created_at => {}
|
||||
_ => {
|
||||
latest.insert(
|
||||
event.pubkey,
|
||||
(
|
||||
event.created_at,
|
||||
Metadata::from_json(&event.content).unwrap_or_default(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
for (public_key, (_, metadata)) in latest {
|
||||
this.profiles
|
||||
.insert(public_key, Profile::new(public_key, metadata));
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Drain the queue in a batched fetch, debounced to collect requests.
|
||||
fn queue_fetch(&mut self, cx: &mut Context<Self>) {
|
||||
if self.fetching {
|
||||
@@ -210,10 +254,16 @@ impl ProfileStore {
|
||||
.kind(Kind::Metadata)
|
||||
.authors(batch.into_iter().collect::<Vec<PublicKey>>());
|
||||
|
||||
// Gossip routes the fetch to each author's relays. Fetched
|
||||
// events land in the database and surface via NostrUpdate.
|
||||
if let Err(e) = client.fetch_events(filter).await {
|
||||
log::warn!("profile fetch failed: {e}");
|
||||
// Negentropy-sync with the bootstrap relays. Synced events
|
||||
// are written to the database directly (no NostrUpdate), so
|
||||
// re-apply from the database afterwards.
|
||||
match sync_bootstrap_only(&client, filter, SyncOptions::default()).await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| this.apply_seen(cx))?;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("profile sync failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ use crate::backend::{Backend, BackendEvent};
|
||||
/// their resolved statuses. Always derived from the local database.
|
||||
pub struct RepoStore {
|
||||
addr: RepoAddr,
|
||||
addr_string: String,
|
||||
pub announcement: Option<Announcement>,
|
||||
/// `(refname, commit-id)` pairs from the latest state announcement.
|
||||
pub refs: Vec<(String, String)>,
|
||||
@@ -29,20 +28,27 @@ pub struct RepoStore {
|
||||
|
||||
impl RepoStore {
|
||||
pub fn new(addr: RepoAddr, cx: &mut Context<Self>) -> Self {
|
||||
let addr_string = addr.to_string();
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
let subscription = cx.subscribe(&Backend::global(cx), |this, _backend, event, cx| {
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let relevant = match event {
|
||||
BackendEvent::NostrUpdate(update) => {
|
||||
update.coordinate.as_deref() == Some(this.addr_string.as_str())
|
||||
|| (update.kind == Kind::GitRepoAnnouncement
|
||||
&& update.author == this.addr.owner)
|
||||
let coordinate = update.coordinate.as_ref() == Some(&this.addr.coordinate());
|
||||
let author = update.author == this.addr.owner;
|
||||
let kind = update.kind == Kind::GitRepoAnnouncement;
|
||||
|
||||
coordinate || (author && kind)
|
||||
}
|
||||
BackendEvent::Published(event) => {
|
||||
event.kind == Kind::GitRepoAnnouncement && event.pubkey == this.addr.owner
|
||||
|| event.tags.iter().any(|t| {
|
||||
t.kind() == "a" && t.content() == Some(this.addr_string.as_str())
|
||||
})
|
||||
let kind = event.kind == Kind::GitRepoAnnouncement;
|
||||
let author = event.pubkey == this.addr.owner;
|
||||
let coordinate = event
|
||||
.tags
|
||||
.coordinates()
|
||||
.into_iter()
|
||||
.any(|c| c == this.addr.coordinate());
|
||||
|
||||
coordinate || (kind && author)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
@@ -54,7 +60,6 @@ impl RepoStore {
|
||||
|
||||
let mut store = Self {
|
||||
addr,
|
||||
addr_string,
|
||||
announcement: None,
|
||||
refs: Vec::new(),
|
||||
head: None,
|
||||
@@ -78,14 +83,20 @@ impl RepoStore {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// Subscribe the relay pool to this repository's activity.
|
||||
/// Fetch this repository's events from the bootstrap relays (one-shot,
|
||||
/// auto-closing subscription).
|
||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||
let addr = self.addr.clone();
|
||||
|
||||
Backend::global(cx).update(cx, |backend, cx| {
|
||||
backend.subscribe(filters::announcement(&addr), cx);
|
||||
backend.subscribe(filters::state(&addr), cx);
|
||||
backend.subscribe(filters::activity(&addr), cx);
|
||||
backend.subscribe_bootstrap(
|
||||
vec![
|
||||
filters::announcement(&addr),
|
||||
filters::state(&addr),
|
||||
filters::activity(&addr),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -20,16 +20,19 @@ pub struct RepoListStore {
|
||||
impl RepoListStore {
|
||||
/// Create a store. If `author` is `None`, all announcements are listed.
|
||||
pub fn new(author: Option<PublicKey>, cx: &mut Context<Self>) -> Self {
|
||||
let subscription = cx.subscribe(&Backend::global(cx), |this, _backend, event, cx| {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let git_kind = Kind::GitRepoAnnouncement;
|
||||
|
||||
let relevant = match event {
|
||||
BackendEvent::NostrUpdate(update) => {
|
||||
update.kind == Kind::GitRepoAnnouncement
|
||||
&& this.author.is_none_or(|a| a == update.author)
|
||||
update.kind == git_kind && this.author.is_none_or(|a| a == update.author)
|
||||
}
|
||||
BackendEvent::Published(event) => {
|
||||
event.kind == Kind::GitRepoAnnouncement
|
||||
&& this.author.is_none_or(|a| a == event.pubkey)
|
||||
event.kind == git_kind && this.author.is_none_or(|a| a == event.pubkey)
|
||||
}
|
||||
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -59,15 +62,17 @@ impl RepoListStore {
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// Negentropy-sync announcements with the bootstrap relays.
|
||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let author = self.author;
|
||||
|
||||
Backend::global(cx).update(cx, |backend, cx| {
|
||||
backend.update(cx, |backend, cx| {
|
||||
let filter = match author {
|
||||
Some(a) => filters::announcements_by(a),
|
||||
None => filters::all_announcements(500),
|
||||
None => filters::all_announcements(),
|
||||
};
|
||||
backend.subscribe(filter, cx);
|
||||
backend.sync_bootstrap(filter, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,7 +94,7 @@ impl RepoListStore {
|
||||
loop {
|
||||
let filter = match author {
|
||||
Some(a) => filters::announcements_by(a),
|
||||
None => filters::all_announcements(500),
|
||||
None => filters::all_announcements(),
|
||||
};
|
||||
|
||||
let events = match client.database().query(filter).await {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "workspace"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
signed_core = { path = "../signed_core" }
|
||||
signed_state = { path = "../signed_state" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
@@ -0,0 +1,15 @@
|
||||
mod views;
|
||||
mod workspace;
|
||||
|
||||
pub use views::{RepoListView, SidebarPanel};
|
||||
pub use workspace::Workspace;
|
||||
|
||||
use gpui::{App, AppContext, Entity, Window};
|
||||
use gpui_component::Root;
|
||||
|
||||
/// Build the root view tree. Requires `signed_state::init` and
|
||||
/// `gpui_component::init` to have been called first.
|
||||
pub fn root(window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let view = cx.new(|cx| Workspace::new(window, cx));
|
||||
cx.new(|cx| Root::new(view, window, cx))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod repo_list;
|
||||
mod sidebar;
|
||||
|
||||
pub use repo_list::RepoListView;
|
||||
pub use sidebar::SidebarPanel;
|
||||
@@ -0,0 +1,159 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
||||
Subscription, Window, div, px, uniform_list,
|
||||
};
|
||||
use gpui_component::dock::{Panel, PanelEvent};
|
||||
use gpui_component::{ActiveTheme, StyledExt};
|
||||
use signed_core::Announcement;
|
||||
use signed_state::{ProfileStore, RepoListStore};
|
||||
|
||||
/// Browse all announced repositories (works anonymously).
|
||||
pub struct RepoListView {
|
||||
store: Entity<RepoListStore>,
|
||||
focus_handle: FocusHandle,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl RepoListView {
|
||||
pub fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let store = cx.new(|cx| RepoListStore::new(None, cx));
|
||||
let subscription = cx.observe(&store, |_this, _store, cx| cx.notify());
|
||||
|
||||
Self {
|
||||
store,
|
||||
focus_handle: cx.focus_handle(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_card(&self, announcement: &Announcement, cx: &mut App) -> AnyElement {
|
||||
let name = announcement
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| announcement.id.clone());
|
||||
|
||||
let owner = ProfileStore::global(cx)
|
||||
.update(cx, |store, cx| store.get(announcement.owner, cx))
|
||||
.name();
|
||||
|
||||
let description = announcement.description.clone().unwrap_or_default();
|
||||
|
||||
div()
|
||||
.v_flex()
|
||||
.h(px(60.))
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.px_4()
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.whitespace_nowrap()
|
||||
.child(owner),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(description),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for RepoListView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"repo_list"
|
||||
}
|
||||
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
"Explore"
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for RepoListView {}
|
||||
|
||||
impl Focusable for RepoListView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RepoListView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let announcements = self.store.read(cx).announcements.clone();
|
||||
let has_announcements = !announcements.is_empty();
|
||||
let count = announcements.len();
|
||||
|
||||
div()
|
||||
.v_flex()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.h_flex()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.items_center()
|
||||
.child(div().text_sm().font_semibold().child("Repositories"))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(format!(" ({count})"))),
|
||||
),
|
||||
)
|
||||
.when(!has_announcements, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.size_full()
|
||||
.v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("No repositories found. Waiting for relays..."),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(has_announcements, |this| {
|
||||
this.child(
|
||||
uniform_list(
|
||||
"repos",
|
||||
count,
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
let mut items = vec![];
|
||||
|
||||
for ix in range {
|
||||
items.push(this.render_card(&announcements[ix], cx));
|
||||
}
|
||||
|
||||
items
|
||||
}),
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use gpui::{App, Window, px};
|
||||
use gpui_component::WindowExt;
|
||||
|
||||
/// Open the Import Identity dialog.
|
||||
///
|
||||
/// Currently a placeholder — the dialog only shows a title for now.
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
dialog.title("Import identity").width(px(400.))
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
App, Context, EventEmitter, FocusHandle, Focusable, Render, Subscription, WeakEntity, Window,
|
||||
div,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent};
|
||||
use gpui_component::input::InputState;
|
||||
use gpui_component::{ActiveTheme, v_flex};
|
||||
use signed_state::{Backend, BackendEvent};
|
||||
|
||||
use super::RepoListView;
|
||||
|
||||
mod import_identity_dialog;
|
||||
mod onboarding_dialog;
|
||||
|
||||
use self::onboarding_dialog::OnboardingState;
|
||||
|
||||
/// Left-dock panel with navigation entries. Entries open content panels in
|
||||
/// the dock area.
|
||||
pub struct SidebarPanel {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
explore: Option<WeakEntity<RepoListView>>,
|
||||
logged_in: bool,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl SidebarPanel {
|
||||
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let logged_in = backend.read(cx).current_user().is_some();
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, backend, event, cx| {
|
||||
match event {
|
||||
BackendEvent::SignerChanged => {
|
||||
this.logged_in = backend.read(cx).current_user().is_some();
|
||||
}
|
||||
BackendEvent::SignerRequired => {
|
||||
this.logged_in = false;
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
explore: None,
|
||||
logged_in,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Explore (repository list) panel in the center of the dock
|
||||
/// area. No-op if it's already open.
|
||||
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self
|
||||
.explore
|
||||
.as_ref()
|
||||
.and_then(WeakEntity::upgrade)
|
||||
.is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let panel = cx.new(|cx| RepoListView::new(window, cx));
|
||||
self.explore = Some(panel.downgrade());
|
||||
|
||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Show the Onboarding dialog.
|
||||
fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
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(|_| OnboardingState::default());
|
||||
|
||||
onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx);
|
||||
}
|
||||
|
||||
/// Show the Import Identity dialog.
|
||||
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
import_identity_dialog::open(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for SidebarPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"sidebar"
|
||||
}
|
||||
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn inner_padding(&self, _cx: &App) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for SidebarPanel {}
|
||||
|
||||
impl Focusable for SidebarPanel {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SidebarPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if self.logged_in {
|
||||
v_flex().child(
|
||||
Button::new("explore")
|
||||
.label("Explore")
|
||||
.w_full()
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_explore(window, cx))),
|
||||
)
|
||||
} else {
|
||||
v_flex()
|
||||
.p_4()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Sign in to continue"),
|
||||
)
|
||||
.child(
|
||||
Button::new("onboarding")
|
||||
.label("Join now")
|
||||
.primary()
|
||||
.w_full()
|
||||
.on_click(
|
||||
cx.listener(|this, _ev, window, cx| this.open_onboarding(window, cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("import-identity")
|
||||
.label("Import identity")
|
||||
.secondary()
|
||||
.w_full()
|
||||
.on_click(
|
||||
cx.listener(|this, _ev, window, cx| this.open_import(window, cx)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, SharedString, Window, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState};
|
||||
use gpui_component::{ActiveTheme, Disableable, WindowExt};
|
||||
use signed_state::Backend;
|
||||
|
||||
/// Shared state for the Onboarding dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct OnboardingState {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
|
||||
/// Open the Onboarding dialog for creating a new identity.
|
||||
///
|
||||
/// The caller is responsible for creating the input and state entities and
|
||||
/// passing them in. This function only builds the dialog UI and wires up
|
||||
/// the continue-button handler.
|
||||
pub fn open(
|
||||
name_input: Entity<InputState>,
|
||||
pass_input: Entity<InputState>,
|
||||
repass_input: Entity<InputState>,
|
||||
state: Entity<OnboardingState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
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 backend = Backend::global(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.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.update_window(handle, |_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = false;
|
||||
state.error = Some(e.to_string().into());
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(_) => {}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px};
|
||||
use gpui_component::dock::{DockArea, DockItem};
|
||||
use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, h_flex, v_flex};
|
||||
use signed_state::{Backend, BackendEvent};
|
||||
|
||||
use crate::views::SidebarPanel;
|
||||
|
||||
/// Root view of the app: title bar, dock area, status bar.
|
||||
pub struct Workspace {
|
||||
dock: Entity<DockArea>,
|
||||
status: SharedString,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx));
|
||||
|
||||
let weak_dock = dock.downgrade();
|
||||
let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx));
|
||||
|
||||
dock.update(cx, |dock_area, cx| {
|
||||
dock_area.set_left_dock(
|
||||
DockItem::panel(Arc::new(sidebar.clone())),
|
||||
Some(px(260.)),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
sidebar.update(cx, |sidebar, cx| sidebar.open_explore(window, cx));
|
||||
|
||||
let connected = backend.read(cx).is_connected();
|
||||
let sync_progress = backend.read(cx).sync_progress();
|
||||
|
||||
let status = if let Some((total, current)) = sync_progress {
|
||||
format!("Syncing repositories... {current}/{total}").into()
|
||||
} else if connected {
|
||||
"Connected".into()
|
||||
} else {
|
||||
"Connecting...".into()
|
||||
};
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
match event {
|
||||
BackendEvent::Connected => this.status = "Connected".into(),
|
||||
BackendEvent::SyncProgress { total, current } => {
|
||||
this.status = format!("Syncing repositories... {current}/{total}").into()
|
||||
}
|
||||
BackendEvent::Synced => this.status = "Connected".into(),
|
||||
BackendEvent::Error(error) => this.status = error.clone().into(),
|
||||
_ => return,
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
status,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Workspace {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let dialog_layer = Root::render_dialog_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
div()
|
||||
.id("workspace")
|
||||
.v_flex()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(
|
||||
TitleBar::new()
|
||||
// Left
|
||||
.child(div())
|
||||
// Right
|
||||
.child(
|
||||
h_flex().px_2().child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(self.status.clone()),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Dock Area
|
||||
.child(self.dock.clone()),
|
||||
)
|
||||
// Notifications
|
||||
.children(notification_layer)
|
||||
// Modals
|
||||
.children(dialog_layer)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ name = "signed"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
paths = { path = "../crates/paths" }
|
||||
signed_state = { path = "../crates/signed_state" }
|
||||
workspace = { path = "../crates/workspace" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
gpui_linux.workspace = true
|
||||
|
||||
+6
-28
@@ -1,30 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::*;
|
||||
use gpui_component::button::*;
|
||||
use gpui_component::*;
|
||||
use gpui_platform::application;
|
||||
|
||||
pub struct HelloWorld;
|
||||
|
||||
impl Render for HelloWorld {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.v_flex()
|
||||
.gap_2()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child("Hello, World!")
|
||||
.child(
|
||||
Button::new("ok")
|
||||
.primary()
|
||||
.label("Let's Go!")
|
||||
.on_click(|_ev, _window, _cx| println!("Clicked!")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt::init();
|
||||
@@ -34,8 +12,12 @@ fn main() {
|
||||
.run(move |cx| {
|
||||
gpui_component::init(cx);
|
||||
|
||||
// Initialize backend and stores (connects relays, restores session)
|
||||
std::fs::create_dir_all(paths::nostr_dir()).ok();
|
||||
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 {
|
||||
@@ -53,11 +35,7 @@ fn main() {
|
||||
};
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
cx.open_window(opts, |window, cx| {
|
||||
let view = cx.new(|_| HelloWorld);
|
||||
cx.new(|cx| Root::new(view, window, cx))
|
||||
})
|
||||
.ok();
|
||||
let _ = cx.open_window(opts, workspace::root);
|
||||
})
|
||||
.detach();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user