remove wasm support and drop unused code paths

This commit is contained in:
2026-10-03 10:53:26 +07:00
parent 855f7f5659
commit 24070cc2cd
18 changed files with 51 additions and 1228 deletions
+7 -197
View File
@@ -40,10 +40,6 @@ pub enum BackendEvent {
/// Repository events arrived: announcements, states, activity and deletions.
RepoUpdates(Vec<Update>),
Synced,
SyncProgress {
total: u64,
current: u64,
},
Error(String),
}
@@ -61,7 +57,6 @@ pub struct Backend {
signer: UniversalSigner,
current_user: Option<PublicKey>,
inbox: Entity<Inbox>,
sync_progress: Option<(u64, u64)>,
/// True when the stored credential is NIP-49 encrypted.
passphrase_required: bool,
pushing_repos: Entity<HashSet<RepoAddr>>,
@@ -160,7 +155,6 @@ impl Backend {
signer,
current_user: None,
inbox: cx.new(|_| Inbox::default()),
sync_progress: None,
passphrase_required: false,
pushing_repos: cx.new(|_| HashSet::new()),
}
@@ -170,7 +164,7 @@ impl Backend {
///
/// - 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>) {
fn restore_session(&mut self, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") {
cx.emit(BackendEvent::SignerRequired);
return;
@@ -913,117 +907,6 @@ impl Backend {
})
}
/// Login with an `nsec1...` key or a `bunker://...` URI.
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
let credential = credential.trim();
if credential.starts_with("nsec1") {
self.login_with_nsec(credential, cx);
} else if credential.starts_with("bunker://") {
self.login_with_bunker(credential, cx);
} else {
cx.emit(BackendEvent::error("Unsupported credential."));
}
}
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
let nsec = Keys::generate()
.secret_key()
.to_bech32()
.expect("infallible");
self.login_with_nsec(&nsec, cx);
}
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
let keys = match SecretKey::parse(nsec) {
Ok(secret) => Keys::new(secret),
Err(e) => {
cx.emit(BackendEvent::error(e.to_string()));
return;
}
};
let nsec = nsec.trim().to_owned();
let pubkey = keys.public_key().to_hex();
let write = cx.write_credentials(USER_KEYRING, &pubkey, nsec.as_bytes());
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
if let Err(e) = write.await {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
return Ok(());
}
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
Ok(())
});
task.detach();
}
/// Login with a `bunker://...` URI, NIP-46.
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 keys = Keys::generate();
let credential = with_master_key(&uri_string, &keys);
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let result = async {
let mut signer = NostrConnect::new(
connect_uri,
keys,
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, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
}
Ok(())
});
task.detach();
}
pub fn logout(&mut self, cx: &mut Context<Self>) {
let delete = cx.delete_credentials(USER_KEYRING);
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
delete.await.ok();
this.update(cx, |this, cx| {
this.signer.swap_inner(Keys::generate());
this.current_user = None;
this.passphrase_required = false;
cx.emit(BackendEvent::SignerChanged);
cx.emit(BackendEvent::SignerRequired);
this.sync_inbox(cx);
cx.notify();
})?;
Ok(())
});
task.detach();
}
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -1101,11 +984,7 @@ impl Backend {
});
}
pub fn sync_progress(&self) -> Option<(u64, u64)> {
self.sync_progress
}
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
@@ -1190,46 +1069,14 @@ impl Backend {
/// Sync several bootstrap filters in order, within a single task.
pub fn sync_bootstraps(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone();
let (tx, mut rx) = SyncProgress::channel();
self.sync_progress = Some((0, 0));
cx.notify();
cx.spawn(async move |this, cx| {
let mut last_percent: u64 = 0;
while rx.changed().await.is_ok() {
let progress = *rx.borrow_and_update();
let percent = (progress.percentage() * 100.0) as u64;
if progress.current > 0 && percent != last_percent {
last_percent = percent;
let alive = this.update(cx, |this, cx| {
this.sync_progress = Some((progress.total, progress.current));
cx.emit(BackendEvent::SyncProgress {
total: progress.total,
current: progress.current,
});
cx.notify();
});
if alive.is_err() {
break;
}
}
}
Ok::<(), anyhow::Error>(())
})
.detach();
let sync = cx.background_spawn(async move {
let mut first_error = None;
for filter in filters {
let opts = SyncOptions::default().progress(tx.clone());
if let Err(error) = sync_bootstrap_only(&client, filter, opts).await {
if let Err(error) =
sync_bootstrap_only(&client, filter, SyncOptions::default()).await
{
first_error.get_or_insert(error);
}
}
@@ -1243,17 +1090,13 @@ impl Backend {
cx.spawn(async move |this, cx| {
match sync.await {
Ok(_) => {
this.update(cx, |this, cx| {
this.sync_progress = None;
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::Synced);
cx.notify();
})?;
}
Err(e) => {
this.update(cx, |this, cx| {
this.sync_progress = None;
cx.emit(BackendEvent::error(e.to_string()))
})?;
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
}
}
@@ -1447,13 +1290,6 @@ pub(crate) async fn sync_bootstrap_only(
Ok(output.value)
}
/// Embed a NIP-46 session key into a bunker URI as `?master=<nsec>`.
fn with_master_key(uri: &str, keys: &Keys) -> String {
let separator = if uri.contains('?') { '&' } else { '?' };
let nsec = keys.secret_key().to_bech32().expect("infallible");
format!("{uri}{separator}master={nsec}")
}
/// Base URL of a grasp server, `https://<host>`.
///
/// `ws://` grasp servers use `http://<host>`, like ngit.
@@ -1991,30 +1827,4 @@ mod tests {
" ! [rejected] main -> main (non-fast-forward)"
));
}
#[test]
fn push_outcome_reports_partial_failures() {
let outcome = PushOutcome {
servers: vec![
GraspServerResult::ok(RelayUrl::parse("wss://gitnostr.com").expect("url")),
GraspServerResult::failed(
RelayUrl::parse("wss://relay.ngit.dev").expect("url"),
"remote: ERR authorisation failed: No state events in purgatory\nfatal: ...",
),
],
state_event: None,
};
assert_eq!(outcome.accepted(), 1);
assert_eq!(
outcome.failure_summary(),
"wss://relay.ngit.dev: remote: ERR authorisation failed: No state events in \
purgatory fatal: ..."
);
let warning = outcome.partial_warning().expect("partial push warning");
assert!(warning.starts_with("Pushed to 1 of 2 grasp servers"));
assert!(warning.contains("Republish to sync"));
// The multi-line server reason is a single display line.
assert_eq!(warning.lines().count(), 1);
}
}
-99
View File
@@ -715,8 +715,6 @@ pub fn pr_proposes_checkout(
mod tests {
use std::process::Command;
use signed_core::{RepoAddr, repo_addr};
use super::*;
#[test]
@@ -744,103 +742,6 @@ mod tests {
assert!(!same_repo_url("/local/path", "/local/other"));
}
fn remembered(path: &str, id: &str, last_used: u64) -> Remembered {
Remembered {
path: PathBuf::from(path),
addr: addr(id),
last_used,
}
}
fn scanned(
path: &str,
origin: Option<&str>,
root: Option<&str>,
) -> (PathBuf, Option<String>, Option<String>) {
(
PathBuf::from(path),
origin.map(str::to_owned),
root.map(str::to_owned),
)
}
const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
fn owner() -> PublicKey {
Keys::new(SecretKey::from_hex(KEY).expect("secret")).public_key()
}
fn addr(id: &str) -> RepoAddr {
repo_addr(owner(), id)
}
/// Build one announcement by the fixed test owner.
/// Takes `clone` URLs and an EUC.
fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement {
let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret"));
let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")];
for url in clones {
tags.push(Tag::parse(vec!["clone", *url]).expect("tag"));
}
if let Some(euc) = euc {
tags.push(Tag::parse(vec!["r", euc, "euc"]).expect("tag"));
}
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys)
.expect("signed");
Announcement::from_event(&event).expect("parsed")
}
#[test]
fn resolve_orders_remembered_freshest_first() {
let announcements = vec![announcement("repo", &[], None)];
let base = addr("repo");
let resolved = resolve_associations(
&[
remembered("/old", "repo", 100),
remembered("/fresh", "repo", 200),
remembered("/other", "unrelated", 300),
],
&[],
&announcements,
);
let paths = resolved.get(&base).expect("associations");
assert_eq!(paths, &vec![PathBuf::from("/fresh"), PathBuf::from("/old")]);
// Records for repositories without announcements stay inert.
assert_eq!(resolved.len(), 2);
}
#[test]
fn resolve_deduplicates_paths_remembering_first() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let announcements = vec![announcement(
"repo",
&["https://host/npub1x/repo.git"],
Some(euc),
)];
let base = addr("repo");
// The same path is both remembered and scanned, its origin matches.
// The remembered occurrence wins and the path is listed once.
let resolved = resolve_associations(
&[remembered("/shared", "repo", 100)],
&[
scanned("/shared", Some("https://host/npub1x/repo"), None),
scanned("/scanned-only", Some("https://host/npub1x/repo.git"), None),
],
&announcements,
);
let paths = resolved.get(&base).expect("associations");
assert_eq!(
paths,
&vec![PathBuf::from("/shared"), PathBuf::from("/scanned-only")]
);
}
#[test]
fn checkout_status_reports_ahead_branches_only() {
let dir = tempfile::tempdir().expect("tempdir");
+2 -1
View File
@@ -3,6 +3,7 @@ use std::sync::OnceLock;
use anyhow::Result;
use gix::Repository;
use nostr::prelude::Url;
use signed_core::RepoAddr;
use signed_git::GitCache;
@@ -30,7 +31,7 @@ pub fn open_repo_mirror(addr: &RepoAddr) -> Result<Option<Repository>> {
}
/// Open the mirror of `addr`, cloning it first when it does not exist yet.
pub fn ensure_repo_mirror<U: AsRef<str>>(addr: &RepoAddr, clone_urls: &[U]) -> Result<Repository> {
pub fn ensure_repo_mirror(addr: &RepoAddr, clone_urls: &[Url]) -> Result<Repository> {
git_cache().ensure_clone(addr, clone_urls)
}
-35
View File
@@ -24,41 +24,6 @@ impl Inbox {
self.loaded
}
pub fn mark_read(
&mut self,
group: &[Event],
all: &[Event],
me: PublicKey,
cx: &mut Context<Self>,
) {
for event in group {
self.state.mark_read(event);
}
self.state.advance_read(all, me, Timestamp::now());
self.persist(cx);
cx.notify();
}
/// Archived events are always read too.
pub fn mark_archived(
&mut self,
group: &[Event],
all: &[Event],
me: PublicKey,
cx: &mut Context<Self>,
) {
for event in group {
self.state.mark_archived(event);
self.state.mark_read(event);
}
let now = Timestamp::now();
self.state.advance_archived(all, me, now);
self.state.advance_read(all, me, now);
self.persist(cx);
cx.notify();
}
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
self.state.mark_all_read(all, me, Timestamp::now());
self.persist(cx);
+3 -3
View File
@@ -16,13 +16,13 @@ use git_store::set_git_cache;
pub use git_store::{ensure_repo_mirror, open_repo_mirror, repo_mirror_path};
use gpui::{App, AppContext};
pub use inbox::{Inbox, query_inbox};
pub use local_repos::{LocalReposStore, ResolvedLocalRepo, local_repo_addr, resolve_local_repos};
pub use local_repos::{LocalReposStore, ResolvedLocalRepo, resolve_local_repos};
pub use nostr_sdk::prelude::Timestamp;
pub use profile::{Profile, ProfileStore};
pub use refresh::{RefreshGate, RefreshRequest};
pub use repo::RepoStore;
pub use repos::{RepoActivityCounts, RepoListStore};
pub use signed_git::{GraspSignals, LocalRepo, Nip34Binding, Nip34Kind};
pub use repos::RepoListStore;
pub use signed_git::{Nip34Binding, Nip34Kind};
use signed_nostr::new_backend;
#[cfg(not(target_arch = "wasm32"))]
-52
View File
@@ -235,56 +235,4 @@ mod tests {
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].announcement.as_ref(), Some(&known));
}
#[test]
fn an_unmatched_repository_keeps_its_binding() {
let repo = bound(KEY, "unlisted");
let resolved = resolve_local_repos(&[repo], &[], &[]);
assert_eq!(resolved.len(), 1);
assert!(resolved[0].announcement.is_none());
assert_eq!(
resolved[0].nip34.as_ref().map(|binding| binding.kind),
Some(Nip34Kind::Initialized)
);
}
#[test]
fn a_plain_repository_is_kept_without_a_binding() {
let repo = LocalRepo {
path: PathBuf::from("plain"),
nip34: None,
};
let resolved = resolve_local_repos(&[repo], &[], &[]);
assert_eq!(resolved.len(), 1);
assert!(resolved[0].nip34.is_none());
assert!(resolved[0].announcement.is_none());
}
#[test]
fn the_name_is_the_directory_name() {
let repo = LocalRepo {
path: PathBuf::from("/tmp/my-repo"),
nip34: None,
};
let resolved = resolve_local_repos(&[repo], &[], &[]);
assert_eq!(resolved[0].name(), SharedString::from("my-repo"));
}
#[test]
fn a_path_without_a_directory_name_is_untitled() {
let repo = LocalRepo {
path: PathBuf::from("/"),
nip34: None,
};
let resolved = resolve_local_repos(&[repo], &[], &[]);
assert_eq!(resolved[0].name(), SharedString::from("Untitled"));
}
}
-45
View File
@@ -42,48 +42,3 @@ impl RefreshGate {
self.running = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_request_while_running_folds_into_a_follow_up() {
let mut gate = RefreshGate::default();
gate.begin();
assert_eq!(gate.request(), RefreshRequest::Fold);
assert!(gate.finish());
}
#[test]
fn a_request_without_a_run_schedules() {
let mut gate = RefreshGate::default();
assert_eq!(gate.request(), RefreshRequest::Schedule);
assert!(!gate.running());
}
#[test]
fn a_request_after_a_run_schedules_again() {
let mut gate = RefreshGate::default();
gate.begin();
assert_eq!(gate.request(), RefreshRequest::Fold);
assert!(gate.finish());
assert_eq!(gate.request(), RefreshRequest::Schedule);
}
#[test]
fn abort_keeps_the_pending_request() {
let mut gate = RefreshGate::default();
gate.begin();
assert_eq!(gate.request(), RefreshRequest::Fold);
gate.abort();
assert!(!gate.running());
gate.begin();
assert!(gate.finish());
}
}
+3 -147
View File
@@ -9,8 +9,7 @@ use nostr::event::IntoEventBuilder;
use nostr_sdk::prelude::*;
use settings::{EventFetchingStrategy, SettingsStore};
use signed_core::{
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
pull_request_patches,
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patches,
};
use signed_git::Nip34Binding;
use signed_nostr::UniversalSigner;
@@ -20,7 +19,6 @@ use crate::backend::{
user_grasp_list_servers,
};
use crate::checkouts::CheckoutsStore;
use crate::git_store::ensure_repo_mirror;
use crate::repos::RepoListStore;
/// Maximum size of one patch event.
@@ -657,7 +655,7 @@ impl RepoStore {
/// Reply to `parent`, a comment on `root`, with a NIP-22 threaded comment.
///
/// `None` publishes a top-level comment on the root itself.
pub fn reply(
fn reply(
&mut self,
root: &Event,
parent: Option<&Event>,
@@ -1151,7 +1149,7 @@ impl RepoStore {
/// Set the status of a root event.
///
/// Only the root author or a maintainer may set it, per NIP-34.
pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
self.last_error = None;
let Some(addr) = self.addr.clone() else {
@@ -1192,88 +1190,6 @@ impl RepoStore {
self.publish(builder, cx);
}
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
self.last_error = None;
self.last_warning = None;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let is_author = Backend::global(cx)
.read(cx)
.current_user()
.is_some_and(|user| self.is_author(&user));
if !is_author {
self.last_error = Some("Only the repository author can merge pull requests".into());
return;
}
let clone_urls: Vec<Url> = self
.announcement
.as_ref()
.map(|a| a.clone.clone())
.unwrap_or_default();
let patch = pull_request_patch(root, self.patches.iter());
// The applied patch events, for the status tags below.
let patches: Vec<Event> = pull_request_patches(root, self.patches.iter())
.into_iter()
.cloned()
.collect();
let relay_hint = self
.announcement
.as_ref()
.and_then(|a| a.relays.first())
.map(ToString::to_string)
.unwrap_or_default();
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let root = root.clone();
let apply = cx.background_spawn(async move {
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
let workdir = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
.to_path_buf();
// The commits the apply created.
// Everything between the previous HEAD and the new one, oldest first.
let previous = signed_git::head_commit_id(&workdir)?;
signed_git::apply_patch(&workdir, &patch)?;
let applied = signed_git::commits_since(&workdir, previous.as_deref())?;
Ok::<_, Error>(applied)
});
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
match apply.await {
Ok(applied) => {
this.update(cx, |this, cx| {
this.publish_applied_status(
&root,
&patches,
&applied,
&relay_hint,
euc.as_deref(),
cx,
);
})?;
}
Err(e) => {
this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
})?;
}
}
Ok(())
});
self.tasks.push(task);
}
/// The latest announcement of this repository,
/// for operations that need its clone URLs and relays.
fn action_announcement(&self, cx: &App) -> Option<Announcement> {
@@ -1495,66 +1411,6 @@ impl RepoStore {
Task::ready(Err(anyhow::anyhow!("{message}")))
}
/// Publish a kind-1631 Applied status event for `root` after a merge.
fn publish_applied_status(
&mut self,
root: &Event,
patches: &[Event],
applied: &[String],
relay_hint: &str,
euc: Option<&str>,
cx: &mut Context<Self>,
) {
let Some(addr) = self.addr.clone() else {
return;
};
let mut tags = vec![
Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"),
Tag::public_key(addr.public_key),
Tag::public_key(root.pubkey),
Tag::coordinate(addr, None),
];
if let Some(euc) = euc
&& let Ok(tag) = Tag::parse(["r", euc])
{
tags.push(tag);
}
// Tag each applied patch event.
// `q` per event, `e` reply for events beyond the root, chain parts and revisions.
// Their statuses then resolve to Applied too.
for (ix, patch) in patches.iter().enumerate() {
if let Ok(tag) =
Tag::parse(["q", &patch.id.to_hex(), relay_hint, &patch.pubkey.to_hex()])
{
tags.push(tag);
}
if ix > 0
&& let Ok(tag) = Tag::parse(["e", &patch.id.to_hex(), "", "reply"])
{
tags.push(tag);
}
}
// The commits `git am` created on top of the previous HEAD.
if !applied.is_empty() {
let mut applied_tag = vec!["applied-as-commits".to_string()];
applied_tag.extend(applied.iter().cloned());
if let Ok(tag) = Tag::parse(applied_tag) {
tags.push(tag);
}
for commit in applied {
if let Ok(tag) = Tag::parse(["r", commit]) {
tags.push(tag);
}
}
}
self.publish(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx);
}
/// Sign `builder`, broadcast it and track the outcome in [`Self::last_error`].
///
/// Every one-shot repository event (issue, comment, status) goes through