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); + } +}