From 5c005e281deda7241702e30b121ad8c0d1d6d555 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 4 Aug 2026 14:52:12 +0700 Subject: [PATCH 1/5] initial backend --- Cargo.lock | 890 ++++++++++++++++++++++++++- Cargo.toml | 15 +- crates/signed_core/Cargo.toml | 8 + crates/signed_core/src/addr.rs | 44 ++ crates/signed_core/src/clone_url.rs | 120 ++++ crates/signed_core/src/filters.rs | 75 +++ crates/signed_core/src/lib.rs | 10 + crates/signed_core/src/model.rs | 86 +++ crates/signed_core/src/status.rs | 60 ++ crates/signed_git/Cargo.toml | 12 + crates/signed_git/src/lib.rs | 118 ++++ crates/signed_nostr/Cargo.toml | 23 + crates/signed_nostr/src/backend.rs | 115 ++++ crates/signed_nostr/src/lib.rs | 7 + crates/signed_nostr/src/pump.rs | 46 ++ crates/signed_nostr/src/signer.rs | 201 ++++++ crates/signed_state/Cargo.toml | 19 + crates/signed_state/src/backend.rs | 195 ++++++ crates/signed_state/src/lib.rs | 42 ++ crates/signed_state/src/repo.rs | 233 +++++++ crates/signed_state/src/repo_list.rs | 114 ++++ 21 files changed, 2412 insertions(+), 21 deletions(-) create mode 100644 crates/signed_core/Cargo.toml create mode 100644 crates/signed_core/src/addr.rs create mode 100644 crates/signed_core/src/clone_url.rs create mode 100644 crates/signed_core/src/filters.rs create mode 100644 crates/signed_core/src/lib.rs create mode 100644 crates/signed_core/src/model.rs create mode 100644 crates/signed_core/src/status.rs create mode 100644 crates/signed_git/Cargo.toml create mode 100644 crates/signed_git/src/lib.rs create mode 100644 crates/signed_nostr/Cargo.toml create mode 100644 crates/signed_nostr/src/backend.rs create mode 100644 crates/signed_nostr/src/lib.rs create mode 100644 crates/signed_nostr/src/pump.rs create mode 100644 crates/signed_nostr/src/signer.rs create mode 100644 crates/signed_state/Cargo.toml create mode 100644 crates/signed_state/src/backend.rs create mode 100644 crates/signed_state/src/lib.rs create mode 100644 crates/signed_state/src/repo.rs create mode 100644 crates/signed_state/src/repo_list.rs diff --git a/Cargo.lock b/Cargo.lock index c9fe88e..4428f5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -124,7 +134,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", "zeroize", ] @@ -468,6 +478,38 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "async-utility" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "188f83b9a198af8c336e505611edb00d6d2ac5c694241c5a4f9a12316938cfe9" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa9ef232c1731ddbe4a306b5b5dcf9e50a924d6bd086d4784939d14f8e78daa" +dependencies = [ + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-happy-eyeballs", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "atomic" version = "0.5.3" @@ -616,6 +658,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + [[package]] name = "bindgen" version = "0.71.1" @@ -636,6 +684,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.9.1" @@ -657,6 +716,43 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -668,6 +764,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "bitstream-io" @@ -762,6 +861,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "btreecap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6160c957d8aa33d0a8ba1dbab98e3cb57023ad9374c501441e88559f99e6c4c9" + [[package]] name = "built" version = "0.8.1" @@ -852,7 +957,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -921,6 +1026,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -932,6 +1048,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -953,10 +1082,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clang-sys" version = "1.9.1" @@ -977,6 +1117,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cocoa" version = "0.25.0" @@ -1401,6 +1547,21 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "data-url" version = "0.3.2" @@ -1466,6 +1627,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1542,6 +1704,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "doxygen-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" +dependencies = [ + "phf 0.11.3", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1765,6 +1936,16 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless 0.8.0", + "serde", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1822,6 +2003,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags 2.13.1", + "rustc_version", +] + [[package]] name = "flate2" version = "1.1.9" @@ -1859,6 +2050,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.9", +] + [[package]] name = "flume" version = "0.12.0" @@ -2203,6 +2405,21 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.13.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe 0.1.6", + "openssl-sys", + "url", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -2244,6 +2461,18 @@ dependencies = [ "walkdir", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "glow" version = "0.17.0" @@ -2332,7 +2561,7 @@ dependencies = [ "gpui_macros", "gpui_shared_string", "gpui_util", - "heapless", + "heapless 0.9.3", "http_client", "image", "inventory", @@ -2780,6 +3009,16 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.9.3" @@ -2802,6 +3041,40 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heed" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d4f449bab7320c56003d37732a917e18798e2f1709d80263face2b4f9436ddb" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "heed-traits", + "heed-types", + "libc", + "lmdb-master-sys", + "once_cell", + "page_size", + "synchronoise", + "url", +] + +[[package]] +name = "heed-traits" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff" + +[[package]] +name = "heed-types" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d3f528b053a6d700b2734eabcd0fd49cb8230647aa72958467527b0b7917114" +dependencies = [ + "byteorder", + "heed-traits", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -2814,6 +3087,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + [[package]] name = "hexf-parse" version = "0.2.1" @@ -2826,7 +3117,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -2838,6 +3129,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "home" version = "0.5.12" @@ -3231,6 +3531,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -3349,6 +3658,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -3507,6 +3846,20 @@ dependencies = [ "cc", ] +[[package]] +name = "libgit2-sys" +version = "0.18.7+1.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3532,6 +3885,32 @@ dependencies = [ "libc", ] +[[package]] +name = "libssh2-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -3574,6 +3953,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lmdb-master-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b" +dependencies = [ + "cc", + "doxygen-rs", + "libc", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -3602,6 +3992,12 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" + [[package]] name = "lru-slab" version = "0.1.2" @@ -3901,6 +4297,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -3910,6 +4312,12 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "negentropy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3965,6 +4373,124 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nostr" +version = "0.45.0-alpha.8" +source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +dependencies = [ + "aes", + "base64", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "faster-hex", + "opaquerr", + "rand 0.10.2", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "universal-time", + "url", +] + +[[package]] +name = "nostr-connect" +version = "0.45.0-alpha.8" +source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +dependencies = [ + "async-utility", + "futures-core", + "nostr", + "nostr-sdk", + "opaquerr", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-database" +version = "0.45.0-alpha.8" +source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +dependencies = [ + "btreecap", + "flatbuffers", + "nostr", + "opaquerr", +] + +[[package]] +name = "nostr-gossip" +version = "0.45.0-alpha.8" +source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +dependencies = [ + "nostr", + "opaquerr", +] + +[[package]] +name = "nostr-gossip-memory" +version = "0.45.0-alpha.8" +source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +dependencies = [ + "indexmap", + "lru", + "nostr", + "nostr-gossip", + "tokio", +] + +[[package]] +name = "nostr-lmdb" +version = "0.45.0-alpha.8" +source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +dependencies = [ + "async-utility", + "flume 0.12.0", + "heed", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-memory" +version = "0.45.0-alpha.8" +source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +dependencies = [ + "btreecap", + "nostr", + "nostr-database", + "tokio", +] + +[[package]] +name = "nostr-sdk" +version = "0.45.0-alpha.8" +source = "git+https://github.com/rust-nostr/nostr?rev=d0a1d67d3c9e5cf9710807a6a414c155a5f47215#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" +dependencies = [ + "async-utility", + "async-wsocket", + "faster-hex", + "futures", + "lru", + "negentropy", + "nostr", + "nostr-database", + "nostr-gossip", + "opaquerr", + "rand 0.10.2", + "tokio", + "tokio-stream", + "tracing", + "universal-time", +] + [[package]] name = "notify" version = "7.0.0" @@ -4501,18 +5027,18 @@ dependencies = [ "async-lock", "blocking", "cbc", - "cipher", + "cipher 0.4.4", "digest 0.10.7", "endi", "futures-lite", "futures-util", "getrandom 0.4.3", "hkdf", - "hmac", + "hmac 0.12.1", "md-5", "num", "num-bigint-dig", - "pbkdf2", + "pbkdf2 0.12.2", "serde", "serde_bytes", "sha2 0.10.9", @@ -4523,6 +5049,18 @@ dependencies = [ "zvariant", ] +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "opaquerr" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e55a7b7f0accc9fdd8e514f122a697adf824d55de481ca26c31ff8f9a8e813c5" + [[package]] name = "open" version = "5.4.0" @@ -4533,12 +5071,30 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -4564,6 +5120,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking" version = "2.2.1" @@ -4631,7 +5197,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", ] [[package]] @@ -4656,6 +5232,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ + "phf_macros 0.11.3", "phf_shared 0.11.3", ] @@ -4665,7 +5242,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", + "phf_macros 0.13.1", "phf_shared 0.13.1", "serde", ] @@ -4700,6 +5277,19 @@ dependencies = [ "phf_shared 0.13.1", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "phf_macros" version = "0.13.1" @@ -4832,6 +5422,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polycool" version = "0.4.0" @@ -5153,7 +5754,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -5689,7 +6290,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", "security-framework", @@ -5722,7 +6323,7 @@ checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -5783,6 +6384,16 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -5809,7 +6420,7 @@ dependencies = [ "async-task", "backtrace", "chrono", - "flume", + "flume 0.12.0", "futures", "parking_lot", "rand 0.9.5", @@ -5877,12 +6488,44 @@ dependencies = [ "once_cell", ] +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2 0.13.0", + "salsa20", + "sha2 0.11.0", +] + [[package]] name = "seahash" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.7", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6056,6 +6699,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -6139,12 +6793,69 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "signed_core" +version = "1.0.0" +dependencies = [ + "nostr", +] + +[[package]] +name = "signed_git" +version = "1.0.0" +dependencies = [ + "anyhow", + "git2", + "nostr", + "signed_core", +] + +[[package]] +name = "signed_nostr" +version = "1.0.0" +dependencies = [ + "anyhow", + "flume 0.11.1", + "nostr", + "nostr-connect", + "nostr-gossip-memory", + "nostr-lmdb", + "nostr-memory", + "nostr-sdk", + "signed_core", + "webbrowser", +] + +[[package]] +name = "signed_state" +version = "1.0.0" +dependencies = [ + "anyhow", + "flume 0.11.1", + "gpui", + "nostr", + "nostr-sdk", + "rustls", + "signed_core", + "signed_nostr", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -6154,6 +6865,12 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplecss" version = "0.2.2" @@ -6392,7 +7109,7 @@ name = "sum_tree" version = "0.1.0" source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" dependencies = [ - "heapless", + "heapless 0.9.3", "log", "rayon", "tracing", @@ -6546,6 +7263,15 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synchronoise" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2" +dependencies = [ + "crossbeam-queue", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -6822,9 +7548,30 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-happy-eyeballs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8564c32dfb6f4257f8bc6edfc178a34af97520e0b7b9815500c55eb3d092f29f" +dependencies = [ + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6847,6 +7594,34 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -7064,6 +7839,25 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.19", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -7129,6 +7923,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -7165,6 +7968,22 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "universal-time" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" + [[package]] name = "untrusted" version = "0.9.0" @@ -7181,6 +8000,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -7325,6 +8145,12 @@ dependencies = [ "sval_serde", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -7588,6 +8414,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6eb50cc7b133f0c7720a64661fbd8e520b882f8ab9015deea5e00d5cd809c4" +dependencies = [ + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[package]] name = "webpki-root-certs" version = "0.26.11" @@ -7606,6 +8448,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 9fe7385..4bb6f98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,12 +20,15 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } gpui-component = { git = "https://github.com/longbridge/gpui-component" } -nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } -nostr-memory = { git = "https://github.com/rust-nostr/nostr" } -nostr-blossom = { git = "https://github.com/rust-nostr/nostr" } -nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } -nostr-connect = { git = "https://github.com/rust-nostr/nostr" } -nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } +nostr = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215", features = ["nip59", "nip49", "nip44"] } +nostr-lmdb = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } +nostr-memory = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } +nostr-blossom = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } +nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } +nostr-connect = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } + +git2 = { version = "0.20", features = ["vendored-libgit2"] } chrono = { version = "0.4.38", features = ["wasmbind"] } smol = "2" diff --git a/crates/signed_core/Cargo.toml b/crates/signed_core/Cargo.toml new file mode 100644 index 0000000..5b1e5ca --- /dev/null +++ b/crates/signed_core/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "signed_core" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +nostr.workspace = true diff --git a/crates/signed_core/src/addr.rs b/crates/signed_core/src/addr.rs new file mode 100644 index 0000000..79df08b --- /dev/null +++ b/crates/signed_core/src/addr.rs @@ -0,0 +1,44 @@ +use std::fmt; +use std::str::FromStr; + +use nostr::prelude::*; + +/// Address of a NIP-34 repository announcement: `30617::`. +#[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) -> 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 `::`, `naddr1...` bech32 or `nostr:naddr1...` URI. + fn from_str(s: &str) -> Result { + let coordinate = Coordinate::parse(s)?; + Ok(Self { + owner: coordinate.public_key, + id: coordinate.identifier, + }) + } +} diff --git a/crates/signed_core/src/clone_url.rs b/crates/signed_core/src/clone_url.rs new file mode 100644 index 0000000..5f3f6ec --- /dev/null +++ b/crates/signed_core/src/clone_url.rs @@ -0,0 +1,120 @@ +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://` — direct repository address. + Addr(RepoAddr), + /// `nostr:///[relay-hint/]` + UserRepo { + /// `npub1...` or a NIP-05 identifier. + user: String, + relay_hint: Option, + /// `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 { + 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) => (Some(percent_decode(second)), 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: Some("relay.ngit.dev".to_owned()), + 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: Some("ws://localhost:7334".to_owned()), + identifier: "my-local-only-repo".to_owned(), + } + ); + } +} diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs new file mode 100644 index 0000000..f275343 --- /dev/null +++ b/crates/signed_core/src/filters.rs @@ -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) +} diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs new file mode 100644 index 0000000..f4958fd --- /dev/null +++ b/crates/signed_core/src/lib.rs @@ -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}; diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs new file mode 100644 index 0000000..199f09a --- /dev/null +++ b/crates/signed_core/src/model.rs @@ -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, + pub description: Option, + /// Webpage URLs for browsing. + pub web: Vec, + /// URLs for `git clone`. + pub clone: Vec, + /// Relays the repository monitors for patches and issues. + pub relays: Vec, + /// Earliest unique commit ID (`r` tag with `euc` marker). + pub euc: Option, + /// Other recognized maintainers. + pub maintainers: Vec, +} + +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 { + if event.kind != Kind::GitRepoAnnouncement { + return None; + } + + let mut id: Option = None; + let mut name: Option = None; + let mut description: Option = None; + let mut web: Vec = Vec::new(); + let mut clone: Vec = Vec::new(); + let mut relays: Vec = Vec::new(); + let mut euc: Option = None; + let mut maintainers: Vec = 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()) + } +} diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs new file mode 100644 index 0000000..5c159e3 --- /dev/null +++ b/crates/signed_core/src/status.rs @@ -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 { + 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, +{ + 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) +} diff --git a/crates/signed_git/Cargo.toml b/crates/signed_git/Cargo.toml new file mode 100644 index 0000000..5a1c760 --- /dev/null +++ b/crates/signed_git/Cargo.toml @@ -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 +git2.workspace = true +anyhow.workspace = true diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs new file mode 100644 index 0000000..f3510b6 --- /dev/null +++ b/crates/signed_git/src/lib.rs @@ -0,0 +1,118 @@ +//! 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 git2::Repository; +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> { + let path = self.repo_path(addr); + match Repository::open(&path) { + Ok(repo) => Ok(Some(repo)), + Err(e) if e.code() == git2::ErrorCode::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 { + 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 = None; + for url in clone_urls { + match Repository::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 heads from `origin`. +pub fn fetch_all(repo: &Repository) -> Result<()> { + let mut remote = repo.find_remote("origin")?; + remote.fetch( + &["+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*"], + None, + None, + )?; + 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 sanitize_path_component(id: &str) -> String { + id.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect() +} diff --git a/crates/signed_nostr/Cargo.toml b/crates/signed_nostr/Cargo.toml new file mode 100644 index 0000000..9d321c5 --- /dev/null +++ b/crates/signed_nostr/Cargo.toml @@ -0,0 +1,23 @@ +[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 + +flume.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 diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs new file mode 100644 index 0000000..7a4c38b --- /dev/null +++ b/crates/signed_nostr/src/backend.rs @@ -0,0 +1,115 @@ +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use nostr_gossip_memory::prelude::*; +use nostr_sdk::prelude::*; + +#[cfg(not(target_arch = "wasm32"))] +use nostr_lmdb::prelude::*; +#[cfg(target_arch = "wasm32")] +use nostr_memory::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) -> Result { + 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 { + let signer = UniversalSigner::new(Keys::generate()); + Ok(Self::with_database(signer, MemoryDatabase::unbounded())) + } + + fn with_database(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)) + .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(()) + } + + 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 { + 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> { + 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 { + 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::>() + .join(", "); + return Err(anyhow!("event not accepted by any relay: {reasons}")); + } + + Ok(event) + } +} diff --git a/crates/signed_nostr/src/lib.rs b/crates/signed_nostr/src/lib.rs new file mode 100644 index 0000000..27e6cee --- /dev/null +++ b/crates/signed_nostr/src/lib.rs @@ -0,0 +1,7 @@ +mod backend; +pub mod pump; +mod signer; + +pub use backend::NostrBackend; +pub use pump::Update; +pub use signer::{SignedAuthUrlHandler, UniversalSigner}; diff --git a/crates/signed_nostr/src/pump.rs b/crates/signed_nostr/src/pump.rs new file mode 100644 index 0000000..3e5cf3b --- /dev/null +++ b/crates/signed_nostr/src/pump.rs @@ -0,0 +1,46 @@ +use flume::Sender; +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, + pub author: PublicKey, + pub event_id: EventId, +} + +/// Consume the client notification stream and forward [`Update`]s into `tx` +/// until the receiving end is dropped or the client shuts down. +/// +/// Spawn this once, e.g. inside `cx.background_spawn`. +pub async fn run(client: Client, tx: Sender) { + let mut notifications = client.notifications(); + + while let Some(notification) = notifications.next().await { + let ClientNotification::Event { event, .. } = notification else { + continue; + }; + + let coordinate = event + .tags + .iter() + .find(|t| t.kind() == "a") + .and_then(|t| t.content()) + .map(str::to_owned); + + let update = Update { + kind: event.kind, + coordinate, + author: event.pubkey, + event_id: event.id, + }; + + if tx.send_async(update).await.is_err() { + break; + } + } +} diff --git a/crates/signed_nostr/src/signer.rs b/crates/signed_nostr/src/signer.rs new file mode 100644 index 0000000..9f676d2 --- /dev/null +++ b/crates/signed_nostr/src/signer.rs @@ -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); + +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(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>>, +} + +impl UniversalSigner { + pub fn new(signer: T) -> Self + where + T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, + ::Error: Error + Send + Sync + 'static, + ::Error: Error + Send + Sync + 'static, + ::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(&self, new_signer: T) + where + T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, + ::Error: Error + Send + Sync + 'static, + ::Error: Error + Send + Sync + 'static, + ::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> + Send + '_>>; + fn sign_event_async( + &self, + unsigned: UnsignedEvent, + ) -> Pin> + Send + '_>>; + fn nip44_encrypt_async<'a>( + &'a self, + public_key: &'a PublicKey, + content: &'a str, + ) -> Pin> + Send + 'a>>; + fn nip44_decrypt_async<'a>( + &'a self, + public_key: &'a PublicKey, + payload: &'a str, + ) -> Pin> + Send + 'a>>; +} + +#[derive(Debug)] +struct InnerSignerImpl(T); + +impl InnerSigner for InnerSignerImpl +where + T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static, + ::Error: Error + Send + Sync + 'static, + ::Error: Error + Send + Sync + 'static, + ::Error: Error + Send + Sync + 'static, +{ + fn get_public_key_async( + &self, + ) -> Pin> + 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> + 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> + 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> + 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> + 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> + 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> + 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> + 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> + Send + '_>> { + Box::pin(async move { + webbrowser::open(auth_url.as_str()).unwrap(); + Ok(()) + }) + } +} diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml new file mode 100644 index 0000000..31a03ee --- /dev/null +++ b/crates/signed_state/Cargo.toml @@ -0,0 +1,19 @@ +[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 + +gpui.workspace = true +flume.workspace = true +anyhow.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rustls = "0.23" diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs new file mode 100644 index 0000000..cdb4c15 --- /dev/null +++ b/crates/signed_state/src/backend.rs @@ -0,0 +1,195 @@ +use anyhow::Error; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; +use nostr_sdk::prelude::*; +use signed_nostr::{NostrBackend, UniversalSigner, pump::Update}; + +#[derive(Debug, Clone)] +pub enum BackendEvent { + /// User has no signer configured. + NoSigner, + /// 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), + /// An error occurred. + Error(String), +} + +impl BackendEvent { + pub fn error(error: T) -> Self + where + T: Into, + { + 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, + tasks: Vec>>, +} + +struct GlobalBackend(Entity); + +impl Global for GlobalBackend {} + +impl EventEmitter for Backend {} + +impl Backend { + /// Retrieve the global backend. + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub(crate) fn set_global(entity: Entity, cx: &mut App) { + cx.set_global(GlobalBackend(entity)); + } + + pub(crate) fn new(inner: NostrBackend, cx: &mut Context) -> Self { + // Pump: relays -> LMDB (automatic) -> flume -> BackendEvent::NostrUpdate. + let (tx, rx) = flume::bounded::(4096); + let client = inner.client(); + + let pump = cx.background_spawn(async move { + signed_nostr::pump::run(client, tx).await; + Ok(()) + }); + + let forward = cx.spawn(async move |this, cx| { + while let Ok(update) = rx.recv_async().await { + this.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(update)))?; + } + Ok(()) + }); + + Self { + inner, + current_user: None, + tasks: vec![pump, forward], + } + } + + /// 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 { + 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(&mut self, new_signer: T, cx: &mut Context) + where + T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, + ::Error: std::error::Error + Send + Sync + 'static, + ::Error: std::error::Error + Send + Sync + 'static, + ::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); + 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, cx: &mut Context) { + 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(()) + })); + } + + /// 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) { + 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. + pub fn send(&mut self, builder: EventBuilder, cx: &mut Context) { + 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| { + match task.await { + Ok(event) => { + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::Published(Box::new(event))); + })?; + } + Err(e) => { + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::error(e.to_string())) + })?; + } + } + Ok(()) + })); + } +} diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs new file mode 100644 index 0000000..ac4bc56 --- /dev/null +++ b/crates/signed_state/src/lib.rs @@ -0,0 +1,42 @@ +mod backend; +mod repo; +mod repo_list; + +pub use backend::{Backend, BackendEvent}; +pub use repo::RepoStore; +pub use repo_list::RepoListStore; + +use std::path::Path; + +use gpui::{App, AppContext, Entity}; +use signed_nostr::NostrBackend; + +/// Initialize the backend and install it as a global. Call once at startup, +/// before opening any window that uses the stores. +#[cfg(not(target_arch = "wasm32"))] +pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { + // 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); + entity +} + +/// Initialize the backend with an in-memory database on wasm. +#[cfg(target_arch = "wasm32")] +pub fn init(cx: &mut App) -> Entity { + 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); + entity +} diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs new file mode 100644 index 0000000..ed9efb8 --- /dev/null +++ b/crates/signed_state/src/repo.rs @@ -0,0 +1,233 @@ +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, + /// `(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, + pub issues: Vec, + pub patches: Vec, + pub pull_requests: Vec, + statuses: Vec, + + _subscription: Subscription, + tasks: Vec>>, +} + +impl RepoStore { + pub fn new(addr: RepoAddr, cx: &mut Context) -> 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(), + _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) { + 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. + pub fn refresh(&mut self, cx: &mut Context) { + let client = Backend::global(cx).read(cx).client(); + let addr = self.addr.clone(); + + let task = cx.spawn(async move |this, cx| { + 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?; + + 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(); + })?; + + 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, content: String, cx: &mut Context) { + 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) { + 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) { + 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(&self, builder: EventBuilder, cx: &mut Context) { + Backend::global(cx).update(cx, |backend, cx| backend.send(builder, cx)); + } +} + +fn latest(events: Events) -> Option { + 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) { + 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) +} diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs new file mode 100644 index 0000000..fe8ef9b --- /dev/null +++ b/crates/signed_state/src/repo_list.rs @@ -0,0 +1,114 @@ +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, + author: Option, + + _subscription: Subscription, + tasks: Vec>>, +} + +impl RepoListStore { + /// Create a store. If `author` is `None`, all announcements are listed. + pub fn new(author: Option, cx: &mut Context) -> 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, + _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, cx: &mut Context) { + self.author = author; + self.subscribe_remote(cx); + self.refresh(cx); + } + + fn subscribe_remote(&mut self, cx: &mut Context) { + 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. + pub fn refresh(&mut self, cx: &mut Context) { + let client = Backend::global(cx).read(cx).client(); + let author = self.author; + + let task = cx.spawn(async move |this, cx| { + let filter = match author { + Some(a) => filters::announcements_by(a), + None => filters::all_announcements(500), + }; + + let events = client.database().query(filter).await?; + + 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 = by_repo.into_values().collect(); + announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at)); + + this.announcements = announcements; + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } +} -- 2.54.0 From 8707e71b53520965531c2fd56086cc2afa6a0b6c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 4 Aug 2026 15:22:18 +0700 Subject: [PATCH 2/5] clean up --- Cargo.lock | 1 - crates/signed_nostr/Cargo.toml | 1 - crates/signed_nostr/src/lib.rs | 4 +- .../signed_nostr/src/{pump.rs => update.rs} | 22 +-- crates/signed_state/src/backend.rs | 73 +++++---- crates/signed_state/src/repo.rs | 151 ++++++++++++------ crates/signed_state/src/repo_list.rs | 83 +++++++--- 7 files changed, 214 insertions(+), 121 deletions(-) rename crates/signed_nostr/src/{pump.rs => update.rs} (53%) diff --git a/Cargo.lock b/Cargo.lock index 4428f5e..763b2d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6815,7 +6815,6 @@ name = "signed_nostr" version = "1.0.0" dependencies = [ "anyhow", - "flume 0.11.1", "nostr", "nostr-connect", "nostr-gossip-memory", diff --git a/crates/signed_nostr/Cargo.toml b/crates/signed_nostr/Cargo.toml index 9d321c5..4ee5f32 100644 --- a/crates/signed_nostr/Cargo.toml +++ b/crates/signed_nostr/Cargo.toml @@ -12,7 +12,6 @@ nostr-sdk.workspace = true nostr-connect.workspace = true nostr-gossip-memory.workspace = true -flume.workspace = true anyhow.workspace = true webbrowser.workspace = true diff --git a/crates/signed_nostr/src/lib.rs b/crates/signed_nostr/src/lib.rs index 27e6cee..1328bf4 100644 --- a/crates/signed_nostr/src/lib.rs +++ b/crates/signed_nostr/src/lib.rs @@ -1,7 +1,7 @@ mod backend; -pub mod pump; mod signer; +mod update; pub use backend::NostrBackend; -pub use pump::Update; pub use signer::{SignedAuthUrlHandler, UniversalSigner}; +pub use update::Update; diff --git a/crates/signed_nostr/src/pump.rs b/crates/signed_nostr/src/update.rs similarity index 53% rename from crates/signed_nostr/src/pump.rs rename to crates/signed_nostr/src/update.rs index 3e5cf3b..1f2222f 100644 --- a/crates/signed_nostr/src/pump.rs +++ b/crates/signed_nostr/src/update.rs @@ -1,4 +1,3 @@ -use flume::Sender; use nostr_sdk::prelude::*; /// A lightweight "something changed" signal for the UI. @@ -13,18 +12,9 @@ pub struct Update { pub event_id: EventId, } -/// Consume the client notification stream and forward [`Update`]s into `tx` -/// until the receiving end is dropped or the client shuts down. -/// -/// Spawn this once, e.g. inside `cx.background_spawn`. -pub async fn run(client: Client, tx: Sender) { - let mut notifications = client.notifications(); - - while let Some(notification) = notifications.next().await { - let ClientNotification::Event { event, .. } = notification else { - continue; - }; - +impl Update { + /// Build an update from a received event. + pub fn from_event(event: &Event) -> Self { let coordinate = event .tags .iter() @@ -32,15 +22,11 @@ pub async fn run(client: Client, tx: Sender) { .and_then(|t| t.content()) .map(str::to_owned); - let update = Update { + Self { kind: event.kind, coordinate, author: event.pubkey, event_id: event.id, - }; - - if tx.send_async(update).await.is_err() { - break; } } } diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index cdb4c15..e9f2a79 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1,7 +1,7 @@ -use anyhow::Error; +use anyhow::{Error, anyhow}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; use nostr_sdk::prelude::*; -use signed_nostr::{NostrBackend, UniversalSigner, pump::Update}; +use signed_nostr::{NostrBackend, UniversalSigner, Update}; #[derive(Debug, Clone)] pub enum BackendEvent { @@ -54,26 +54,33 @@ impl Backend { } pub(crate) fn new(inner: NostrBackend, cx: &mut Context) -> Self { - // Pump: relays -> LMDB (automatic) -> flume -> BackendEvent::NostrUpdate. - let (tx, rx) = flume::bounded::(4096); let client = inner.client(); - let pump = cx.background_spawn(async move { - signed_nostr::pump::run(client, tx).await; - Ok(()) - }); + let pump = cx.spawn(async move |this, cx| { + let mut notifications = client.notifications(); - let forward = cx.spawn(async move |this, cx| { - while let Ok(update) = rx.recv_async().await { - this.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(update)))?; + 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(()) }); Self { inner, current_user: None, - tasks: vec![pump, forward], + tasks: vec![pump], } } @@ -141,9 +148,7 @@ impl Backend { this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?; } Err(e) => { - this.update(cx, |_this, cx| { - cx.emit(BackendEvent::error(e.to_string())) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } } Ok(()) @@ -155,15 +160,11 @@ impl Backend { pub fn subscribe(&mut self, filter: Filter, cx: &mut Context) { let backend = self.inner.clone(); - let task = cx.background_spawn(async move { - backend.subscribe(filter).await.map(|_| ()) - }); + 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())) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) })); @@ -171,25 +172,39 @@ impl Backend { /// Sign, broadcast and locally store an event. Emits /// [`BackendEvent::Published`] on success so stores can refresh. - pub fn send(&mut self, builder: EventBuilder, cx: &mut Context) { - let backend = self.inner.clone(); + /// + /// 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, + ) -> flume::Receiver> { + 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| { - match task.await { + let result = task.await; + + match &result { Ok(event) => { this.update(cx, |_this, cx| { - cx.emit(BackendEvent::Published(Box::new(event))); + cx.emit(BackendEvent::Published(Box::new(event.clone()))); })?; } Err(e) => { - this.update(cx, |_this, cx| { - cx.emit(BackendEvent::error(e.to_string())) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } } - Ok(()) + + tx.send_async(result) + .await + .map_err(|_| anyhow!("action result receiver dropped")) })); + + rx } } diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index ed9efb8..e288145 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -10,7 +10,6 @@ use crate::backend::{Backend, BackendEvent}; pub struct RepoStore { addr: RepoAddr, addr_string: String, - pub announcement: Option, /// `(refname, commit-id)` pairs from the latest state announcement. pub refs: Vec<(String, String)>, @@ -20,9 +19,12 @@ pub struct RepoStore { pub patches: Vec, pub pull_requests: Vec, statuses: Vec, - - _subscription: Subscription, + /// Error of the last action initiated from this store, if any. + pub last_error: Option, + refreshing: bool, + refresh_dirty: bool, tasks: Vec>>, + _subscription: Subscription, } impl RepoStore { @@ -37,8 +39,7 @@ impl RepoStore { && update.author == this.addr.owner) } BackendEvent::Published(event) => { - event.kind == Kind::GitRepoAnnouncement - && event.pubkey == this.addr.owner + 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()) }) @@ -61,6 +62,9 @@ impl RepoStore { patches: Vec::new(), pull_requests: Vec::new(), statuses: Vec::new(), + last_error: None, + refreshing: false, + refresh_dirty: false, _subscription: subscription, tasks: Vec::new(), }; @@ -86,49 +90,92 @@ impl RepoStore { } /// 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) { + 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| { - let db = client.database(); + 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?; + let announcements = db.query(filters::announcement(&addr)).await?; + let states = db.query(filters::state(&addr)).await?; + let activity = db.query(filters::activity(&addr)).await?; - 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; + Ok::<_, Error>((announcements, states, activity)) } + .await; - 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), - _ => {} + 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; } - - sort_newest_first(&mut this.issues); - sort_newest_first(&mut this.patches); - sort_newest_first(&mut this.pull_requests); - - cx.notify(); - })?; + } Ok(()) }); @@ -171,12 +218,11 @@ impl RepoStore { return; }; - let builder = EventBuilder::new(Kind::GitPatch, patch) - .tags([ - Tag::coordinate(self.addr.coordinate(), None), - Tag::public_key(self.addr.owner), - root_marker, - ]); + 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); } @@ -197,8 +243,23 @@ impl RepoStore { self.send(builder, cx); } - fn send(&self, builder: EventBuilder, cx: &mut Context) { - Backend::global(cx).update(cx, |backend, cx| backend.send(builder, cx)); + fn send(&mut self, builder: EventBuilder, cx: &mut Context) { + 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); } } diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index fe8ef9b..7d88dfd 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -11,9 +11,10 @@ use crate::backend::{Backend, BackendEvent}; pub struct RepoListStore { pub announcements: Vec, author: Option, - - _subscription: Subscription, + refreshing: bool, + refresh_dirty: bool, tasks: Vec>>, + _subscription: Subscription, } impl RepoListStore { @@ -40,6 +41,8 @@ impl RepoListStore { let mut store = Self { announcements: Vec::new(), author, + refreshing: false, + refresh_dirty: false, _subscription: subscription, tasks: Vec::new(), }; @@ -69,42 +72,72 @@ impl RepoListStore { } /// 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) { + 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| { - let filter = match author { - Some(a) => filters::announcements_by(a), - None => filters::all_announcements(500), - }; + loop { + let filter = match author { + Some(a) => filters::announcements_by(a), + None => filters::all_announcements(500), + }; - let events = client.database().query(filter).await?; + let events = match client.database().query(filter).await { + Ok(events) => events, + Err(_) => { + return this.update(cx, |this, _cx| { + this.refreshing = false; + }); + } + }; - this.update(cx, |this, cx| { - let mut by_repo: HashMap<(String, String), Announcement> = HashMap::new(); + 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; - }; + for event in events { + let Some(announcement) = Announcement::from_event(&event) else { + continue; + }; - let key = (announcement.owner.to_hex(), announcement.id.clone()); + 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); + match by_repo.get(&key) { + Some(existing) if existing.created_at >= announcement.created_at => {} + _ => { + by_repo.insert(key, announcement); + } } } + + let mut announcements: Vec = 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; } - - let mut announcements: Vec = by_repo.into_values().collect(); - announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at)); - - this.announcements = announcements; - cx.notify(); - })?; + } Ok(()) }); -- 2.54.0 From ebdb07f6527bd8f70c549cfe65f53fcb92a2eca9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 4 Aug 2026 17:36:49 +0700 Subject: [PATCH 3/5] migrate to gix --- Cargo.lock | 1126 +++++++++++++++++++++++++++++++--- Cargo.toml | 2 +- crates/signed_git/Cargo.toml | 2 +- crates/signed_git/src/lib.rs | 41 +- 4 files changed, 1079 insertions(+), 92 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 763b2d9..823e5a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -695,6 +695,21 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bisync" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7" +dependencies = [ + "bisync_macros", +] + +[[package]] +name = "bisync_macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" + [[package]] name = "bit-set" version = "0.9.1" @@ -858,6 +873,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", + "regex-automata", "serde_core", ] @@ -1108,6 +1124,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "cmake" version = "0.1.58" @@ -1478,6 +1503,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -1568,6 +1602,37 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.19", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2406,18 +2471,799 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] -name = "git2" -version = "0.20.4" +name = "gix" +version = "0.86.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce" +dependencies = [ + "gix-actor", + "gix-attributes", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-credentials", + "gix-date", + "gix-diff", + "gix-discover", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-negotiate", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-pathspec", + "gix-prompt", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-transport", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-state", + "gix-worktree-stream", + "gix-zlib", + "nonempty", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-actor" +version = "0.41.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-attributes" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31c593692ebdc1e38858d9a2b56f6a594c501e24a38971fe6685571f5a07be0" +dependencies = [ + "bstr", + "gix-features", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "smallvec", + "thiserror 2.0.19", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-chunk" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4363accdf6ef7ba861871d2d521ab7418a04aaaed919fadb022af71d379b12" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103d11bef95c467577ecfa8b7b86a22e65af3507b2c9bfa3809a4afbae7df301" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "gix-utils", + "smallvec", + "thiserror 2.0.19", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6af5321bfd3711a279d6b244d58532ba1cfabf9eb6374791f19929d8970082" dependencies = [ "bitflags 2.13.1", + "bstr", + "gix-path", "libc", - "libgit2-sys", - "log", - "openssl-probe 0.1.6", - "openssl-sys", - "url", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-credentials" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbdb1c417980d05d547c19fc2b63344b5257c2387048d69ffe957bda142b59" +dependencies = [ + "bstr", + "gix-command", + "gix-config-value", + "gix-date", + "gix-path", + "gix-prompt", + "gix-quote", + "gix-sec", + "gix-trace", + "gix-url", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-date" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc" +dependencies = [ + "bstr", + "gix-hash", + "gix-object", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-discover" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f517766fa1101dfe2606c1a19a8ffa699099030995a9194445446dfe261bdf" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-error" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20aa09e83a48dc02c5f5f08578aa79d3ab1bab4618b8c362f88684645a02bdcc" +dependencies = [ + "bytes", + "crc32fast", + "crossbeam-channel", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "parking_lot", + "prodash", + "walkdir", +] + +[[package]] +name = "gix-filter" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7b5dbf524d97e839f642930c76d7f011c0791e7d11d8148989ac5af7c76aa8" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-fs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865cf13fcaf5455220546cb9607c416bd1be9a6caafd143655a362fdeab64e80" +dependencies = [ + "bstr", + "gix-features", + "gix-path", + "gix-utils", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-glob" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421e92a711554fa5827d1b0599d3389acdd0f6729e97a8c5a57d79af1e50bf36" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13adaa73415fd6c902310923f68d0b98e8cecf14b33ea58c02cc387cee56f54e" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-hashtable" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78fccd6fea3bcf0b39c076bae60ae49b08daaf538b950202101a981f9d3c01d3" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cff8e8aa125e39377456073e63df3334d9e5741372ddcc226198015076dda2" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-index" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5009c4e7e9f9b4cfaaab1153e49133eb04d79c015b5702d6c3d2ab94271a89c6" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix 1.1.4", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-lock" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c69157820343bf1c6e4b88b9808e920900de02e18aaf5862b30ada43814848" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-negotiate" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fa5aa789990f124e4f559b142cecfb60662cb4ae17006684ea9d668f4f07689" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-object", + "gix-revwalk", +] + +[[package]] +name = "gix-object" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-odb" +version = "0.83.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dd494ffb5037e62b8220109e894d2861ff2150a2cacbfccdba57ae1ebab2b96" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "gix-zlib", + "memmap2", + "parking_lot", + "tempfile", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-pack" +version = "0.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-zlib", + "memmap2", + "parking_lot", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-packetline" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-path" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "751d6bd162106f8c1e7e9aaccb5bbdd605267e91a930a17a4560c46e33a9100c" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-pathspec" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f6fa5f8007f008187c3f60b4373209ca83d1cc947f35ede03e16cd15a4d137" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-prompt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cb1f1eb92d6f4c9d4c105a6ca912cff637fbd5cacbcbafa5deccd88bfaa3565" +dependencies = [ + "gix-command", + "gix-config-value", + "parking_lot", + "rustix 1.1.4", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-protocol" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588" +dependencies = [ + "bisync", + "bstr", + "gix-credentials", + "gix-date", + "gix-features", + "gix-hash", + "gix-lock", + "gix-negotiate", + "gix-object", + "gix-ref", + "gix-refspec", + "gix-revwalk", + "gix-shallow", + "gix-trace", + "gix-transport", + "gix-utils", + "nonempty", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-refspec" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-revision" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" +dependencies = [ + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-sec" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" +dependencies = [ + "bitflags 2.13.1", + "gix-path", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-shallow" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecc9f4b40537043e4bbd7d3d1760e74fb8e7b07a546166b558acaa73ad97f4a" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-submodule" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd98077a56d08886112e6b08dc94076d03539f4bc0b9d7880e4be2b8a640d8c" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-tempfile" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e" +dependencies = [ + "gix-fs", + "libc", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" + +[[package]] +name = "gix-transport" +version = "0.58.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc" +dependencies = [ + "base64", + "bstr", + "gix-command", + "gix-credentials", + "gix-features", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-sec", + "gix-url", + "parking_lot", + "reqwest", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-traverse" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-url" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31bdfc93aa880cda3272718a5879ce3aa7723fa13514320dd6608151607afe72" +dependencies = [ + "bstr", + "gix-path", + "gix-utils", + "percent-encoding", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-utils" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" +dependencies = [ + "bstr", + "fastrand", + "getrandom 0.4.3", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-worktree" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31eb8e675122e83585e461fe28f68ff8c5ed55b49017b697e7e76423ff973424" +dependencies = [ + "bstr", + "gix-attributes", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] +name = "gix-worktree-state" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc47372fc12b9fbea51b257bcc8d4970326cf5c7e42135bbfb5d72871bb30bd4" +dependencies = [ + "bstr", + "gix-features", + "gix-filter", + "gix-fs", + "gix-index", + "gix-object", + "gix-path", + "gix-worktree", + "io-close", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", +] + +[[package]] +name = "gix-zlib" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e8813f5579b3075ff9c90f7c59cd2b62b4ebb639361f0911648b22d7446cc7c" +dependencies = [ + "thiserror 2.0.19", + "zlib-rs", ] [[package]] @@ -3008,6 +3854,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heapless" @@ -3220,7 +4071,7 @@ version = "0.1.0" source = "git+https://github.com/zed-industries/zed#35cb7558a9d9a6f2eaf31ce2e4dce4a0575820ef" dependencies = [ "rustls", - "rustls-platform-verifier", + "rustls-platform-verifier 0.5.3", ] [[package]] @@ -3281,13 +4132,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -3572,6 +4426,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "io-surface" version = "0.16.1" @@ -3642,6 +4506,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -3846,20 +4763,6 @@ dependencies = [ "cc", ] -[[package]] -name = "libgit2-sys" -version = "0.18.7+1.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" -dependencies = [ - "cc", - "libc", - "libssh2-sys", - "libz-sys", - "openssl-sys", - "pkg-config", -] - [[package]] name = "libloading" version = "0.8.9" @@ -3885,32 +4788,6 @@ dependencies = [ "libc", ] -[[package]] -name = "libssh2-sys" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "libz-sys" -version = "1.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -4358,6 +5235,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -5071,30 +5954,12 @@ dependencies = [ "libc", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -5560,6 +6425,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "parking_lot", +] + [[package]] name = "profiling" version = "1.0.18" @@ -5676,6 +6550,7 @@ version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", @@ -6032,6 +6907,46 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier 0.7.0", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest_client" version = "0.1.0" @@ -6290,7 +7205,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", "security-framework", @@ -6336,6 +7251,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 1.0.9", + "windows-sys 0.61.2", +] + [[package]] name = "rustls-platform-verifier-android" version = "0.1.1" @@ -6710,6 +7646,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -6747,6 +7693,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shellexpand" version = "3.1.2" @@ -6805,7 +7757,7 @@ name = "signed_git" version = "1.0.0" dependencies = [ "anyhow", - "git2", + "gix", "nostr", "signed_core", ] @@ -7742,6 +8694,24 @@ dependencies = [ "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -7898,6 +8868,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + [[package]] name = "unicode-ccc" version = "0.4.0" @@ -8144,12 +9120,6 @@ dependencies = [ "sval_serde", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -9811,6 +10781,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zlog" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4bb6f98..0b8f27f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1 nostr-connect = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } -git2 = { version = "0.20", features = ["vendored-libgit2"] } +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"] } smol = "2" diff --git a/crates/signed_git/Cargo.toml b/crates/signed_git/Cargo.toml index 5a1c760..bdfb0c0 100644 --- a/crates/signed_git/Cargo.toml +++ b/crates/signed_git/Cargo.toml @@ -8,5 +8,5 @@ publish.workspace = true signed_core = { path = "../signed_core" } nostr.workspace = true -git2.workspace = true +gix.workspace = true anyhow.workspace = true diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index f3510b6..0d5bb29 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -7,7 +7,8 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; -use git2::Repository; +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. @@ -29,18 +30,19 @@ impl GitCache { } /// Open an existing clone. - pub fn open(&self, addr: &RepoAddr) -> Result> { + pub fn open(&self, addr: &RepoAddr) -> Result> { let path = self.repo_path(addr); - match Repository::open(&path) { + match gix::open(&path) { Ok(repo) => Ok(Some(repo)), - Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), + 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 { + pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> Result { let path = self.repo_path(addr); if let Some(repo) = self.open(addr)? { @@ -53,9 +55,10 @@ impl GitCache { .with_context(|| format!("failed to create {}", parent.display()))?; } - let mut last_err: Option = None; + let mut last_err: Option = None; + for url in clone_urls { - match Repository::clone(url, &path) { + match clone(url, &path) { Ok(repo) => return Ok(repo), Err(e) => last_err = Some(e), } @@ -68,14 +71,12 @@ impl GitCache { } } -/// Fetch all heads from `origin`. -pub fn fetch_all(repo: &Repository) -> Result<()> { - let mut remote = repo.find_remote("origin")?; - remote.fetch( - &["+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*"], - None, - None, - )?; +/// 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(()) } @@ -105,6 +106,16 @@ pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> { Ok(()) } +fn clone(url: &str, path: &Path) -> Result { + 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| { -- 2.54.0 From 7300c5d1fd1c7902066a419e3a5ffa3b1b164e93 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 4 Aug 2026 18:53:18 +0700 Subject: [PATCH 4/5] add bootstrap flow --- Cargo.lock | 1 + crates/signed_core/src/clone_url.rs | 19 +- crates/signed_nostr/src/backend.rs | 15 +- crates/signed_state/Cargo.toml | 1 + crates/signed_state/src/backend.rs | 300 +++++++++++++++++++++++++++- 5 files changed, 321 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 823e5a2..dee9af4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7785,6 +7785,7 @@ dependencies = [ "flume 0.11.1", "gpui", "nostr", + "nostr-connect", "nostr-sdk", "rustls", "signed_core", diff --git a/crates/signed_core/src/clone_url.rs b/crates/signed_core/src/clone_url.rs index 5f3f6ec..19772cc 100644 --- a/crates/signed_core/src/clone_url.rs +++ b/crates/signed_core/src/clone_url.rs @@ -11,7 +11,7 @@ pub enum CloneTarget { UserRepo { /// `npub1...` or a NIP-05 identifier. user: String, - relay_hint: Option, + relay_hint: Option, /// `d` tag identifier of the repository. identifier: String, }, @@ -35,7 +35,10 @@ pub fn parse_clone_url(url: &str) -> Option { } let (relay_hint, identifier) = match third { - Some(id) => (Some(percent_decode(second)), percent_decode(id)), + Some(id) => ( + RelayUrl::parse(&percent_decode(second)).ok(), + percent_decode(id), + ), None => (None, percent_decode(second)), }; @@ -78,8 +81,7 @@ mod tests { assert_eq!( target, CloneTarget::UserRepo { - user: "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr" - .to_owned(), + user: "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr".to_owned(), relay_hint: None, identifier: "ngit".to_owned(), } @@ -88,15 +90,12 @@ mod tests { #[test] fn parses_user_repo_with_relay_hint() { - let target = parse_clone_url( - "nostr://danconwaydev.com/relay.ngit.dev/ngit", - ) - .unwrap(); + 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: Some("relay.ngit.dev".to_owned()), + relay_hint: RelayUrl::parse("relay.ngit.dev").ok(), identifier: "ngit".to_owned(), } ); @@ -112,7 +111,7 @@ mod tests { target, CloneTarget::UserRepo { user: "danconwaydev.com".to_owned(), - relay_hint: Some("ws://localhost:7334".to_owned()), + relay_hint: RelayUrl::parse("ws://localhost:7334").ok(), identifier: "my-local-only-repo".to_owned(), } ); diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs index 7a4c38b..1a11220 100644 --- a/crates/signed_nostr/src/backend.rs +++ b/crates/signed_nostr/src/backend.rs @@ -2,12 +2,11 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow}; use nostr_gossip_memory::prelude::*; -use nostr_sdk::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; @@ -51,6 +50,8 @@ impl NostrBackend { .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), }) @@ -72,6 +73,16 @@ impl NostrBackend { 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; } diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index 31a03ee..aec9e9c 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -10,6 +10,7 @@ signed_nostr = { path = "../signed_nostr" } nostr.workspace = true nostr-sdk.workspace = true +nostr-connect.workspace = true gpui.workspace = true flume.workspace = true diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index e9f2a79..668063a 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1,12 +1,38 @@ +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_nostr::{NostrBackend, UniversalSigner, Update}; +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. - NoSigner, + SignerRequired, /// The signer has changed (login/logout/account switch). SignerChanged, /// Relay bootstrap finished. @@ -77,11 +103,257 @@ impl Backend { Ok(()) }); - Self { + 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) { + 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) { + 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) { + 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) { + 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) { + 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 { + 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) { + 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 = 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. @@ -114,6 +386,7 @@ impl Backend { 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(); })?; @@ -155,6 +428,27 @@ 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. + pub fn add_discovery_relays(&mut self, urls: Vec, cx: &mut Context) { + 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) { -- 2.54.0 From a785b9eea4713dafd1f645f5ec842f92c6c2c368 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 5 Aug 2026 07:54:44 +0700 Subject: [PATCH 5/5] add profile store --- Cargo.lock | 1 + crates/signed_state/Cargo.toml | 1 + crates/signed_state/src/lib.rs | 20 ++- crates/signed_state/src/profile.rs | 225 +++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 crates/signed_state/src/profile.rs diff --git a/Cargo.lock b/Cargo.lock index dee9af4..fe4b891 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7784,6 +7784,7 @@ dependencies = [ "anyhow", "flume 0.11.1", "gpui", + "log", "nostr", "nostr-connect", "nostr-sdk", diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index aec9e9c..cea9b9a 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -15,6 +15,7 @@ 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" diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index ac4bc56..25fe3ec 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -1,18 +1,19 @@ mod backend; +mod profile; mod repo; mod repo_list; -pub use backend::{Backend, BackendEvent}; -pub use repo::RepoStore; -pub use repo_list::RepoListStore; - 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 install it as a global. Call once at startup, -/// before opening any window that uses the stores. +/// 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, cx: &mut App) -> Entity { // rustls uses the `aws_lc_rs` provider by default; ignore if already installed. @@ -29,6 +30,9 @@ pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { let entity = cx.new(|cx| Backend::new(inner, cx)); Backend::set_global(entity.clone(), cx); + + ProfileStore::set_global(cx.new(ProfileStore::new), cx); + entity } @@ -36,7 +40,11 @@ pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { #[cfg(target_arch = "wasm32")] pub fn init(cx: &mut App) -> Entity { 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 } diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs new file mode 100644 index 0000000..b72f62c --- /dev/null +++ b/crates/signed_state/src/profile.rs @@ -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 { + 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, + /// Public keys we've already requested this session. + seen: HashSet, + /// Public keys queued for the next batched fetch. + queued: HashSet, + fetching: bool, + tasks: Vec>>, + _subscription: Subscription, +} + +struct GlobalProfileStore(Entity); + +impl Global for GlobalProfileStore {} + +impl ProfileStore { + /// Retrieve the global profile store. + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub(crate) fn set_global(entity: Entity, cx: &mut App) { + cx.set_global(GlobalProfileStore(entity)); + } + + pub(crate) fn new(cx: &mut Context) -> 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) -> 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) { + 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) { + 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) { + 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::>()); + + // 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); + } +} -- 2.54.0