update
This commit is contained in:
+233
-245
@@ -18,8 +18,9 @@ use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
||||
|
||||
use crate::git_store::GitStore;
|
||||
|
||||
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`
|
||||
/// with an embedded `?master=<nsec>` NIP-46 session key).
|
||||
/// Keyring entry for the user credential.
|
||||
/// It is an `nsec1...` key or a `bunker://...` URI.
|
||||
/// The URI embeds a `?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;
|
||||
@@ -32,34 +33,35 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
"wss://profiles.nostr1.com",
|
||||
];
|
||||
|
||||
/// Relays used for indexing user's relay list (NIP-65).
|
||||
/// Relays used to index the user's NIP-65 relay list.
|
||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||
|
||||
/// How long an identical fetch/sync request is suppressed after it started.
|
||||
/// A second panel for the same repository (or the global and per-author
|
||||
/// list stores at login) doesn't duplicate a sync that just ran; after the
|
||||
/// window, re-fetching is allowed again so data stays fresh.
|
||||
/// How long an identical fetch or sync request is suppressed after it started.
|
||||
/// A second panel for the same repository does not duplicate a live sync.
|
||||
/// The global and per-author list stores at login share this dedup.
|
||||
/// After the window, re-fetching is allowed again so data stays fresh.
|
||||
const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BackendEvent {
|
||||
/// User has no signer configured.
|
||||
SignerRequired,
|
||||
/// The stored identity is NIP-49 encrypted (`ncryptsec1...`); a
|
||||
/// passphrase is required to decrypt it before the session can resume.
|
||||
/// The stored identity is NIP-49 encrypted, an `ncryptsec1...` key.
|
||||
/// A passphrase is required to decrypt it before the session can resume.
|
||||
PassphraseRequired,
|
||||
/// The signer has changed (login/logout/account switch).
|
||||
/// The signer changed on login, logout or account switch.
|
||||
SignerChanged,
|
||||
/// Relay bootstrap finished.
|
||||
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).
|
||||
/// 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.
|
||||
/// A negentropy sync is in flight.
|
||||
/// Stores may re-query to render incrementally.
|
||||
/// UI can show `current` and `total` progress.
|
||||
SyncProgress {
|
||||
/// Total events to process.
|
||||
total: u64,
|
||||
@@ -81,28 +83,29 @@ impl BackendEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The global backend entity.
|
||||
/// Owns the nostr client, the signer and the notification pump.
|
||||
/// Stores subscribe to [`BackendEvent`].
|
||||
/// They re-query the local database when relevant updates arrive.
|
||||
pub struct Backend {
|
||||
client: Client,
|
||||
signer: UniversalSigner,
|
||||
current_user: Option<PublicKey>,
|
||||
connected: bool,
|
||||
sync_progress: Option<(u64, u64)>,
|
||||
/// Whether the stored credential is NIP-49 encrypted and a passphrase
|
||||
/// is still needed to resume the session.
|
||||
/// True when the stored credential is NIP-49 encrypted.
|
||||
/// A passphrase is still needed to resume the session.
|
||||
passphrase_required: bool,
|
||||
/// Fingerprints of recently started fetches/syncs (relay + filter set),
|
||||
/// so duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into
|
||||
/// one. Entries are pruned lazily on the next request.
|
||||
/// Fingerprints of recently started fetches and syncs, a relay plus filter set.
|
||||
/// Duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into one.
|
||||
/// Entries are pruned lazily on the next request.
|
||||
recent_fetches: HashMap<u64, Instant>,
|
||||
/// Repositories a push (mirror or checkout) is currently in flight
|
||||
/// for. Concurrent pushes of the same refs — two panels of the same
|
||||
/// repository, or the banner push racing the header's Republish — make
|
||||
/// the losing push fail server-side with a compare-and-swap rejection
|
||||
/// ("cannot lock ref … is at … but expected …"), so pushes are
|
||||
/// single-flight per repository.
|
||||
/// Repositories with a push in flight, mirror or checkout based.
|
||||
/// Concurrent pushes of the same refs make the losing push fail server-side.
|
||||
/// The rejection is a compare-and-swap error from the server.
|
||||
/// Two panels of the same repository can race.
|
||||
/// The banner push can also race the header's Republish.
|
||||
/// Pushes are single-flight per repository.
|
||||
pushing_repos: Arc<Mutex<HashSet<RepoAddr>>>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
@@ -111,9 +114,8 @@ struct GlobalBackend(Entity<Backend>);
|
||||
|
||||
impl Global for GlobalBackend {}
|
||||
|
||||
/// Removes its repository from the in-flight push set when dropped, so a
|
||||
/// push task that is cancelled (e.g. its panel closed mid-push) can never
|
||||
/// leave the repository locked for the rest of the session.
|
||||
/// Removes its repository from the in-flight push set when dropped.
|
||||
/// A push task cancelled by its panel closing cannot leave the repository locked.
|
||||
struct PushGuard {
|
||||
repos: Arc<Mutex<HashSet<RepoAddr>>>,
|
||||
addr: RepoAddr,
|
||||
@@ -179,8 +181,9 @@ impl Backend {
|
||||
this
|
||||
}
|
||||
|
||||
/// Bootstrap the client: connect to the default relays (indexers as
|
||||
/// discovery-only) and restore the saved session, if any.
|
||||
/// Bootstrap the client.
|
||||
/// Connect to the default relays, with the indexers as discovery-only.
|
||||
/// Restore the saved session, if any.
|
||||
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -217,10 +220,9 @@ impl Backend {
|
||||
self.restore_session(cx);
|
||||
}
|
||||
|
||||
/// Restore the saved session from the keyring. Emits
|
||||
/// [`BackendEvent::SignerRequired`] if no credential is stored, or
|
||||
/// [`BackendEvent::PassphraseRequired`] if the stored identity is
|
||||
/// NIP-49 encrypted.
|
||||
/// Restore the saved session from the keyring.
|
||||
/// Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
||||
/// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
|
||||
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
cx.emit(BackendEvent::SignerRequired);
|
||||
@@ -254,8 +256,8 @@ impl Backend {
|
||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
||||
} else if content.starts_with("ncryptsec1") {
|
||||
// Encrypted identity: a passphrase is required to
|
||||
// decrypt it before the session can resume.
|
||||
// Encrypted identity.
|
||||
// A passphrase is required to decrypt it before the session can resume.
|
||||
log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase");
|
||||
this.update(cx, |this, cx| {
|
||||
this.passphrase_required = true;
|
||||
@@ -280,11 +282,10 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Decrypt the NIP-49 encrypted credential stored in the keyring with
|
||||
/// the given passphrase and resume the session.
|
||||
///
|
||||
/// The scrypt decryption runs off the UI thread. The task yields the
|
||||
/// public key, or the failure reason (e.g. wrong passphrase).
|
||||
/// Decrypt the NIP-49 keyring credential with the given passphrase.
|
||||
/// Resume the session on success.
|
||||
/// The scrypt decryption runs off the UI thread.
|
||||
/// The task yields the public key or the failure reason, e.g. a wrong passphrase.
|
||||
pub fn restore_with_passphrase(
|
||||
&mut self,
|
||||
password: &str,
|
||||
@@ -319,12 +320,12 @@ 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 encryption runs off the UI thread; the task yields the new
|
||||
/// public key.
|
||||
/// Create a new identity.
|
||||
/// Generate keys and encrypt the secret key with the passphrase, NIP-49.
|
||||
/// Persist it in the keyring.
|
||||
/// Then publish the NIP-65 relay list, metadata and grasp list.
|
||||
/// The encryption runs off the UI thread.
|
||||
/// The task yields the new public key.
|
||||
pub fn create_identity(
|
||||
&mut self,
|
||||
name: &str,
|
||||
@@ -360,8 +361,7 @@ impl Backend {
|
||||
write.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
// Become the new identity, so the publishes below are
|
||||
// signed with the new keys.
|
||||
// Become the new identity so later publishes are signed with the new keys.
|
||||
this.signer.swap_inner(keys);
|
||||
this.current_user = Some(public_key);
|
||||
this.bootstrap_user(public_key, cx);
|
||||
@@ -412,21 +412,20 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new repository: initialize a local clone with a `main`
|
||||
/// branch and a `README.md`, publish the NIP-34 announcement and the
|
||||
/// repository state to the grasp relays, then push the initial commit
|
||||
/// to each grasp server. A working copy of the repository is also
|
||||
/// created at `<folder>/<name>` (named like the repo header's Clone
|
||||
/// action), with `origin` pointing at the first grasp server, so the
|
||||
/// new project exists in the chosen folder right away.
|
||||
///
|
||||
/// The events must reach the grasp servers *before* the push: GRASP
|
||||
/// servers hold the signed state event in "purgatory" and only accept
|
||||
/// a push for a not-yet-existing repository while that authorization is
|
||||
/// pending (it expires after 30 minutes), like gitworkshop and ngit.
|
||||
///
|
||||
/// The git work runs on background threads; the task yields the
|
||||
/// published announcement and the path of the created working copy.
|
||||
/// Create a repository.
|
||||
/// Initialize a local clone with a `main` branch and a `README.md`.
|
||||
/// Publish the NIP-34 announcement and the repository state to the grasp relays.
|
||||
/// Push the initial commit to each grasp server.
|
||||
/// Also create a working copy at `<folder>/<name>`, like the header's Clone action.
|
||||
/// Its `origin` points at the first grasp server.
|
||||
/// The new project exists in the chosen folder right away.
|
||||
/// The events must reach the grasp relays before the push.
|
||||
/// GRASP servers hold the signed state event in purgatory.
|
||||
/// They accept the push only while the authorization is pending.
|
||||
/// The pushed repository must not exist yet.
|
||||
/// The authorization expires after 30 minutes, like gitworkshop and ngit.
|
||||
/// The git work runs on background threads.
|
||||
/// The task yields the announcement and the path of the working copy.
|
||||
pub fn create_repository(
|
||||
&mut self,
|
||||
name: &str,
|
||||
@@ -454,9 +453,10 @@ impl Backend {
|
||||
return Task::ready(Err(anyhow!("Sign in to create a repository")));
|
||||
};
|
||||
|
||||
// The repository identifier is derived from the name, like ngit and
|
||||
// gitworkshop: spaces become hyphens, other non-alphanumeric
|
||||
// characters (except `/`) become hyphens, case is preserved.
|
||||
// The repository identifier is derived from the name, like ngit and gitworkshop.
|
||||
// Spaces become hyphens.
|
||||
// Other non-alphanumeric characters become hyphens, except `/`.
|
||||
// Case is preserved.
|
||||
let repo_id = identifier_from_name(&name);
|
||||
if repo_id.is_empty() || repo_id.len() > 100 {
|
||||
return Task::ready(Err(anyhow!(
|
||||
@@ -495,18 +495,16 @@ impl Backend {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let commit = signed_git::init_repository(&path, &name, &description)?;
|
||||
|
||||
// Point `origin` at the first grasp server so later
|
||||
// fetches (and pushes) have a target, like ngit.
|
||||
// Point `origin` at the first grasp server.
|
||||
// Later fetches and pushes have a target, like ngit.
|
||||
if let Some(base) = servers.first().and_then(grasp_base_url) {
|
||||
let url = format!("{base}/{owner}/{repo_id}.git");
|
||||
signed_git::ensure_origin(&path, &url).ok();
|
||||
}
|
||||
|
||||
// A working copy at `<folder>/<name>` (the same naming
|
||||
// as the header's Clone action), cloned from the mirror
|
||||
// above so it shares the announced history exactly;
|
||||
// `origin` is re-pointed at the first grasp server
|
||||
// instead of the mirror path.
|
||||
// A working copy at `<folder>/<name>`, like the header's Clone action.
|
||||
// Cloned from the mirror above so it shares the announced history.
|
||||
// `origin` is set to the first grasp server, not the mirror path.
|
||||
let destination = {
|
||||
let dir_name = signed_git::sanitize_path_component(&name);
|
||||
let dir_name = if dir_name.is_empty() {
|
||||
@@ -546,8 +544,8 @@ impl Backend {
|
||||
this.add_relays(urls, cx);
|
||||
})?;
|
||||
|
||||
// The state event is the push authorization ("purgatory"), so
|
||||
// it must be accepted before the push below.
|
||||
// The state event is the push authorization.
|
||||
// It must be accepted before the push below.
|
||||
let announcement = GitRepositoryAnnouncement {
|
||||
id: repo_id.clone(),
|
||||
name: Some(name.clone()),
|
||||
@@ -593,8 +591,7 @@ impl Backend {
|
||||
}
|
||||
};
|
||||
|
||||
// Push to every grasp server; creation only fails when no
|
||||
// server accepted it.
|
||||
// Push to every grasp server. Creation fails only when no server accepted it.
|
||||
let push = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
let owner = owner.clone();
|
||||
@@ -604,8 +601,8 @@ impl Backend {
|
||||
});
|
||||
|
||||
if let Err(e) = push.await {
|
||||
// The events are already published; retract them so the
|
||||
// repository doesn't remain announced without content.
|
||||
// The events are already published.
|
||||
// Retract them so the repository is not left announced without content.
|
||||
this.update(cx, |this, cx| {
|
||||
this.retract_events(&[event.clone(), state_event.clone()], cx);
|
||||
})
|
||||
@@ -624,13 +621,12 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish an existing local repository to NIP-34: read its current
|
||||
/// branches, tags and HEAD, publish the announcement and the repository
|
||||
/// state to the grasp relays, then push every branch and tag to each
|
||||
/// grasp server. Also points `origin` at the first grasp server.
|
||||
///
|
||||
/// Same ordering constraint as [`Self::create_repository`]: the state
|
||||
/// event ("purgatory") must be accepted before the push.
|
||||
/// Publish an existing local repository to NIP-34.
|
||||
/// Read its current branches, tags and HEAD.
|
||||
/// Publish the announcement and the repository state to the grasp relays.
|
||||
/// Then push every branch and tag to each grasp server.
|
||||
/// Also point `origin` at the first grasp server.
|
||||
/// The state event must be accepted before the push, like [`Self::create_repository`].
|
||||
pub fn publish_local_repo(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
@@ -654,8 +650,7 @@ impl Backend {
|
||||
return Task::ready(Err(anyhow!("Sign in to publish a repository")));
|
||||
};
|
||||
|
||||
// The repository identifier is derived from the name as in
|
||||
// [`Self::create_repository`].
|
||||
// The identifier derives from the name, as in [`Self::create_repository`].
|
||||
let repo_id = identifier_from_name(&name);
|
||||
|
||||
if repo_id.is_empty() || repo_id.len() > 100 {
|
||||
@@ -690,8 +685,8 @@ impl Backend {
|
||||
this.add_relays(urls, cx);
|
||||
})?;
|
||||
|
||||
// The state event is the push authorization ("purgatory"), so
|
||||
// it must be accepted before the push below.
|
||||
// The state event is the push authorization.
|
||||
// It must be accepted before the push below.
|
||||
let announcement = GitRepositoryAnnouncement {
|
||||
id: repo_id.clone(),
|
||||
name: Some(name.clone()),
|
||||
@@ -735,9 +730,9 @@ impl Backend {
|
||||
}
|
||||
};
|
||||
|
||||
// Push every branch and tag to each grasp server; the init
|
||||
// only fails when no server accepted it. An empty repository
|
||||
// has nothing to push.
|
||||
// Push every branch and tag to each grasp server.
|
||||
// The push fails only when no server accepted it.
|
||||
// An empty repository has nothing to push.
|
||||
if !refs.is_empty() {
|
||||
let push = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
@@ -759,8 +754,7 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
// Point `origin` at the first grasp server so later pushes
|
||||
// have a target.
|
||||
// Point `origin` at the first grasp server so later pushes have a target.
|
||||
if let Some(base) = servers.first().and_then(grasp_base_url) {
|
||||
let url = format!("{base}/{owner}/{repo_id}.git");
|
||||
let path = path.clone();
|
||||
@@ -774,10 +768,10 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-push the repository's current refs to the grasp servers announced
|
||||
/// in its `relays` tag: publishes a fresh state event (the push
|
||||
/// authorization), then pushes every branch and tag, like the init
|
||||
/// flow. The repository must have a local clone in the cache.
|
||||
/// Re-push the repository's current refs to the grasp servers in its `relays` tag.
|
||||
/// Publish a fresh state event, the push authorization.
|
||||
/// Then push every branch and tag, like the init flow.
|
||||
/// The repository must have a local clone in the cache.
|
||||
pub fn push_repository(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
@@ -788,12 +782,12 @@ impl Backend {
|
||||
self.push_repo_from(announcement, path, None, cx)
|
||||
}
|
||||
|
||||
/// Push the refs of a local checkout (the working copy of the user's
|
||||
/// own repository) to the grasp servers announced in the `relays` tag:
|
||||
/// publishes a fresh state event, then pushes every branch and tag of
|
||||
/// the checkout, like the init flow. `announced_head` keeps the state
|
||||
/// event's `HEAD` on the repository's announced default branch when the
|
||||
/// checkout is on a different branch.
|
||||
/// Push the refs of a local checkout to the grasp servers in its `relays` tag.
|
||||
/// The checkout is the working copy of the user's own repository.
|
||||
/// Publish a fresh state event, then push every branch and tag of the checkout.
|
||||
/// That mirrors the init flow.
|
||||
/// `announced_head` keeps the state event's `HEAD` on the announced default branch.
|
||||
/// That matters when the checkout is on a different branch.
|
||||
pub fn push_checkout(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
@@ -804,12 +798,13 @@ impl Backend {
|
||||
self.push_repo_from(announcement, checkout, announced_head, cx)
|
||||
}
|
||||
|
||||
/// Shared body of the mirror-based and checkout-based pushes: publish
|
||||
/// the repository state (the push authorization), then push every
|
||||
/// branch and tag of `path` to each announced grasp server. Pushes are
|
||||
/// single-flight per repository: two concurrent pushes of the same refs
|
||||
/// (e.g. two panels of the same repository) make the losing push fail
|
||||
/// server-side with a compare-and-swap rejection.
|
||||
/// Shared body of the mirror-based and checkout-based pushes.
|
||||
/// Publish the repository state, the push authorization.
|
||||
/// Then push every branch and tag of `path` to each announced grasp server.
|
||||
/// Pushes are single-flight per repository.
|
||||
/// Concurrent pushes of the same refs fail server-side.
|
||||
/// The rejection is a compare-and-swap error from the server.
|
||||
/// Two panels of the same repository can produce the race.
|
||||
fn push_repo_from(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
@@ -841,8 +836,8 @@ impl Backend {
|
||||
let relays = announcement.relays.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Held for the whole task; dropped (and the lock released) on
|
||||
// completion, on error and on cancellation alike.
|
||||
// Held for the whole task.
|
||||
// Dropped on completion, on error and on cancellation alike.
|
||||
let _guard = guard;
|
||||
|
||||
let mut state = {
|
||||
@@ -853,10 +848,9 @@ impl Backend {
|
||||
work.await?
|
||||
};
|
||||
|
||||
// The state event announces the pushed refs. When the source is
|
||||
// a checkout on a side branch, keep the repository's announced
|
||||
// default branch (its `HEAD`) when that branch is among the
|
||||
// pushed refs; otherwise the checkout's current branch.
|
||||
// The state event announces the pushed refs.
|
||||
// Keep the announced default branch in `HEAD` when it is among the pushed refs.
|
||||
// Otherwise `HEAD` stays the checkout's current branch.
|
||||
let heads: Vec<&str> = state
|
||||
.refs
|
||||
.iter()
|
||||
@@ -895,10 +889,10 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete the repository from nostr: publish NIP-09 deletions for its
|
||||
/// announcement, state and activity events (issues, pull requests,
|
||||
/// patches, statuses, comments). Only the repository owner may delete
|
||||
/// it.
|
||||
/// Delete the repository from nostr.
|
||||
/// Publish NIP-09 deletions for its announcement, state and activity events.
|
||||
/// Those are issues, pull requests, patches, statuses and comments.
|
||||
/// Only the repository owner may delete it.
|
||||
pub fn delete_repository(
|
||||
&mut self,
|
||||
addr: RepoAddr,
|
||||
@@ -939,8 +933,8 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on
|
||||
/// the credential's prefix.
|
||||
/// Login with an `nsec1...` key or a `bunker://...` URI.
|
||||
/// Dispatch on the credential's prefix.
|
||||
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
|
||||
let credential = credential.trim();
|
||||
|
||||
@@ -955,8 +949,8 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fresh identity and login with it. The generated key is
|
||||
/// persisted in the keyring like any other `nsec` credential.
|
||||
/// 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()
|
||||
@@ -965,8 +959,8 @@ impl Backend {
|
||||
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.
|
||||
/// 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 keys = match SecretKey::parse(nsec) {
|
||||
Ok(secret) => Keys::new(secret),
|
||||
@@ -990,11 +984,11 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Login with a `bunker://...` URI, NIP-46.
|
||||
/// A fresh session key is embedded into the stored URI as `?master=<nsec>`.
|
||||
/// 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();
|
||||
|
||||
@@ -1058,8 +1052,8 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Fetch the user's grasp list (kind `10317`) and add the listed grasp
|
||||
/// servers as relays.
|
||||
/// Fetch the user's grasp list of kind `10317`.
|
||||
/// Add the listed grasp servers as relays.
|
||||
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -1111,8 +1105,8 @@ impl Backend {
|
||||
self.current_user
|
||||
}
|
||||
|
||||
/// Whether the stored credential is NIP-49 encrypted and a passphrase
|
||||
/// is still needed to resume the session.
|
||||
/// True when the stored credential is NIP-49 encrypted.
|
||||
/// A passphrase is still needed to resume the session.
|
||||
pub fn passphrase_required(&self) -> bool {
|
||||
self.passphrase_required
|
||||
}
|
||||
@@ -1127,13 +1121,15 @@ impl Backend {
|
||||
self.connected
|
||||
}
|
||||
|
||||
/// Progress of the in-flight negentropy sync, if any: `(total, current)`.
|
||||
/// Progress of the in-flight negentropy sync, if any.
|
||||
/// Reported as `total` and `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).
|
||||
/// Update the signer.
|
||||
/// Any type implementing the async signer traits works.
|
||||
/// Examples are `Keys`, `NostrConnect` and a browser extension proxy.
|
||||
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||
@@ -1194,8 +1190,8 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Add relays used only for discovery (e.g. NIP-65 indexers) and
|
||||
/// connect to them. No subscriptions or writes are routed through them.
|
||||
/// Add discovery-only relays, 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 client = self.client.clone();
|
||||
|
||||
@@ -1218,8 +1214,9 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Start a persistent subscription. Matching events are stored in the
|
||||
/// database automatically and surface as [`BackendEvent::NostrUpdate`].
|
||||
/// Start a persistent subscription.
|
||||
/// Matching events are stored in the database automatically.
|
||||
/// They surface as [`BackendEvent::NostrUpdate`].
|
||||
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -1233,9 +1230,8 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Whether an identical fetch was started within [`FETCH_DEDUP_WINDOW`]
|
||||
/// and is still recent enough to suppress a duplicate. Records the
|
||||
/// fingerprint (after pruning expired entries) when returning `false`.
|
||||
/// Whether an identical fetch started within [`FETCH_DEDUP_WINDOW`] is still recent.
|
||||
/// Records the fingerprint when returning `false`, pruning expired entries first.
|
||||
fn fetch_recently_started(&mut self, fingerprint: u64) -> bool {
|
||||
self.recent_fetches
|
||||
.retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW);
|
||||
@@ -1246,17 +1242,13 @@ impl Backend {
|
||||
false
|
||||
}
|
||||
|
||||
/// Connect to relays announced by a repository (NIP-34 `relays` tag) and
|
||||
/// fetch its events from them: a one-shot auto-closing subscription for
|
||||
/// `filters`, plus a negentropy sync so issues, patches and PRs stored
|
||||
/// only on those relays are not missed.
|
||||
///
|
||||
/// Deduplicated: an identical request (same relays and filters) started
|
||||
/// within [`FETCH_DEDUP_WINDOW`] is skipped, so a second panel for the
|
||||
/// same repository doesn't re-run the fetch.
|
||||
///
|
||||
/// Best-effort: failures are logged, not surfaced. The relays stay in
|
||||
/// the pool, so later publishes for this repository also reach them.
|
||||
/// Connect to a repository's announced relays, its NIP-34 `relays` tag.
|
||||
/// Fetch the repository's events from them.
|
||||
/// Run a one-shot auto-closing subscription for `filters`.
|
||||
/// Then a negentropy sync covers issues, patches and PRs stored only on those relays.
|
||||
/// An identical request within [`FETCH_DEDUP_WINDOW`] is skipped.
|
||||
/// The relays stay in the pool, so later publishes for this repository reach them too.
|
||||
/// Failures are logged, not surfaced.
|
||||
pub fn connect_repo_relays(
|
||||
&mut self,
|
||||
relays: Vec<RelayUrl>,
|
||||
@@ -1285,10 +1277,10 @@ 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.
|
||||
/// One-shot subscription on the bootstrap relays only.
|
||||
/// Auto-closes after EOSE or a short timeout.
|
||||
/// Matching events are stored in the database.
|
||||
/// They surface as [`BackendEvent::NostrUpdate`] while the subscription is open.
|
||||
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -1303,14 +1295,13 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Deduplicated: an identical sync started within
|
||||
/// [`FETCH_DEDUP_WINDOW`] is skipped. Observers still see the original
|
||||
/// sync's progress and completion events.
|
||||
/// 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.
|
||||
/// Emits [`BackendEvent::Synced`] on completion.
|
||||
/// An identical sync started within [`FETCH_DEDUP_WINDOW`] is skipped.
|
||||
/// Observers still see the original sync's progress and completion events.
|
||||
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
|
||||
if self.fetch_recently_started(fingerprint) {
|
||||
@@ -1385,12 +1376,10 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Sign, broadcast and locally store an event. Emits
|
||||
/// [`BackendEvent::Published`] on success so stores can refresh.
|
||||
///
|
||||
/// The task yields the outcome of this specific action (for inline
|
||||
/// progress/errors) and is owned by the caller; dropping it cancels
|
||||
/// the publish.
|
||||
/// Sign, broadcast and locally store an event.
|
||||
/// Emits [`BackendEvent::Published`] on success so stores can refresh.
|
||||
/// The task yields the outcome of this specific action for inline progress or errors.
|
||||
/// The caller owns the task, dropping it cancels the publish.
|
||||
pub fn send(
|
||||
&mut self,
|
||||
builder: EventBuilder,
|
||||
@@ -1400,8 +1389,8 @@ impl Backend {
|
||||
let signer = self.signer.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Sign with the current signer, broadcast, and save locally so
|
||||
// the event is immediately visible to database queries.
|
||||
// Sign with the current signer, broadcast and save locally.
|
||||
// The event is immediately visible to database queries.
|
||||
let work = cx.background_spawn(async move {
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
let output = client.send_event(&event).await?;
|
||||
@@ -1440,10 +1429,10 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Broadcast and locally store an already-signed event, like
|
||||
/// [`Self::send`] without the signing step. Callers that signed early
|
||||
/// (e.g. to learn the event id before pushing a commit to the grasp
|
||||
/// servers) publish through this.
|
||||
/// Broadcast and locally store an already-signed event.
|
||||
/// Like [`Self::send`] without the signing step.
|
||||
/// Callers that signed early use this.
|
||||
/// They may need the event id before pushing a commit to the grasp servers.
|
||||
pub fn publish_event(
|
||||
&mut self,
|
||||
event: Event,
|
||||
@@ -1489,9 +1478,9 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish a NIP-34 repository announcement (kind 30617) with the
|
||||
/// current signer. The returned task yields the published event, so
|
||||
/// callers can show inline progress/errors.
|
||||
/// Publish a NIP-34 repository announcement, kind 30617, with the current signer.
|
||||
/// The returned task yields the published event.
|
||||
/// Callers can show inline progress or errors.
|
||||
pub fn publish_announcement(
|
||||
&mut self,
|
||||
announcement: GitRepositoryAnnouncement,
|
||||
@@ -1500,9 +1489,9 @@ impl Backend {
|
||||
self.send(announcement.into_event_builder(), cx)
|
||||
}
|
||||
|
||||
/// Sign, broadcast and store an event without awaiting the result;
|
||||
/// failures surface through [`BackendEvent::Error`]. The spawned task is
|
||||
/// owned by the backend, so it is cancelled when the backend is dropped.
|
||||
/// Sign, broadcast and store an event without awaiting the result.
|
||||
/// Failures surface through [`BackendEvent::Error`].
|
||||
/// The backend owns the spawned task, so dropping it cancels the task.
|
||||
fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
|
||||
let task = self.send(builder, cx);
|
||||
|
||||
@@ -1517,10 +1506,10 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Publish a NIP-09 deletion event for `events` (best-effort), so a
|
||||
/// publish that fails midway can retract the events that were already
|
||||
/// broadcast to relays. Failures are logged, not surfaced: the caller's
|
||||
/// error already told the user what happened.
|
||||
/// Publish NIP-09 deletions for `events`, best-effort.
|
||||
/// A publish that fails midway retracts the events already broadcast to relays.
|
||||
/// Failures are logged, not surfaced.
|
||||
/// The caller's error already told the user what happened.
|
||||
fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
@@ -1544,8 +1533,8 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fingerprint of a relay + filter set, for fetch dedup. Relays and
|
||||
/// filters are sorted first so the fingerprint is order-independent.
|
||||
/// Fingerprint of a relay and filter set, for fetch dedup.
|
||||
/// Relays and filters are sorted first, so the fingerprint is order-independent.
|
||||
fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
|
||||
let mut relays: Vec<&str> = relays.to_vec();
|
||||
relays.sort_unstable();
|
||||
@@ -1558,11 +1547,10 @@ fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Add the given relays, connect to them, and fetch the filters: a one-shot
|
||||
/// subscription (auto-closing after EOSE) plus a negentropy sync per filter
|
||||
/// as a second pass, so events that race with the subscription or relays
|
||||
/// with flaky EOSE behavior can't be missed. Relays without NEG-XX support
|
||||
/// just fail the sync step; the subscription already covered them.
|
||||
/// Add the given relays, connect and fetch the filters.
|
||||
/// Run a one-shot subscription, auto-closing after EOSE, then a negentropy sync per filter.
|
||||
/// The second pass catches events that race the subscription or flaky EOSE behavior.
|
||||
/// Relays without NEG-XX support fail the sync step, the subscription already covered them.
|
||||
async fn connect_repo_relays_only(
|
||||
client: &Client,
|
||||
relays: Vec<RelayUrl>,
|
||||
@@ -1576,8 +1564,8 @@ async fn connect_repo_relays_only(
|
||||
for url in &relays {
|
||||
added |= client.add_relay(url).await?;
|
||||
}
|
||||
// Connecting is only needed when the pool grew; connected relays no-op,
|
||||
// but the call still iterates every relay in the pool.
|
||||
// Connect only when the pool grew.
|
||||
// Connected relays no-op, but the call still iterates every relay in the pool.
|
||||
if added {
|
||||
client.connect().await;
|
||||
}
|
||||
@@ -1592,9 +1580,9 @@ async fn connect_repo_relays_only(
|
||||
.collect();
|
||||
client.subscribe(target).close_on(opts).await?;
|
||||
|
||||
// Sync the filters concurrently: each reconciles against every relay
|
||||
// either way, and a relay without NEG-XX support otherwise serializes
|
||||
// its initial timeout behind every other filter.
|
||||
// Sync the filters concurrently.
|
||||
// Each reconciles against every relay either way.
|
||||
// Without NEG-XX a relay would serialize its initial timeout behind every other filter.
|
||||
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
|
||||
let syncs = filters.into_iter().map(|filter| {
|
||||
let client = &client;
|
||||
@@ -1616,9 +1604,10 @@ async fn connect_repo_relays_only(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Subscribe only on the bootstrap relays.
|
||||
/// Auto-closes after EOSE or a short timeout.
|
||||
/// Use for one-shot data fetches, repo events and profiles.
|
||||
/// Not for persistent gossip-routed subscriptions.
|
||||
pub(crate) async fn subscribe_bootstrap_only(
|
||||
client: &Client,
|
||||
filters: Vec<Filter>,
|
||||
@@ -1658,17 +1647,17 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
|
||||
format!("{uri}{separator}master={nsec}")
|
||||
}
|
||||
|
||||
/// A `https://<host>` (or `http://<host>` for `ws://` grasp servers, like
|
||||
/// ngit) base URL for a grasp server. The repository then lives at
|
||||
/// `{base}/{npub}/{repo-id}.git`.
|
||||
/// Base URL of a grasp server, `https://<host>`.
|
||||
/// `ws://` grasp servers use `http://<host>`, like ngit.
|
||||
/// The repository then lives at `{base}/{npub}/{repo-id}.git`.
|
||||
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
||||
// `domain()` drops the port; parse the full URL to keep it (local dev
|
||||
// grasp servers commonly run on a custom port).
|
||||
// `domain()` drops the port.
|
||||
// Parse the full URL to keep it, local dev grasp servers often run on a custom port.
|
||||
let parsed = Url::parse(relay.as_str()).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
|
||||
// `ws://` grasp servers (e.g. local dev relays) speak plain HTTP;
|
||||
// everything else is HTTPS, matching ngit.
|
||||
// `ws://` grasp servers, e.g. local dev relays, speak plain HTTP.
|
||||
// Everything else is HTTPS, matching ngit.
|
||||
let scheme = if relay.scheme().is_secure() {
|
||||
"https"
|
||||
} else {
|
||||
@@ -1677,25 +1666,25 @@ pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
||||
Some(format!("{scheme}://{host}{port}"))
|
||||
}
|
||||
|
||||
/// The GRASP clone URL of a repository on a grasp server, matching the
|
||||
/// format ngit announces: `https://<host>/<npub>/<repo-id>.git`.
|
||||
/// GRASP clone URL of a repository on a grasp server.
|
||||
/// Matches the format ngit announces, `https://<host>/<npub>/<repo-id>.git`.
|
||||
fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
|
||||
let base = grasp_base_url(relay)?;
|
||||
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
|
||||
}
|
||||
|
||||
/// The GRASP-06 contributor namespace URL of a pull request tip on the
|
||||
/// author's grasp server: `{base}/prs/<author-npub>/<repo-id>.git` (npub in
|
||||
/// the URL; the server stores it under the hex form). Anyone may push there;
|
||||
/// no announcement or maintainer rights are involved.
|
||||
/// GRASP-06 contributor namespace URL of a pull request tip.
|
||||
/// The pattern is `{base}/prs/<author-npub>/<repo-id>.git`.
|
||||
/// The npub sits in the URL, the server stores it under the hex form.
|
||||
/// Anyone may push there, no announcement or maintainer rights are involved.
|
||||
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
|
||||
format!("{base_url}/prs/{npub}/{repo_id}.git")
|
||||
}
|
||||
|
||||
/// Assemble the `clone` URLs of a pull request: the author's GRASP-06
|
||||
/// `/prs/` URLs first (author-controlled, most likely to accept the tip
|
||||
/// push), then the base announcement's clone URLs, deduplicated while
|
||||
/// preserving that order.
|
||||
/// Assemble the `clone` URLs of a pull request.
|
||||
/// The author's GRASP-06 `/prs/` URLs come first.
|
||||
/// They are author-controlled and most likely to accept the tip push.
|
||||
/// The base announcement's clone URLs follow, deduplicated while preserving order.
|
||||
pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut urls = Vec::new();
|
||||
@@ -1708,7 +1697,7 @@ pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Ve
|
||||
}
|
||||
|
||||
/// The `g` tag servers of one kind-10317 grasp list event, in tag order.
|
||||
/// Unparseable URLs are dropped (the UI only writes well-formed servers).
|
||||
/// Unparseable URLs are dropped, the UI only writes well-formed servers.
|
||||
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
||||
event
|
||||
.tags
|
||||
@@ -1719,10 +1708,9 @@ fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The grasp servers of the newest kind-10317 grasp list among `events`
|
||||
/// (latest event wins, like every other latest-wins resolution in the app);
|
||||
/// empty when there is no list, so the caller falls back to the settings
|
||||
/// defaults.
|
||||
/// Grasp servers of the newest kind-10317 grasp list among `events`.
|
||||
/// The latest event wins, like other latest-wins resolutions in the app.
|
||||
/// Empty when there is no list, so the caller falls back to the settings defaults.
|
||||
fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
||||
events
|
||||
.into_iter()
|
||||
@@ -1731,10 +1719,10 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve the user's published grasp servers: the `g` tags (in order) of
|
||||
/// their latest kind-10317 grasp list in the local database. Returns an
|
||||
/// empty list when the user has no published list, so the caller can fall
|
||||
/// back to the settings defaults.
|
||||
/// Resolve the user's published grasp servers.
|
||||
/// Read the `g` tags of their latest kind-10317 grasp list in the local database.
|
||||
/// Returns an empty list when the user has no published list.
|
||||
/// The caller can then fall back to the settings defaults.
|
||||
pub(crate) async fn user_grasp_list_servers(
|
||||
client: Client,
|
||||
user: PublicKey,
|
||||
@@ -1748,11 +1736,11 @@ pub(crate) async fn user_grasp_list_servers(
|
||||
Ok(latest_grasp_list_servers(events))
|
||||
}
|
||||
|
||||
/// Push the repository at `path` to every grasp server: a server that
|
||||
/// rejects the push is logged, but the push only fails when no server
|
||||
/// accepted it. `push` performs the single-server push (e.g.
|
||||
/// [`signed_git::push_main`] for the create flow, [`signed_git::push_all`]
|
||||
/// for the init flow).
|
||||
/// Push the repository at `path` to every grasp server.
|
||||
/// Rejecting servers are logged, the push only fails when no server accepted it.
|
||||
/// `push` performs the single-server push.
|
||||
/// [`signed_git::push_main`] serves the create flow.
|
||||
/// [`signed_git::push_all`] serves the init flow.
|
||||
async fn push_to_grasp_servers(
|
||||
path: PathBuf,
|
||||
owner: String,
|
||||
@@ -1789,7 +1777,7 @@ async fn push_to_grasp_servers(
|
||||
}
|
||||
|
||||
/// Split a stored bunker credential into the plain URI and the session key.
|
||||
/// Credentials without an embedded key (legacy) get a fresh one.
|
||||
/// 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)) => {
|
||||
@@ -1837,7 +1825,7 @@ mod tests {
|
||||
grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"),
|
||||
"https://relay.ngit.dev/prs/npub1author/my-repo.git"
|
||||
);
|
||||
// `ws://` grasp servers (local dev) keep their plain-HTTP base.
|
||||
// `ws://` grasp servers, local dev, keep their plain-HTTP base.
|
||||
assert_eq!(
|
||||
grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"),
|
||||
"http://localhost:8080/prs/npub1author/my-repo.git"
|
||||
@@ -1913,7 +1901,7 @@ mod tests {
|
||||
vec!["wss://fresh.example", "wss://also.example"]
|
||||
);
|
||||
|
||||
// No list at all: empty, so the caller falls back to the defaults.
|
||||
// No list at all, empty, so the caller falls back to the defaults.
|
||||
assert!(latest_grasp_list_servers(Vec::new()).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user