feat: implement a basic backend (#1)
Reviewed-on: https://git.reya.su/reya/signed/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
Generated
+1852
-15
File diff suppressed because it is too large
Load Diff
+9
-6
@@ -20,12 +20,15 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
|||||||
|
|
||||||
gpui-component = { git = "https://github.com/longbridge/gpui-component" }
|
gpui-component = { git = "https://github.com/longbridge/gpui-component" }
|
||||||
|
|
||||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
nostr = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215", features = ["nip59", "nip49", "nip44"] }
|
||||||
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" }
|
||||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-memory = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" }
|
||||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-blossom = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" }
|
||||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" }
|
||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" }
|
||||||
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" }
|
||||||
|
|
||||||
|
gix = { version = "0.86", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation"] }
|
||||||
|
|
||||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||||
smol = "2"
|
smol = "2"
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "signed_core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nostr.workspace = true
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// Address of a NIP-34 repository announcement: `30617:<owner-pubkey>:<repo-id>`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct RepoAddr {
|
||||||
|
pub owner: PublicKey,
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoAddr {
|
||||||
|
pub fn new(owner: PublicKey, id: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
owner,
|
||||||
|
id: id.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The NIP-33 coordinate for the announcement event (`a` tag value).
|
||||||
|
pub fn coordinate(&self) -> Coordinate {
|
||||||
|
Coordinate::new(Kind::GitRepoAnnouncement, self.owner).identifier(self.id.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RepoAddr {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.coordinate())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for RepoAddr {
|
||||||
|
type Err = nostr::error::Error;
|
||||||
|
|
||||||
|
/// Parse from `<kind>:<pubkey>:<d-tag>`, `naddr1...` bech32 or `nostr:naddr1...` URI.
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
let coordinate = Coordinate::parse(s)?;
|
||||||
|
Ok(Self {
|
||||||
|
owner: coordinate.public_key,
|
||||||
|
id: coordinate.identifier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
use crate::RepoAddr;
|
||||||
|
|
||||||
|
/// Target of a `nostr://` clone URL (NIP-34 "Nostr Clone URL format").
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CloneTarget {
|
||||||
|
/// `nostr://<naddr1...>` — direct repository address.
|
||||||
|
Addr(RepoAddr),
|
||||||
|
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
|
||||||
|
UserRepo {
|
||||||
|
/// `npub1...` or a NIP-05 identifier.
|
||||||
|
user: String,
|
||||||
|
relay_hint: Option<RelayUrl>,
|
||||||
|
/// `d` tag identifier of the repository.
|
||||||
|
identifier: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `nostr://` clone URL. Returns `None` for other URL schemes.
|
||||||
|
pub fn parse_clone_url(url: &str) -> Option<CloneTarget> {
|
||||||
|
let rest = url.strip_prefix("nostr://")?;
|
||||||
|
let mut parts = rest.split('/');
|
||||||
|
|
||||||
|
let first = parts.next()?;
|
||||||
|
let second = parts.next()?;
|
||||||
|
let third = parts.next();
|
||||||
|
|
||||||
|
if first.starts_with("naddr1") {
|
||||||
|
let coordinate = Nip19Coordinate::from_bech32(first).ok()?;
|
||||||
|
return Some(CloneTarget::Addr(RepoAddr::new(
|
||||||
|
coordinate.coordinate.public_key,
|
||||||
|
coordinate.coordinate.identifier,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (relay_hint, identifier) = match third {
|
||||||
|
Some(id) => (
|
||||||
|
RelayUrl::parse(&percent_decode(second)).ok(),
|
||||||
|
percent_decode(id),
|
||||||
|
),
|
||||||
|
None => (None, percent_decode(second)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(CloneTarget::UserRepo {
|
||||||
|
user: first.to_owned(),
|
||||||
|
relay_hint,
|
||||||
|
identifier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode(input: &str) -> String {
|
||||||
|
let bytes = input.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||||
|
let hex = &input[i + 1..i + 3];
|
||||||
|
if let Ok(v) = u8::from_str_radix(hex, 16) {
|
||||||
|
out.push(v);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_user_repo_without_relay() {
|
||||||
|
let target = parse_clone_url(
|
||||||
|
"nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
target,
|
||||||
|
CloneTarget::UserRepo {
|
||||||
|
user: "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr".to_owned(),
|
||||||
|
relay_hint: None,
|
||||||
|
identifier: "ngit".to_owned(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_user_repo_with_relay_hint() {
|
||||||
|
let target = parse_clone_url("nostr://danconwaydev.com/relay.ngit.dev/ngit").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
target,
|
||||||
|
CloneTarget::UserRepo {
|
||||||
|
user: "danconwaydev.com".to_owned(),
|
||||||
|
relay_hint: RelayUrl::parse("relay.ngit.dev").ok(),
|
||||||
|
identifier: "ngit".to_owned(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_percent_encoded_parts() {
|
||||||
|
let target = parse_clone_url(
|
||||||
|
"nostr://danconwaydev.com/ws%3A%2F%2Flocalhost%3A7334/my-local-only-repo",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
target,
|
||||||
|
CloneTarget::UserRepo {
|
||||||
|
user: "danconwaydev.com".to_owned(),
|
||||||
|
relay_hint: RelayUrl::parse("ws://localhost:7334").ok(),
|
||||||
|
identifier: "my-local-only-repo".to_owned(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
use nostr::filter::{Alphabet, SingleLetterTag};
|
||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
use crate::RepoAddr;
|
||||||
|
|
||||||
|
/// Kinds that make up the activity of a repository.
|
||||||
|
pub const ACTIVITY_KINDS: [Kind; 8] = [
|
||||||
|
Kind::GitPatch,
|
||||||
|
Kind::GitPullRequest,
|
||||||
|
Kind::GitPullRequestUpdate,
|
||||||
|
Kind::GitIssue,
|
||||||
|
Kind::GitStatusOpen,
|
||||||
|
Kind::GitStatusApplied,
|
||||||
|
Kind::GitStatusClosed,
|
||||||
|
Kind::GitStatusDraft,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Latest announcement event for a repository.
|
||||||
|
pub fn announcement(addr: &RepoAddr) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::GitRepoAnnouncement)
|
||||||
|
.author(addr.owner)
|
||||||
|
.identifier(addr.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Latest state event (refs / HEAD) for a repository.
|
||||||
|
pub fn state(addr: &RepoAddr) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::RepoState)
|
||||||
|
.author(addr.owner)
|
||||||
|
.identifier(addr.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All NIP-34 activity addressed to a repository (`#a` tag).
|
||||||
|
///
|
||||||
|
/// Note: the `a` tag on status events is optional per NIP-34, so statuses
|
||||||
|
/// published without it won't be matched here.
|
||||||
|
pub fn activity(addr: &RepoAddr) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kinds(ACTIVITY_KINDS)
|
||||||
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), addr.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status events (`1630..=1633`) referencing a specific root event (`#e` tag).
|
||||||
|
pub fn statuses_for(root: EventId) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kinds([
|
||||||
|
Kind::GitStatusOpen,
|
||||||
|
Kind::GitStatusApplied,
|
||||||
|
Kind::GitStatusClosed,
|
||||||
|
Kind::GitStatusDraft,
|
||||||
|
])
|
||||||
|
.event(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A user's grasp list (kind `10317`).
|
||||||
|
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::GitUserGraspList)
|
||||||
|
.author(public_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All repositories announced by an author.
|
||||||
|
pub fn announcements_by(public_key: PublicKey) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::GitRepoAnnouncement)
|
||||||
|
.author(public_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All repository announcements (for global discovery).
|
||||||
|
pub fn all_announcements(limit: usize) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::GitRepoAnnouncement)
|
||||||
|
.limit(limit)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
pub mod addr;
|
||||||
|
pub mod clone_url;
|
||||||
|
pub mod filters;
|
||||||
|
pub mod model;
|
||||||
|
pub mod status;
|
||||||
|
|
||||||
|
pub use addr::RepoAddr;
|
||||||
|
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||||
|
pub use model::Announcement;
|
||||||
|
pub use status::{RepoStatus, references_root, resolve_status};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// Parsed NIP-34 repository announcement (plain data, ready for the UI).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Announcement {
|
||||||
|
/// Author of the announcement event.
|
||||||
|
pub owner: PublicKey,
|
||||||
|
/// When the announcement was published (for latest-wins resolution).
|
||||||
|
pub created_at: Timestamp,
|
||||||
|
/// Repository ID (`d` tag).
|
||||||
|
pub id: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Webpage URLs for browsing.
|
||||||
|
pub web: Vec<String>,
|
||||||
|
/// URLs for `git clone`.
|
||||||
|
pub clone: Vec<String>,
|
||||||
|
/// Relays the repository monitors for patches and issues.
|
||||||
|
pub relays: Vec<String>,
|
||||||
|
/// Earliest unique commit ID (`r` tag with `euc` marker).
|
||||||
|
pub euc: Option<String>,
|
||||||
|
/// Other recognized maintainers.
|
||||||
|
pub maintainers: Vec<PublicKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Announcement {
|
||||||
|
/// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing.
|
||||||
|
pub fn from_event(event: &Event) -> Option<Self> {
|
||||||
|
if event.kind != Kind::GitRepoAnnouncement {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut id: Option<String> = None;
|
||||||
|
let mut name: Option<String> = None;
|
||||||
|
let mut description: Option<String> = None;
|
||||||
|
let mut web: Vec<String> = Vec::new();
|
||||||
|
let mut clone: Vec<String> = Vec::new();
|
||||||
|
let mut relays: Vec<String> = Vec::new();
|
||||||
|
let mut euc: Option<String> = None;
|
||||||
|
let mut maintainers: Vec<PublicKey> = Vec::new();
|
||||||
|
|
||||||
|
for tag in event.tags.iter() {
|
||||||
|
let values: &[String] = tag.as_slice();
|
||||||
|
match tag.kind() {
|
||||||
|
"d" => id = tag.content().map(str::to_owned),
|
||||||
|
"name" => name = tag.content().map(str::to_owned),
|
||||||
|
"description" => description = tag.content().map(str::to_owned),
|
||||||
|
"web" => web.extend(values.iter().skip(1).cloned()),
|
||||||
|
"clone" => clone.extend(values.iter().skip(1).cloned()),
|
||||||
|
"relays" => relays.extend(values.iter().skip(1).cloned()),
|
||||||
|
"r" => {
|
||||||
|
if values.get(2).map(String::as_str) == Some("euc") {
|
||||||
|
euc = tag.content().map(str::to_owned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"maintainers" => {
|
||||||
|
maintainers.extend(
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.filter_map(|v| PublicKey::from_hex(v).ok()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
owner: event.pubkey,
|
||||||
|
created_at: event.created_at,
|
||||||
|
id: id?,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
web,
|
||||||
|
clone,
|
||||||
|
relays,
|
||||||
|
euc,
|
||||||
|
maintainers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The repository address of this announcement.
|
||||||
|
pub fn addr(&self) -> crate::RepoAddr {
|
||||||
|
crate::RepoAddr::new(self.owner, self.id.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// Status of a root patch, pull request or issue (kinds `1630..=1633`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum RepoStatus {
|
||||||
|
Open,
|
||||||
|
Applied,
|
||||||
|
Closed,
|
||||||
|
Draft,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoStatus {
|
||||||
|
pub fn from_kind(kind: Kind) -> Option<Self> {
|
||||||
|
match kind {
|
||||||
|
Kind::GitStatusOpen => Some(Self::Open),
|
||||||
|
Kind::GitStatusApplied => Some(Self::Applied),
|
||||||
|
Kind::GitStatusClosed => Some(Self::Closed),
|
||||||
|
Kind::GitStatusDraft => Some(Self::Draft),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind(self) -> Kind {
|
||||||
|
match self {
|
||||||
|
Self::Open => Kind::GitStatusOpen,
|
||||||
|
Self::Applied => Kind::GitStatusApplied,
|
||||||
|
Self::Closed => Kind::GitStatusClosed,
|
||||||
|
Self::Draft => Kind::GitStatusDraft,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a status event references the given root event via an `e` tag.
|
||||||
|
pub fn references_root(event: &Event, root: &EventId) -> bool {
|
||||||
|
let root_hex: String = root.to_hex();
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.kind() == "e" && t.content() == Some(root_hex.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the status of a root event per NIP-34:
|
||||||
|
/// the most recent status event from the root author or a maintainer wins.
|
||||||
|
/// Defaults to [`RepoStatus::Open`].
|
||||||
|
pub fn resolve_status<'a, I>(
|
||||||
|
status_events: I,
|
||||||
|
root_author: &PublicKey,
|
||||||
|
maintainers: &[PublicKey],
|
||||||
|
) -> RepoStatus
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = &'a Event>,
|
||||||
|
{
|
||||||
|
status_events
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| RepoStatus::from_kind(e.kind).is_some())
|
||||||
|
.filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey))
|
||||||
|
.max_by_key(|e| e.created_at)
|
||||||
|
.and_then(|e| RepoStatus::from_kind(e.kind))
|
||||||
|
.unwrap_or(RepoStatus::Open)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "signed_git"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
signed_core = { path = "../signed_core" }
|
||||||
|
|
||||||
|
nostr.workspace = true
|
||||||
|
gix.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
//! Blocking local git operations against GRASP servers.
|
||||||
|
//!
|
||||||
|
//! All functions may block; call them inside `cx.background_spawn`.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use gix::interrupt::IS_INTERRUPTED;
|
||||||
|
use gix::progress::Discard;
|
||||||
|
use signed_core::RepoAddr;
|
||||||
|
|
||||||
|
/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GitCache {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitCache {
|
||||||
|
pub fn new(root: PathBuf) -> Self {
|
||||||
|
Self { root }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local path of the clone for a repository.
|
||||||
|
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
||||||
|
self.root
|
||||||
|
.join(addr.owner.to_hex())
|
||||||
|
.join(sanitize_path_component(&addr.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an existing clone.
|
||||||
|
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
|
||||||
|
let path = self.repo_path(addr);
|
||||||
|
match gix::open(&path) {
|
||||||
|
Ok(repo) => Ok(Some(repo)),
|
||||||
|
Err(gix::open::Error::NotARepository { .. }) => Ok(None),
|
||||||
|
Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the local clone if it exists (fetching first), otherwise clone
|
||||||
|
/// from the first working URL in `clone_urls` (the announcement's `clone` tag).
|
||||||
|
pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> Result<gix::Repository> {
|
||||||
|
let path = self.repo_path(addr);
|
||||||
|
|
||||||
|
if let Some(repo) = self.open(addr)? {
|
||||||
|
fetch_all(&repo).ok();
|
||||||
|
return Ok(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last_err: Option<anyhow::Error> = None;
|
||||||
|
|
||||||
|
for url in clone_urls {
|
||||||
|
match clone(url, &path) {
|
||||||
|
Ok(repo) => return Ok(repo),
|
||||||
|
Err(e) => last_err = Some(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match last_err {
|
||||||
|
Some(e) => Err(e).context("failed to clone from any mirror"),
|
||||||
|
None => bail!("no clone URLs provided"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all configured refspecs from `origin`.
|
||||||
|
pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
|
||||||
|
repo.find_remote("origin")?
|
||||||
|
.connect(gix::remote::Direction::Fetch)?
|
||||||
|
.prepare_fetch(Discard, Default::default())?
|
||||||
|
.receive(Discard, &IS_INTERRUPTED)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a `git format-patch` patch (or series) with `git am`.
|
||||||
|
///
|
||||||
|
/// Uses the git CLI because it handles the mbox format natively; can be
|
||||||
|
/// replaced with a pure-Rust implementation later without changing callers.
|
||||||
|
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
||||||
|
let mut child = Command::new("git")
|
||||||
|
.arg("am")
|
||||||
|
.current_dir(repo_path)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.context("failed to spawn `git am`")?;
|
||||||
|
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.as_mut()
|
||||||
|
.expect("stdin piped")
|
||||||
|
.write_all(patch.as_bytes())?;
|
||||||
|
|
||||||
|
let output = child.wait_with_output()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
|
||||||
|
let url = gix::url::parse(url).context("invalid clone URL")?;
|
||||||
|
|
||||||
|
let mut prepare = gix::prepare_clone(url, path)?;
|
||||||
|
let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?;
|
||||||
|
let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?;
|
||||||
|
|
||||||
|
Ok(repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_path_component(id: &str) -> String {
|
||||||
|
id.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "signed_nostr"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
signed_core = { path = "../signed_core" }
|
||||||
|
|
||||||
|
nostr.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
nostr-connect.workspace = true
|
||||||
|
nostr-gossip-memory.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
|
webbrowser.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
nostr-memory.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
nostr-lmdb.workspace = true
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use nostr_gossip_memory::prelude::*;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use nostr_lmdb::prelude::*;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use nostr_memory::prelude::*;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
use crate::signer::UniversalSigner;
|
||||||
|
|
||||||
|
/// Owns the nostr client: relay pool, LMDB database and signer.
|
||||||
|
///
|
||||||
|
/// The SDK manages its own internal tokio runtime; every method here is a
|
||||||
|
/// plain async fn that can be driven by GPUI's executors.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct NostrBackend {
|
||||||
|
client: Client,
|
||||||
|
signer: UniversalSigner,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NostrBackend {
|
||||||
|
/// Open (or create) the LMDB database at `db_path` and build the client.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub async fn new(db_path: impl AsRef<std::path::Path>) -> Result<Self> {
|
||||||
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
|
let database = NostrLmdb::open(db_path)
|
||||||
|
.await
|
||||||
|
.context("failed to open nostr database")?;
|
||||||
|
Ok(Self::with_database(signer, database))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory database on wasm (no LMDB available).
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
|
Ok(Self::with_database(signer, MemoryDatabase::unbounded()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_database<D>(signer: UniversalSigner, database: D) -> Self
|
||||||
|
where
|
||||||
|
D: IntoNostrDatabase,
|
||||||
|
{
|
||||||
|
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||||
|
|
||||||
|
let client = ClientBuilder::default()
|
||||||
|
.database(database)
|
||||||
|
.authenticator(authenticator)
|
||||||
|
.gossip(NostrGossipMemory::unbounded())
|
||||||
|
.gossip_config(GossipConfig::default().no_background_refresh())
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.verify_subscriptions(true)
|
||||||
|
.ban_relay_on_mismatch(true)
|
||||||
|
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||||
|
timeout: Duration::from_secs(600),
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Self { client, signer }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client(&self) -> Client {
|
||||||
|
self.client.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn signer(&self) -> UniversalSigner {
|
||||||
|
self.signer.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_relay(&self, url: &str) -> Result<()> {
|
||||||
|
self.client.add_relay(url).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a relay used only for discovery (e.g. NIP-65 indexer relays).
|
||||||
|
/// No subscriptions or writes are routed through it.
|
||||||
|
pub async fn add_discovery_relay(&self, url: &str) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.add_relay(url)
|
||||||
|
.capabilities(RelayCapabilities::DISCOVERY)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(&self) {
|
||||||
|
self.client.connect().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a persistent subscription. Received events are stored in the
|
||||||
|
/// database automatically by the relay pool.
|
||||||
|
pub async fn subscribe(&self, filter: Filter) -> Result<SubscriptionId> {
|
||||||
|
let output = self.client.subscribe(filter).await?;
|
||||||
|
Ok(output.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query the local database (the single source of truth for the UI).
|
||||||
|
pub async fn query(&self, filter: Filter) -> Result<Vec<Event>> {
|
||||||
|
let events = self.client.database().query(filter).await?;
|
||||||
|
Ok(events.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign with the current signer, broadcast, and save locally so the
|
||||||
|
/// 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
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ");
|
||||||
|
return Err(anyhow!("event not accepted by any relay: {reasons}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
mod backend;
|
||||||
|
mod signer;
|
||||||
|
mod update;
|
||||||
|
|
||||||
|
pub use backend::NostrBackend;
|
||||||
|
pub use signer::{SignedAuthUrlHandler, UniversalSigner};
|
||||||
|
pub use update::Update;
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
use std::fmt;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
use nostr_connect::client::AuthUrlHandler;
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A type-erased signer whose inner signer can be swapped in-place
|
||||||
|
/// (e.g. after login/logout). All clones see the swap.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UniversalSigner {
|
||||||
|
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UniversalSigner {
|
||||||
|
pub fn new<T>(signer: T) -> Self
|
||||||
|
where
|
||||||
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
|
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(Arc::new(InnerSignerImpl(signer)))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swap the inner signer in-place. All clones see the new signer.
|
||||||
|
pub fn swap_inner<T>(&self, new_signer: T)
|
||||||
|
where
|
||||||
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
|
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
*self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_signer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||||
|
fn get_public_key_async(
|
||||||
|
&self,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
||||||
|
fn sign_event_async(
|
||||||
|
&self,
|
||||||
|
unsigned: UnsignedEvent,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
||||||
|
fn nip44_encrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
content: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||||
|
fn nip44_decrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
payload: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct InnerSignerImpl<T>(T);
|
||||||
|
|
||||||
|
impl<T> InnerSigner for InnerSignerImpl<T>
|
||||||
|
where
|
||||||
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
|
||||||
|
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
fn get_public_key_async(
|
||||||
|
&self,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||||
|
.await
|
||||||
|
.map_err(UniversalSignerError::new)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_event_async(
|
||||||
|
&self,
|
||||||
|
unsigned: UnsignedEvent,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||||
|
.await
|
||||||
|
.map_err(UniversalSignerError::new)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip44_encrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
content: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||||
|
.await
|
||||||
|
.map_err(UniversalSignerError::new)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip44_decrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
payload: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||||
|
.await
|
||||||
|
.map_err(UniversalSignerError::new)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncGetPublicKey for UniversalSigner {
|
||||||
|
type Error = UniversalSignerError;
|
||||||
|
|
||||||
|
fn get_public_key_async(
|
||||||
|
&self,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
|
||||||
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
|
Box::pin(async move { inner.get_public_key_async().await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncSignEvent for UniversalSigner {
|
||||||
|
type Error = UniversalSignerError;
|
||||||
|
|
||||||
|
fn sign_event_async(
|
||||||
|
&self,
|
||||||
|
unsigned: UnsignedEvent,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
|
||||||
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
|
Box::pin(async move { inner.sign_event_async(unsigned).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncNip44 for UniversalSigner {
|
||||||
|
type Error = UniversalSignerError;
|
||||||
|
|
||||||
|
fn nip44_encrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
content: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||||
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
|
Box::pin(async move { inner.nip44_encrypt_async(public_key, content).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip44_decrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
payload: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||||
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
|
Box::pin(async move { inner.nip44_decrypt_async(public_key, payload).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the NIP-46 auth URL in the default browser.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SignedAuthUrlHandler;
|
||||||
|
|
||||||
|
impl AuthUrlHandler for SignedAuthUrlHandler {
|
||||||
|
fn on_auth_url(
|
||||||
|
&self,
|
||||||
|
auth_url: Url,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
webbrowser::open(auth_url.as_str()).unwrap();
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
/// A lightweight "something changed" signal for the UI.
|
||||||
|
///
|
||||||
|
/// Heavy data stays in the database; consumers re-query on receipt.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
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 author: PublicKey,
|
||||||
|
pub event_id: EventId,
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
kind: event.kind,
|
||||||
|
coordinate,
|
||||||
|
author: event.pubkey,
|
||||||
|
event_id: event.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "signed_state"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
signed_core = { path = "../signed_core" }
|
||||||
|
signed_nostr = { path = "../signed_nostr" }
|
||||||
|
|
||||||
|
nostr.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
nostr-connect.workspace = true
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
|
flume.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
rustls = "0.23"
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Error, anyhow};
|
||||||
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
||||||
|
use nostr_connect::prelude::*;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use signed_core::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";
|
||||||
|
/// Timeout for NIP-46 signer responses.
|
||||||
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
|
/// Relays connected at startup, before any user-specific relay config is known.
|
||||||
|
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://relay.ditto.pub",
|
||||||
|
"wss://index.ngit.dev",
|
||||||
|
"wss://profiles.nostr1.com",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Relays used for indexing user's relay list (NIP-65).
|
||||||
|
pub const INDEXER_RELAYS: [&str; 3] = [
|
||||||
|
"wss://indexer.coracle.social",
|
||||||
|
"wss://purplepag.es",
|
||||||
|
"wss://user.kindpag.es",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum BackendEvent {
|
||||||
|
/// User has no signer configured.
|
||||||
|
SignerRequired,
|
||||||
|
/// The signer has changed (login/logout/account switch).
|
||||||
|
SignerChanged,
|
||||||
|
/// Relay bootstrap finished.
|
||||||
|
Connected,
|
||||||
|
/// A new event was received from a relay and stored in the database.
|
||||||
|
NostrUpdate(Update),
|
||||||
|
/// An event built locally was signed, broadcast and stored.
|
||||||
|
Published(Box<Event>),
|
||||||
|
/// An error occurred.
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendEvent {
|
||||||
|
pub fn error<T>(error: T) -> Self
|
||||||
|
where
|
||||||
|
T: Into<String>,
|
||||||
|
{
|
||||||
|
Self::Error(error.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global backend entity: owns the nostr client, the signer and the
|
||||||
|
/// notification pump. Stores subscribe to [`BackendEvent`] and re-query the
|
||||||
|
/// local database when relevant updates arrive.
|
||||||
|
pub struct Backend {
|
||||||
|
inner: NostrBackend,
|
||||||
|
current_user: Option<PublicKey>,
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalBackend(Entity<Backend>);
|
||||||
|
|
||||||
|
impl Global for GlobalBackend {}
|
||||||
|
|
||||||
|
impl EventEmitter<BackendEvent> for Backend {}
|
||||||
|
|
||||||
|
impl Backend {
|
||||||
|
/// Retrieve the global backend.
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalBackend>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalBackend(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new(inner: NostrBackend, cx: &mut Context<Self>) -> Self {
|
||||||
|
let client = inner.client();
|
||||||
|
|
||||||
|
let pump = cx.spawn(async move |this, cx| {
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
|
||||||
|
while let Some(notification) = notifications.next().await {
|
||||||
|
let ClientNotification::Event { event, .. } = notification else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let update = Update::from_event(&event);
|
||||||
|
|
||||||
|
if this
|
||||||
|
.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(update)))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut this = Self {
|
||||||
|
inner,
|
||||||
|
current_user: None,
|
||||||
|
tasks: vec![pump],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.bootstrap(cx);
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bootstrap the client: connect to the default relays (indexers as
|
||||||
|
/// discovery-only) and restore the saved session, if any.
|
||||||
|
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let backend = self.inner.clone();
|
||||||
|
|
||||||
|
let task = cx.background_spawn(async move {
|
||||||
|
for url in BOOTSTRAP_RELAYS {
|
||||||
|
backend.add_relay(url).await?;
|
||||||
|
}
|
||||||
|
for url in INDEXER_RELAYS {
|
||||||
|
backend.add_discovery_relay(url).await?;
|
||||||
|
}
|
||||||
|
backend.connect().await;
|
||||||
|
Ok::<(), Error>(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(()) => {
|
||||||
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
|
||||||
|
self.restore_session(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the saved session from the keyring. Emits
|
||||||
|
/// [`BackendEvent::SignerRequired`] if no credential is stored.
|
||||||
|
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if cfg!(target_arch = "wasm32") {
|
||||||
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Ok(Some((_username, secret))) => String::from_utf8(secret)?,
|
||||||
|
_ => {
|
||||||
|
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = async {
|
||||||
|
if content.starts_with("nsec1") {
|
||||||
|
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 mut signer = NostrConnect::new(
|
||||||
|
uri,
|
||||||
|
master.await,
|
||||||
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||||
|
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
||||||
|
} else {
|
||||||
|
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<_, Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
this.update(cx, |_, cx| {
|
||||||
|
cx.emit(BackendEvent::error(e.to_string()));
|
||||||
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>) {
|
||||||
|
let nsec = nsec.trim().to_owned();
|
||||||
|
|
||||||
|
let keys = match SecretKey::parse(&nsec) {
|
||||||
|
Ok(secret) => Keys::new(secret),
|
||||||
|
Err(e) => {
|
||||||
|
cx.emit(BackendEvent::error(e.to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let write =
|
||||||
|
cx.write_credentials(USER_KEYRING, &keys.public_key().to_hex(), nsec.as_bytes());
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
if let Err(e) = write.await {
|
||||||
|
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
|
||||||
|
let uri_string = uri.trim().to_owned();
|
||||||
|
|
||||||
|
let connect_uri = match NostrConnectUri::parse(&uri_string) {
|
||||||
|
Ok(uri) => uri,
|
||||||
|
Err(e) => {
|
||||||
|
cx.emit(BackendEvent::error(e.to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let master = self.master_key(cx);
|
||||||
|
let write = cx.write_credentials(USER_KEYRING, "bunker", uri_string.as_bytes());
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let result = async {
|
||||||
|
let mut signer = NostrConnect::new(
|
||||||
|
connect_uri,
|
||||||
|
master.await,
|
||||||
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||||
|
|
||||||
|
// Verify the signer before persisting the credential.
|
||||||
|
signer.get_public_key_async().await?;
|
||||||
|
write.await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the saved credential and reset to an anonymous session.
|
||||||
|
pub fn logout(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let delete = cx.delete_credentials(USER_KEYRING);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
delete.await.ok();
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.inner.signer().swap_inner(Keys::generate());
|
||||||
|
this.current_user = None;
|
||||||
|
cx.emit(BackendEvent::SignerChanged);
|
||||||
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>) {
|
||||||
|
let backend = self.inner.clone();
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let result = async {
|
||||||
|
let events = backend
|
||||||
|
.client()
|
||||||
|
.fetch_events(filters::grasp_list(public_key))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let urls: Vec<String> = events
|
||||||
|
.into_iter()
|
||||||
|
.max_by_key(|e| e.created_at)
|
||||||
|
.map(|e| {
|
||||||
|
e.tags
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.kind() == "g")
|
||||||
|
.filter_map(|t| t.content().map(str::to_owned))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for url in urls {
|
||||||
|
backend.add_relay(&url).await.ok();
|
||||||
|
}
|
||||||
|
backend.connect().await;
|
||||||
|
|
||||||
|
Ok::<_, Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the nostr client.
|
||||||
|
pub fn client(&self) -> Client {
|
||||||
|
self.inner.client()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current signer.
|
||||||
|
pub fn signer(&self) -> UniversalSigner {
|
||||||
|
self.inner.signer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current user's public key.
|
||||||
|
pub fn current_user(&self) -> Option<PublicKey> {
|
||||||
|
self.current_user
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>)
|
||||||
|
where
|
||||||
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
|
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let task = cx.spawn(async move |this, cx| {
|
||||||
|
match new_signer.get_public_key_async().await {
|
||||||
|
Ok(public_key) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.inner.signer().swap_inner(new_signer);
|
||||||
|
this.current_user = Some(public_key);
|
||||||
|
this.bootstrap_user(public_key, cx);
|
||||||
|
cx.emit(BackendEvent::SignerChanged);
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
cx.emit(BackendEvent::error(e.to_string()));
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add relays and connect to them.
|
||||||
|
pub fn add_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
|
||||||
|
let backend = self.inner.clone();
|
||||||
|
|
||||||
|
let task = cx.background_spawn(async move {
|
||||||
|
for url in urls {
|
||||||
|
backend.add_relay(&url).await?;
|
||||||
|
}
|
||||||
|
backend.connect().await;
|
||||||
|
Ok::<(), Error>(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(()) => {
|
||||||
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add relays used only for discovery (e.g. NIP-65 indexers) and
|
||||||
|
/// connect to them. No subscriptions or writes are routed through them.
|
||||||
|
pub fn add_discovery_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
|
||||||
|
let backend = self.inner.clone();
|
||||||
|
|
||||||
|
let task = cx.background_spawn(async move {
|
||||||
|
for url in urls {
|
||||||
|
backend.add_discovery_relay(&url).await?;
|
||||||
|
}
|
||||||
|
backend.connect().await;
|
||||||
|
Ok::<(), Error>(())
|
||||||
|
});
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a persistent subscription. Matching events are stored in the
|
||||||
|
/// database automatically and surface as [`BackendEvent::NostrUpdate`].
|
||||||
|
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||||
|
let backend = self.inner.clone();
|
||||||
|
|
||||||
|
let task = cx.background_spawn(async move { backend.subscribe(filter).await.map(|_| ()) });
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign, broadcast and locally store an event. Emits
|
||||||
|
/// [`BackendEvent::Published`] on success so stores can refresh.
|
||||||
|
///
|
||||||
|
/// The returned receiver yields the outcome of this specific action,
|
||||||
|
/// so callers can show inline progress/errors instead of relying on
|
||||||
|
/// the global [`BackendEvent::Error`].
|
||||||
|
pub fn send(
|
||||||
|
&mut self,
|
||||||
|
builder: EventBuilder,
|
||||||
|
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 });
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let result = task.await;
|
||||||
|
|
||||||
|
match &result {
|
||||||
|
Ok(event) => {
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
cx.emit(BackendEvent::Published(Box::new(event.clone())));
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.send_async(result)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("action result receiver dropped"))
|
||||||
|
}));
|
||||||
|
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
mod backend;
|
||||||
|
mod profile;
|
||||||
|
mod repo;
|
||||||
|
mod repo_list;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub use backend::{Backend, BackendEvent};
|
||||||
|
use gpui::{App, AppContext, Entity};
|
||||||
|
pub use profile::{Profile, ProfileStore, shorten_pubkey};
|
||||||
|
pub use repo::RepoStore;
|
||||||
|
pub use repo_list::RepoListStore;
|
||||||
|
use signed_nostr::NostrBackend;
|
||||||
|
|
||||||
|
/// Initialize the backend and stores, and install them as globals. Call once
|
||||||
|
/// at startup, before opening any window that uses the stores.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
|
||||||
|
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
|
||||||
|
rustls::crypto::aws_lc_rs::default_provider()
|
||||||
|
.install_default()
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let path = db_path.as_ref().to_path_buf();
|
||||||
|
let inner = cx.foreground_executor().block_on(async move {
|
||||||
|
NostrBackend::new(path)
|
||||||
|
.await
|
||||||
|
.expect("failed to initialize nostr backend")
|
||||||
|
});
|
||||||
|
|
||||||
|
let entity = cx.new(|cx| Backend::new(inner, cx));
|
||||||
|
Backend::set_global(entity.clone(), cx);
|
||||||
|
|
||||||
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
|
|
||||||
|
entity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the backend with an in-memory database on wasm.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn init(cx: &mut App) -> Entity<Backend> {
|
||||||
|
let inner = NostrBackend::new().expect("failed to initialize nostr backend");
|
||||||
|
|
||||||
|
let entity = cx.new(|cx| Backend::new(inner, cx));
|
||||||
|
Backend::set_global(entity.clone(), cx);
|
||||||
|
|
||||||
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
|
|
||||||
|
entity
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use gpui::{App, Context, Entity, Global, SharedString, Subscription, Task};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
use crate::backend::{Backend, BackendEvent};
|
||||||
|
|
||||||
|
/// A user profile (kind `0` metadata), as plain data for the UI.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Profile {
|
||||||
|
public_key: PublicKey,
|
||||||
|
metadata: Metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Profile {
|
||||||
|
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||||
|
Self {
|
||||||
|
public_key,
|
||||||
|
metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key(&self) -> PublicKey {
|
||||||
|
self.public_key
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn metadata(&self) -> &Metadata {
|
||||||
|
&self.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display name, falling back to `name`, then a shortened npub.
|
||||||
|
pub fn name(&self) -> SharedString {
|
||||||
|
if let Some(display_name) = self.metadata.display_name.as_ref()
|
||||||
|
&& !display_name.is_empty()
|
||||||
|
{
|
||||||
|
return SharedString::from(display_name.trim().to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = self.metadata.name.as_ref()
|
||||||
|
&& !name.is_empty()
|
||||||
|
{
|
||||||
|
return SharedString::from(name.trim().to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
SharedString::from(shorten_pubkey(self.public_key, 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avatar URL, if set.
|
||||||
|
pub fn picture(&self) -> Option<SharedString> {
|
||||||
|
self.metadata
|
||||||
|
.picture
|
||||||
|
.as_ref()
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.map(|p| SharedString::from(p.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
|
||||||
|
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
|
||||||
|
let npub = public_key.to_bech32().unwrap();
|
||||||
|
format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global profile cache. Profiles are fetched in batches and kept as plain
|
||||||
|
/// data; the whole store notifies on change.
|
||||||
|
pub struct ProfileStore {
|
||||||
|
profiles: HashMap<PublicKey, Profile>,
|
||||||
|
/// Public keys we've already requested this session.
|
||||||
|
seen: HashSet<PublicKey>,
|
||||||
|
/// Public keys queued for the next batched fetch.
|
||||||
|
queued: HashSet<PublicKey>,
|
||||||
|
fetching: bool,
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
_subscription: Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalProfileStore(Entity<ProfileStore>);
|
||||||
|
|
||||||
|
impl Global for GlobalProfileStore {}
|
||||||
|
|
||||||
|
impl ProfileStore {
|
||||||
|
/// Retrieve the global profile store.
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalProfileStore>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalProfileStore(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||||
|
BackendEvent::NostrUpdate(update) if update.kind == Kind::Metadata => {
|
||||||
|
this.apply_author(update.author, cx);
|
||||||
|
}
|
||||||
|
BackendEvent::Published(event) if event.kind == Kind::Metadata => {
|
||||||
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
|
this.profiles
|
||||||
|
.insert(event.pubkey, Profile::new(event.pubkey, metadata));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut store = Self {
|
||||||
|
profiles: HashMap::new(),
|
||||||
|
seen: HashSet::new(),
|
||||||
|
queued: HashSet::new(),
|
||||||
|
fetching: false,
|
||||||
|
tasks: Vec::new(),
|
||||||
|
_subscription: subscription,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.load(cx);
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a profile. Returns a placeholder (default metadata) and queues a
|
||||||
|
/// fetch if the profile isn't cached yet.
|
||||||
|
pub fn get(&mut self, public_key: PublicKey, cx: &mut Context<Self>) -> Profile {
|
||||||
|
if let Some(profile) = self.profiles.get(&public_key) {
|
||||||
|
return profile.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.seen.insert(public_key) {
|
||||||
|
self.queued.insert(public_key);
|
||||||
|
self.queue_fetch(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
Profile::new(public_key, Metadata::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load recently seen profiles from the local database.
|
||||||
|
fn load(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let client = Backend::global(cx).read(cx).client();
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |this, cx| {
|
||||||
|
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||||
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
for event in events {
|
||||||
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
|
this.profiles
|
||||||
|
.insert(event.pubkey, Profile::new(event.pubkey, metadata));
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-read the latest metadata of an author from the local database.
|
||||||
|
fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||||
|
let client = Backend::global(cx).read(cx).client();
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |this, cx| {
|
||||||
|
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
|
||||||
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) {
|
||||||
|
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
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 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.fetching = true;
|
||||||
|
|
||||||
|
let client = Backend::global(cx).read(cx).client();
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |this, cx| {
|
||||||
|
loop {
|
||||||
|
// Collect more requests before firing the batch.
|
||||||
|
cx.background_executor()
|
||||||
|
.timer(Duration::from_millis(500))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let batch = this.update(cx, |this, _cx| std::mem::take(&mut this.queued))?;
|
||||||
|
|
||||||
|
if batch.is_empty() {
|
||||||
|
this.update(cx, |this, _cx| {
|
||||||
|
this.fetching = false;
|
||||||
|
})?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
use anyhow::Error;
|
||||||
|
use gpui::{Context, Subscription, Task};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use signed_core::{Announcement, RepoAddr, RepoStatus, filters};
|
||||||
|
|
||||||
|
use crate::backend::{Backend, BackendEvent};
|
||||||
|
|
||||||
|
/// Per-repository store: announcement, state, issues, patches, PRs and
|
||||||
|
/// 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)>,
|
||||||
|
/// Branch pointed to by `HEAD` in the latest state announcement.
|
||||||
|
pub head: Option<String>,
|
||||||
|
pub issues: Vec<Event>,
|
||||||
|
pub patches: Vec<Event>,
|
||||||
|
pub pull_requests: Vec<Event>,
|
||||||
|
statuses: Vec<Event>,
|
||||||
|
/// Error of the last action initiated from this store, if any.
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
refreshing: bool,
|
||||||
|
refresh_dirty: bool,
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
_subscription: Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoStore {
|
||||||
|
pub fn new(addr: RepoAddr, cx: &mut Context<Self>) -> Self {
|
||||||
|
let addr_string = addr.to_string();
|
||||||
|
|
||||||
|
let subscription = cx.subscribe(&Backend::global(cx), |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)
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if relevant {
|
||||||
|
this.refresh(cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut store = Self {
|
||||||
|
addr,
|
||||||
|
addr_string,
|
||||||
|
announcement: None,
|
||||||
|
refs: Vec::new(),
|
||||||
|
head: None,
|
||||||
|
issues: Vec::new(),
|
||||||
|
patches: Vec::new(),
|
||||||
|
pull_requests: Vec::new(),
|
||||||
|
statuses: Vec::new(),
|
||||||
|
last_error: None,
|
||||||
|
refreshing: false,
|
||||||
|
refresh_dirty: false,
|
||||||
|
_subscription: subscription,
|
||||||
|
tasks: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
store.subscribe_remote(cx);
|
||||||
|
store.refresh(cx);
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn addr(&self) -> &RepoAddr {
|
||||||
|
&self.addr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe the relay pool to this repository's activity.
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-query the local database and update all fields.
|
||||||
|
///
|
||||||
|
/// Debounced: concurrent requests are coalesced into a single re-query
|
||||||
|
/// after the running one finishes.
|
||||||
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.refreshing {
|
||||||
|
self.refresh_dirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.refreshing = true;
|
||||||
|
|
||||||
|
let client = Backend::global(cx).read(cx).client();
|
||||||
|
let addr = self.addr.clone();
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |this, cx| {
|
||||||
|
loop {
|
||||||
|
let queries = async {
|
||||||
|
let db = client.database();
|
||||||
|
|
||||||
|
let announcements = db.query(filters::announcement(&addr)).await?;
|
||||||
|
let states = db.query(filters::state(&addr)).await?;
|
||||||
|
let activity = db.query(filters::activity(&addr)).await?;
|
||||||
|
|
||||||
|
Ok::<_, Error>((announcements, states, activity))
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (announcements, states, activity) = match queries {
|
||||||
|
Ok(results) => results,
|
||||||
|
Err(e) => {
|
||||||
|
return this.update(cx, |this, cx| {
|
||||||
|
this.refreshing = false;
|
||||||
|
this.last_error = Some(e.to_string());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let again = this.update(cx, |this, cx| {
|
||||||
|
this.announcement = latest(announcements)
|
||||||
|
.as_ref()
|
||||||
|
.and_then(Announcement::from_event);
|
||||||
|
|
||||||
|
if let Some(state) = latest(states) {
|
||||||
|
let (refs, head) = parse_state(&state);
|
||||||
|
this.refs = refs;
|
||||||
|
this.head = head;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.issues.clear();
|
||||||
|
this.patches.clear();
|
||||||
|
this.pull_requests.clear();
|
||||||
|
this.statuses.clear();
|
||||||
|
|
||||||
|
for event in activity {
|
||||||
|
match event.kind {
|
||||||
|
Kind::GitIssue => this.issues.push(event),
|
||||||
|
Kind::GitPatch => this.patches.push(event),
|
||||||
|
Kind::GitPullRequest | Kind::GitPullRequestUpdate => {
|
||||||
|
this.pull_requests.push(event)
|
||||||
|
}
|
||||||
|
kind if RepoStatus::from_kind(kind).is_some() => {
|
||||||
|
this.statuses.push(event)
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort_newest_first(&mut this.issues);
|
||||||
|
sort_newest_first(&mut this.patches);
|
||||||
|
sort_newest_first(&mut this.pull_requests);
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
if this.refresh_dirty {
|
||||||
|
this.refresh_dirty = false;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
this.refreshing = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !again {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the status of a root event (issue / patch / PR) per NIP-34.
|
||||||
|
pub fn status_of(&self, root: &Event) -> RepoStatus {
|
||||||
|
let maintainers = self
|
||||||
|
.announcement
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.maintainers.as_slice())
|
||||||
|
.unwrap_or(&[]);
|
||||||
|
|
||||||
|
let events = self
|
||||||
|
.statuses
|
||||||
|
.iter()
|
||||||
|
.filter(|e| signed_core::references_root(e, &root.id));
|
||||||
|
|
||||||
|
signed_core::resolve_status(events, &root.pubkey, maintainers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an issue on this repository.
|
||||||
|
pub fn open_issue(&mut self, subject: Option<String>, content: String, cx: &mut Context<Self>) {
|
||||||
|
let builder = GitIssue {
|
||||||
|
repository: self.addr.coordinate(),
|
||||||
|
content,
|
||||||
|
subject,
|
||||||
|
labels: Vec::new(),
|
||||||
|
}
|
||||||
|
.into_event_builder();
|
||||||
|
|
||||||
|
self.send(builder, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a root patch (`git format-patch` output) to this repository.
|
||||||
|
pub fn send_root_patch(&mut self, patch: String, cx: &mut Context<Self>) {
|
||||||
|
let Ok(root_marker) = Tag::parse(["t", "root"]) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let builder = EventBuilder::new(Kind::GitPatch, patch).tags([
|
||||||
|
Tag::coordinate(self.addr.coordinate(), None),
|
||||||
|
Tag::public_key(self.addr.owner),
|
||||||
|
root_marker,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self.send(builder, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the status of a root event (requires being the root author or a maintainer).
|
||||||
|
pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
|
||||||
|
let Ok(root_ref) = Tag::parse(["e", &root.id.to_hex(), "", "root"]) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let builder = EventBuilder::new(status.kind(), "").tags([
|
||||||
|
root_ref,
|
||||||
|
Tag::public_key(self.addr.owner),
|
||||||
|
Tag::public_key(root.pubkey),
|
||||||
|
Tag::coordinate(self.addr.coordinate(), None),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self.send(builder, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
|
||||||
|
self.last_error = None;
|
||||||
|
|
||||||
|
let rx = Backend::global(cx).update(cx, |backend, cx| backend.send(builder, cx));
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |this, cx| {
|
||||||
|
if let Ok(Err(e)) = rx.recv_async().await {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.last_error = Some(e.to_string());
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn latest(events: Events) -> Option<Event> {
|
||||||
|
events.into_iter().max_by_key(|e| e.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort_newest_first(events: &mut [Event]) {
|
||||||
|
events.sort_by_key(|e| std::cmp::Reverse(e.created_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a kind `30618` state event into refs and HEAD.
|
||||||
|
fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
let mut head = None;
|
||||||
|
|
||||||
|
for tag in event.tags.iter() {
|
||||||
|
let kind = tag.kind();
|
||||||
|
if kind == "HEAD" {
|
||||||
|
head = tag
|
||||||
|
.content()
|
||||||
|
.and_then(|v| v.strip_prefix("ref: refs/heads/"))
|
||||||
|
.map(str::to_owned);
|
||||||
|
} else if kind.starts_with("refs/")
|
||||||
|
&& let Some(commit) = tag.content()
|
||||||
|
{
|
||||||
|
refs.push((kind.to_owned(), commit.to_owned()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(refs, head)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use gpui::{Context, Subscription, Task};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use signed_core::{Announcement, filters};
|
||||||
|
|
||||||
|
use crate::backend::{Backend, BackendEvent};
|
||||||
|
|
||||||
|
/// Store listing repository announcements (global discovery or per-author).
|
||||||
|
pub struct RepoListStore {
|
||||||
|
pub announcements: Vec<Announcement>,
|
||||||
|
author: Option<PublicKey>,
|
||||||
|
refreshing: bool,
|
||||||
|
refresh_dirty: bool,
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
_subscription: Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 relevant = match event {
|
||||||
|
BackendEvent::NostrUpdate(update) => {
|
||||||
|
update.kind == Kind::GitRepoAnnouncement
|
||||||
|
&& 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)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if relevant {
|
||||||
|
this.refresh(cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut store = Self {
|
||||||
|
announcements: Vec::new(),
|
||||||
|
author,
|
||||||
|
refreshing: false,
|
||||||
|
refresh_dirty: false,
|
||||||
|
_subscription: subscription,
|
||||||
|
tasks: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
store.subscribe_remote(cx);
|
||||||
|
store.refresh(cx);
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scope the list to an author (or clear the scope with `None`).
|
||||||
|
pub fn set_author(&mut self, author: Option<PublicKey>, cx: &mut Context<Self>) {
|
||||||
|
self.author = author;
|
||||||
|
self.subscribe_remote(cx);
|
||||||
|
self.refresh(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let author = self.author;
|
||||||
|
|
||||||
|
Backend::global(cx).update(cx, |backend, cx| {
|
||||||
|
let filter = match author {
|
||||||
|
Some(a) => filters::announcements_by(a),
|
||||||
|
None => filters::all_announcements(500),
|
||||||
|
};
|
||||||
|
backend.subscribe(filter, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-query the local database. Latest announcement per repository wins.
|
||||||
|
///
|
||||||
|
/// Debounced: concurrent requests are coalesced into a single re-query
|
||||||
|
/// after the running one finishes.
|
||||||
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.refreshing {
|
||||||
|
self.refresh_dirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.refreshing = true;
|
||||||
|
|
||||||
|
let client = Backend::global(cx).read(cx).client();
|
||||||
|
let author = self.author;
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |this, cx| {
|
||||||
|
loop {
|
||||||
|
let filter = match author {
|
||||||
|
Some(a) => filters::announcements_by(a),
|
||||||
|
None => filters::all_announcements(500),
|
||||||
|
};
|
||||||
|
|
||||||
|
let events = match client.database().query(filter).await {
|
||||||
|
Ok(events) => events,
|
||||||
|
Err(_) => {
|
||||||
|
return this.update(cx, |this, _cx| {
|
||||||
|
this.refreshing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let again = this.update(cx, |this, cx| {
|
||||||
|
let mut by_repo: HashMap<(String, String), Announcement> = HashMap::new();
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
let Some(announcement) = Announcement::from_event(&event) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = (announcement.owner.to_hex(), announcement.id.clone());
|
||||||
|
|
||||||
|
match by_repo.get(&key) {
|
||||||
|
Some(existing) if existing.created_at >= announcement.created_at => {}
|
||||||
|
_ => {
|
||||||
|
by_repo.insert(key, announcement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
||||||
|
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
|
||||||
|
|
||||||
|
this.announcements = announcements;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
if this.refresh_dirty {
|
||||||
|
this.refresh_dirty = false;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
this.refreshing = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !again {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user