2 Commits
Author SHA1 Message Date
reya e01d2fbef3 render reaction 2026-07-31 09:27:19 +07:00
reya c3d677ca81 add send reaction 2026-07-31 08:45:56 +07:00
52 changed files with 892 additions and 2081 deletions
Generated
+77 -184
View File
@@ -202,11 +202,11 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "ar_archive_writer"
version = "0.5.3"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348"
dependencies = [
"object 0.39.1",
"object",
]
[[package]]
@@ -493,12 +493,6 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
[[package]]
name = "atomic-destructor"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d919cb60ba95c87ba42777e9e246c4e8d658057299b437b7512531ce0a09a23"
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -546,10 +540,17 @@ dependencies = [
name = "auto_update"
version = "1.0.0-beta5"
dependencies = [
"anyhow",
"common",
"gpui",
"gpui-updater",
"instant",
"log",
"semver",
"serde",
"serde_json",
"smallvec",
"smol",
"tempfile",
]
[[package]]
@@ -634,7 +635,7 @@ dependencies = [
"cfg-if",
"libc",
"miniz_oxide",
"object 0.37.3",
"object",
"rustc-demangle",
"windows-link 0.2.1",
]
@@ -838,21 +839,6 @@ dependencies = [
"cfg_aliases",
]
[[package]]
name = "browser-signer-proxy"
version = "0.1.0"
dependencies = [
"atomic-destructor",
"event-listener",
"nostr",
"opaquerr",
"serde",
"serde_json",
"smol",
"tracing",
"uuid",
]
[[package]]
name = "bstr"
version = "1.13.0"
@@ -1101,7 +1087,6 @@ dependencies = [
"nostr-sdk",
"person",
"pulldown-cmark",
"regex",
"serde",
"settings",
"smallvec",
@@ -1246,7 +1231,7 @@ dependencies = [
[[package]]
name = "collections"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"gpui_util",
"indexmap",
@@ -1368,35 +1353,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "coop"
version = "1.0.0-beta5"
@@ -1412,7 +1368,6 @@ dependencies = [
"gpui_platform",
"gpui_windows",
"log",
"nostr-sdk",
"person",
"reqwest_client",
"settings",
@@ -1760,7 +1715,7 @@ dependencies = [
[[package]]
name = "derive_refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"proc-macro2",
"quote",
@@ -2656,7 +2611,7 @@ dependencies = [
[[package]]
name = "gpui"
version = "0.2.2"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"accesskit",
"anyhow",
@@ -2735,26 +2690,10 @@ dependencies = [
"zed-scap",
]
[[package]]
name = "gpui-updater"
version = "0.0.6"
source = "git+https://github.com/AprilNEA/gpui-updater?tag=v0.0.6#e8a85768793ac3238b0c1bc1f32d6a1544667ec3"
dependencies = [
"gpui",
"minisign-verify",
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
"thiserror 2.0.19",
"tracing",
"ureq",
]
[[package]]
name = "gpui_linux"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"accesskit",
"accesskit_unix",
@@ -2806,7 +2745,7 @@ dependencies = [
[[package]]
name = "gpui_macos"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"accesskit",
"accesskit_macos",
@@ -2855,7 +2794,7 @@ dependencies = [
[[package]]
name = "gpui_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -2866,7 +2805,7 @@ dependencies = [
[[package]]
name = "gpui_platform"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"console_error_panic_hook",
"gpui",
@@ -2879,7 +2818,7 @@ dependencies = [
[[package]]
name = "gpui_shared_string"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"schemars",
"serde",
@@ -2889,7 +2828,7 @@ dependencies = [
[[package]]
name = "gpui_tokio"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"anyhow",
"gpui",
@@ -2900,7 +2839,7 @@ dependencies = [
[[package]]
name = "gpui_util"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"anyhow",
"log",
@@ -2910,7 +2849,7 @@ dependencies = [
[[package]]
name = "gpui_web"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"anyhow",
"console_error_panic_hook",
@@ -2933,7 +2872,7 @@ dependencies = [
[[package]]
name = "gpui_wgpu"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"anyhow",
"bytemuck",
@@ -2963,7 +2902,7 @@ dependencies = [
[[package]]
name = "gpui_windows"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"accesskit",
"accesskit_windows",
@@ -3253,7 +3192,7 @@ dependencies = [
[[package]]
name = "http_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"anyhow",
"async-compression",
@@ -3273,7 +3212,7 @@ dependencies = [
[[package]]
name = "http_client_tls"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"rustls",
"rustls-platform-verifier",
@@ -4048,6 +3987,15 @@ dependencies = [
"libc",
]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@@ -4071,7 +4019,7 @@ dependencies = [
[[package]]
name = "media"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"anyhow",
"bindgen",
@@ -4144,12 +4092,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -4270,8 +4212,8 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "nostr"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#affbc9e8f918ead0f1857a6c7d4afdc16f812a6c"
dependencies = [
"aes",
"base64",
@@ -4295,8 +4237,8 @@ dependencies = [
[[package]]
name = "nostr-blossom"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#affbc9e8f918ead0f1857a6c7d4afdc16f812a6c"
dependencies = [
"base64",
"bitcoin_hashes",
@@ -4308,8 +4250,8 @@ dependencies = [
[[package]]
name = "nostr-connect"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#affbc9e8f918ead0f1857a6c7d4afdc16f812a6c"
dependencies = [
"async-utility",
"futures-core",
@@ -4322,8 +4264,8 @@ dependencies = [
[[package]]
name = "nostr-database"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#affbc9e8f918ead0f1857a6c7d4afdc16f812a6c"
dependencies = [
"btreecap",
"flatbuffers",
@@ -4333,8 +4275,8 @@ dependencies = [
[[package]]
name = "nostr-gossip"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#affbc9e8f918ead0f1857a6c7d4afdc16f812a6c"
dependencies = [
"nostr",
"opaquerr",
@@ -4342,8 +4284,8 @@ dependencies = [
[[package]]
name = "nostr-gossip-memory"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#affbc9e8f918ead0f1857a6c7d4afdc16f812a6c"
dependencies = [
"indexmap",
"lru",
@@ -4354,8 +4296,8 @@ dependencies = [
[[package]]
name = "nostr-lmdb"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#affbc9e8f918ead0f1857a6c7d4afdc16f812a6c"
dependencies = [
"async-utility",
"flume 0.12.0",
@@ -4368,8 +4310,8 @@ dependencies = [
[[package]]
name = "nostr-memory"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#affbc9e8f918ead0f1857a6c7d4afdc16f812a6c"
dependencies = [
"btreecap",
"nostr",
@@ -4379,8 +4321,8 @@ dependencies = [
[[package]]
name = "nostr-sdk"
version = "0.45.0-alpha.8"
source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215"
version = "0.45.0-alpha.7"
source = "git+https://github.com/rust-nostr/nostr#affbc9e8f918ead0f1857a6c7d4afdc16f812a6c"
dependencies = [
"async-utility",
"async-wsocket",
@@ -4789,15 +4731,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -4991,7 +4924,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "perf"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"collections",
"serde",
@@ -5011,6 +4944,7 @@ dependencies = [
"nostr-sdk",
"smallvec",
"state",
"urlencoding",
]
[[package]]
@@ -5802,7 +5736,7 @@ dependencies = [
[[package]]
name = "refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"derive_refineable",
]
@@ -5884,7 +5818,7 @@ dependencies = [
[[package]]
name = "reqwest_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"anyhow",
"bytes",
@@ -6199,7 +6133,7 @@ dependencies = [
[[package]]
name = "scheduler"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"async-task",
"backtrace",
@@ -6747,7 +6681,6 @@ name = "state"
version = "1.0.0-beta5"
dependencies = [
"anyhow",
"browser-signer-proxy",
"common",
"flume 0.11.1",
"futures",
@@ -6826,7 +6759,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "sum_tree"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"heapless 0.9.3",
"log",
@@ -7181,7 +7114,6 @@ dependencies = [
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
@@ -7190,16 +7122,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
@@ -7546,10 +7468,14 @@ version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
@@ -7773,37 +7699,6 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64",
"cookie_store",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"ureq-proto",
"utf8-zero",
"webpki-roots 1.0.9",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -7817,6 +7712,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "usvg"
version = "0.46.0"
@@ -7850,12 +7751,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -7865,7 +7760,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "util_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"perf",
"quote",
@@ -9117,8 +9012,6 @@ name = "workspace"
version = "1.0.0-beta5"
dependencies = [
"anyhow",
"auto_update",
"browser-signer-proxy",
"chat",
"chat_ui",
"common",
@@ -9197,9 +9090,9 @@ dependencies = [
[[package]]
name = "xcursor"
version = "0.3.11"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
[[package]]
name = "xim-ctext"
@@ -9600,7 +9493,7 @@ dependencies = [
[[package]]
name = "zlog"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"anyhow",
"chrono",
@@ -9617,7 +9510,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "ztracing"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
dependencies = [
"tracing",
"tracing-subscriber",
@@ -9628,7 +9521,7 @@ dependencies = [
[[package]]
name = "ztracing_macro"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5e1fd392f67e27fa1da91bad43eef7db1a5dec23"
source = "git+https://github.com/zed-industries/zed#ae394f3d474f4996d2cdef6ee97551fdb6748acd"
[[package]]
name = "zune-core"
+1 -1
View File
@@ -42,7 +42,7 @@ schemars = "1"
smallvec = "1.14.0"
smol = "2"
webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
errno = { version = "0.3.14", default-features = false }
instant = "0.1"
-62
View File
@@ -1,62 +0,0 @@
[workspace]
resolver = "2"
members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
[workspace.package]
version = "1.0.0-beta5"
edition = "2024"
publish = false
[workspace.dependencies]
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { git = "https://github.com/zed-industries/zed" }
gpui_windows = { git = "https://github.com/zed-industries/zed" }
gpui_macos = { git = "https://github.com/zed-industries/zed" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr
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", features = [ "nip59", "nip49", "nip44" ] }
# Others
anyhow = "1.0.44"
chrono = { version = "0.4.38", features = ["wasmbind"] }
futures = "0.3"
itertools = "0.13.0"
log = "0.4"
oneshot = "0.1.10"
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
rust-embed = { version = "8.5", features = ["include-exclude"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
schemars = "1"
smallvec = "1.14.0"
smol = "2"
webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
errno = { version = "0.3.14", default-features = false }
instant = "0.1"
[patch.crates-io]
# Use stacker's psm version which may have better WASM support
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
[profile.profiling]
inherits = "release"
debug = true
+120
View File
@@ -1,5 +1,125 @@
![Coop](/docs/coop.png)
<p>
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
</a>
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
</p>
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
### Screenshots
<p float="left">
<img src="/docs/mac_01.png" width="250" />
<img src="/docs/mac_02.png" width="250" />
<img src="/docs/mac_03.png" width="250" />
<img src="/docs/mac_04.png" width="250" />
<img src="/docs/mac_05.png" width="250" />
<img src="/docs/mac_06.png" width="250" />
<img src="/docs/mac_07.png" width="250" />
<img src="/docs/mac_08.png" width="250" />
<img src="/docs/mac_09.png" width="250" />
<img src="/docs/linux_01.png" width="250" />
<img src="/docs/linux_02.png" width="250" />
<img src="/docs/linux_03.png" width="250" />
<img src="/docs/linux_04.png" width="250" />
<img src="/docs/linux_05.png" width="250" />
</p>
### Installation
To install Coop, follow these steps:
1. **Download the Latest Release**:
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
- Download the package that matches your operating system (Windows, macOS, or Linux).
2. **Install**:
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
3. **Run Coop**:
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
For more detailed instructions, refer to the [Release Notes](#) on GitHub.
### Developing Coop
Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nostr SDK](https://github.com/rust-nostr/nostr)
#### Prerequisites
- **Rust Toolchain**: Ensure you have Rust installed. If not, you can install it using [rustup](https://rustup.rs/).
- **Cargo**: Rust's package manager, which comes bundled with the Rust installation.
- **Git**: To clone the repository and manage version control.
#### Setting Up the Development Environment
1. Clone the repository:
```bash
git clone https://github.com/reyakov/coop.git
cd coop
```
2.1 Install Linux dependencies:
```bash
./script/linux
```
2.2 Install FreeBSD dependencies:
```bash
./script/freebsd
```
3. Install Rust dependencies:
```bash
cargo build
```
4. Run the app:
```bash
cargo run
```
#### Building for Production
To build Coop for production, use the following command:
```bash
cargo build --release
```
This will generate an optimized binary in the `target/release` directory.
#### Contributing Code
If you'd like to contribute to Coop, please follow these steps:
1. Fork the repository.
2. Create a new branch for your feature or bugfix.
3. Make your changes and ensure all tests pass.
4. Submit a pull request with a detailed description of your changes.
For more information, see the [Contributing](#contributing) section.
#### Additional Resources
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
- [GPUI](https://www.gpui.rs/)
- [GPUI Components](https://github.com/longbridge/gpui-component/)
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
### License
Copyright (C) 2025 Ren Amamiya & other Coop contributors
+11 -1
View File
@@ -5,8 +5,18 @@ edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
gpui.workspace = true
instant.workspace = true
anyhow.workspace = true
log.workspace = true
smallvec.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
semver = "1.0.27"
tempfile = "3.23.0"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
+498 -115
View File
@@ -1,9 +1,24 @@
#![cfg(not(target_arch = "wasm32"))]
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
use instant::{Duration, Instant};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::http_client::{AsyncBody, HttpClient};
use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
Window,
};
use instant::Duration;
use semver::Version;
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
use smol::fs::File;
use smol::io::AsyncReadExt;
use smol::process::Command;
const GITHUB_API_URL: &str = "https://api.github.com";
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
fn get_github_repo_owner() -> String {
@@ -15,15 +30,12 @@ fn get_github_repo_name() -> String {
}
fn is_flatpak_installation() -> bool {
// Check if app is installed via Flatpak
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
}
/// Initialize the auto-update system.
///
/// Skips initialization when running as a Flatpak (updates are handled by the
/// Flatpak distribution channel). Otherwise creates the global [`AutoUpdater`]
/// entity and schedules a check for updates after a 2-minute delay.
pub fn init(window: &mut Window, cx: &mut App) {
// Skip auto-update initialization if installed via Flatpak
if is_flatpak_installation() {
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
return;
@@ -36,145 +48,516 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {}
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
#[cfg(not(target_os = "windows"))]
struct InstallerDir(tempfile::TempDir);
/// The global auto-updater entity.
///
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
/// (GitHub repo, Flatpak detection, delayed auto-check).
///
/// Retrieve the global instance via [`AutoUpdater::global`].
#[cfg(not(target_os = "windows"))]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
Ok(Self(
tempfile::Builder::new()
.prefix("coop-auto-update")
.tempdir()?,
))
}
fn path(&self) -> &Path {
self.0.path()
}
}
#[cfg(target_os = "windows")]
struct InstallerDir(PathBuf);
#[cfg(target_os = "windows")]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
let installer_dir = std::env::current_exe()?
.parent()
.context("No parent dir for Coop.exe")?
.join("updates");
if smol::fs::metadata(&installer_dir).await.is_ok() {
smol::fs::remove_dir_all(&installer_dir).await?;
}
smol::fs::create_dir(&installer_dir).await?;
Ok(Self(installer_dir))
}
fn path(&self) -> &Path {
self.0.as_path()
}
}
struct MacOsUnmounter<'a> {
mount_path: PathBuf,
background_executor: &'a BackgroundExecutor,
}
impl Drop for MacOsUnmounter<'_> {
fn drop(&mut self) {
let mount_path = std::mem::take(&mut self.mount_path);
self.background_executor
.spawn(async move {
let unmount_output = Command::new("hdiutil")
.args(["detach", "-force"])
.arg(&mount_path)
.output()
.await;
match unmount_output {
Ok(output) if output.status.success() => {
log::info!("Successfully unmounted the disk image");
}
Ok(output) => {
log::error!(
"Failed to unmount disk image: {:?}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(error) => {
log::error!("Error while trying to unmount disk image: {:?}", error);
}
}
})
.detach();
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum AutoUpdateStatus {
Idle,
Checking,
Checked { download_url: String },
Installing,
Updated,
Errored { msg: Box<String> },
}
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
fn as_ref(&self) -> &AutoUpdateStatus {
self
}
}
impl AutoUpdateStatus {
pub fn is_updating(&self) -> bool {
matches!(self, Self::Checked { .. } | Self::Installing)
}
pub fn is_updated(&self) -> bool {
matches!(self, Self::Updated)
}
pub fn checked(download_url: String) -> Self {
Self::Checked { download_url }
}
pub fn error(e: String) -> Self {
Self::Errored { msg: Box::new(e) }
}
}
#[derive(Debug, Deserialize)]
pub struct GitHubRelease {
pub tag_name: String,
pub assets: Vec<GitHubAsset>,
}
#[derive(Debug, Deserialize)]
pub struct GitHubAsset {
pub name: String,
pub browser_download_url: String,
}
#[derive(Debug)]
pub struct AutoUpdater {
/// The underlying gpui-updater entity that does the heavy lifting.
pub updater: Entity<Updater>,
/// Currently running app version.
/// Current status of the auto updater
pub status: AutoUpdateStatus,
/// Current version of the application
pub version: Version,
/// Keeps the observer subscription alive.
_subscription: Subscription,
/// When the last error was recorded, so we can reset to idle after 5s.
error_time: Option<Instant>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
}
impl AutoUpdater {
/// Retrieve the global auto updater instance.
/// Retrieve the global auto updater instance
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAutoUpdater>().0.clone()
}
/// Set the global auto updater instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAutoUpdater(state));
}
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
let mut subscriptions = smallvec![];
let repo_owner = get_github_repo_owner();
let repo_name = get_github_repo_name();
subscriptions.push(
// Observe the status
cx.observe_self(|this, cx| {
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
this.download_and_install(&download_url, cx);
}
}),
);
let source =
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
"macos" => "macos",
"linux" => "linux",
_ => "",
});
let updater: Entity<Updater> =
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
// When an update becomes available, automatically download and install it.
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
let status = this.updater.read(cx).status().clone();
if matches!(status, UpdateStatus::Available(_)) {
this.updater.update(cx, |updater, cx| {
updater.download_and_install(cx);
});
}
if matches!(status, UpdateStatus::Errored(_)) {
this.error_time = Some(Instant::now());
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(5)).await;
this.update(cx, |_this, cx| cx.notify()).ok();
})
.detach();
} else {
this.error_time = None;
}
cx.notify();
});
// Schedule an auto-check after a 2-minute delay (deferred to run at the
// end of the current frame so the window is fully set up).
cx.defer_in(window, |_this, _window, cx| {
let duration = Duration::from_secs(120);
cx.spawn(async move |this, cx| {
cx.background_executor().timer(duration).await;
this.update(cx, |this, cx| {
this.updater.update(cx, |updater, cx| {
updater.check(cx);
});
})
.ok();
})
.detach();
// Run at the end of current cycle
cx.defer_in(window, |this, _window, cx| {
this.check(cx);
});
Self {
updater,
status: AutoUpdateStatus::Idle,
version,
_subscription: subscription,
error_time: None,
tasks: vec![],
_subscriptions: subscriptions,
}
}
pub fn idle(&self, cx: &App) -> bool {
let status = self.updater.read(cx).status();
if status == &UpdateStatus::Idle {
return true;
}
if matches!(status, UpdateStatus::Errored(_))
&& self
.error_time
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
{
return true;
}
false
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
self.status = status;
cx.notify();
}
pub fn status(&self, cx: &App) -> SharedString {
let status = self.updater.read(cx).status();
fn check(&mut self, cx: &mut Context<Self>) {
let version = self.version.clone();
let duration = Duration::from_secs(120);
let task = self.check_for_updates(version, cx);
match status {
UpdateStatus::Idle => "Up to date".into(),
UpdateStatus::Checking => "Checking for updates…".into(),
UpdateStatus::UpToDate => "Up to date".into(),
UpdateStatus::Available(version) => format!("Version {version} available").into(),
UpdateStatus::Downloading { downloaded, total } => {
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
let downloaded_mb = *downloaded as f64 / 1_048_576.0;
match total_mb {
Some(t) => format!("Downloading {downloaded_mb:.1} / {t:.1} MB").into(),
None => format!("Downloading {downloaded_mb:.1} MB").into(),
// Check for updates after 2 minutes
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor().timer(duration).await;
// Update the status to checking
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Checking, cx);
})?;
match task.await {
Ok(download_url) => {
// Update the status to checked with download URL
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::checked(download_url), cx);
})?;
}
Err(e) => {
log::warn!("Failed to check for updates: {e}");
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Idle, cx);
})?;
}
}
UpdateStatus::Installing => "Installing update…".into(),
UpdateStatus::Staged(version) => {
format!("Version {version} ready — restart to apply").into()
Ok(())
}));
}
fn check_for_updates(&self, version: Version, cx: &App) -> Task<Result<String, Error>> {
let http_client = cx.http_client();
let repo_owner = get_github_repo_owner();
let repo_name = get_github_repo_name();
cx.background_spawn(async move {
let url = format!(
"{}/repos/{}/{}/releases/latest",
GITHUB_API_URL, repo_owner, repo_name
);
let async_body = AsyncBody::default();
let mut body = Vec::new();
let mut response = http_client.get(&url, async_body, false).await?;
// Read the response body into a vector
response.body_mut().read_to_end(&mut body).await?;
if !response.status().is_success() {
return Err(anyhow!("GitHub API returned error: {}", response.status()));
}
UpdateStatus::Errored(msg) => {
if self
.error_time
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
{
"Up to date".into()
} else {
format!("Update failed: {msg}").into()
// Parse the response body as JSON
let release: GitHubRelease = serde_json::from_slice(&body)?;
// Parse version from tag (remove 'v' prefix if present)
let tag_version = release.tag_name.trim_start_matches('v');
let new_version = Version::parse(tag_version).context(format!(
"Failed to parse version from tag: {}",
release.tag_name
))?;
if new_version > version {
// Find the appropriate asset for the current platform
let current_os = std::env::consts::OS;
let asset_name = match current_os {
"macos" => "Coop.dmg",
"linux" => "coop.tar.gz",
"windows" => "Coop.exe",
_ => return Err(anyhow!("Unsupported OS: {}", current_os)),
};
let download_url = release
.assets
.iter()
.find(|asset| asset.name == asset_name)
.map(|asset| asset.browser_download_url.clone())
.context(format!(
"No {} asset found in release {}",
asset_name, release.tag_name
))?;
Ok(download_url)
} else {
Err(anyhow!(
"No update available. Current: {}, Latest: {}",
version,
new_version
))
}
})
}
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
let http_client = cx.http_client();
let download_url = download_url.to_string();
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
let installer_dir = InstallerDir::new().await?;
let target_path = Self::target_path(&installer_dir).await?;
// Download the release
download(&download_url, &target_path, http_client).await?;
Ok((installer_dir, target_path))
});
self.tasks.push(
// Install the new release
cx.spawn(async move |this, cx| {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Installing, cx);
})?;
match task.await {
Ok((installer_dir, target_path)) => {
if Self::install(installer_dir, target_path, cx).await.is_ok() {
// Update the status to updated
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Updated, cx);
})?;
}
}
Err(e) => {
// Update the status to error including the error message
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
})?;
}
}
}
Ok(())
}),
);
}
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
let filename = match std::env::consts::OS {
"macos" => anyhow::Ok("Coop.dmg"),
"linux" => Ok("coop.tar.gz"),
"windows" => Ok("Coop.exe"),
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
}?;
Ok(installer_dir.path().join(filename))
}
async fn install(
installer_dir: InstallerDir,
target_path: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
match std::env::consts::OS {
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
"linux" => install_release_linux(&installer_dir, target_path, cx).await,
"windows" => install_release_windows(target_path).await,
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
}
}
}
async fn download(
url: &str,
target_path: &std::path::Path,
client: Arc<dyn HttpClient>,
) -> Result<(), Error> {
let body = AsyncBody::default();
let mut target_file = File::create(&target_path).await?;
let mut response = client.get(url, body, true).await?;
// Copy the response body to the target file
smol::io::copy(response.body_mut(), &mut target_file).await?;
Ok(())
}
async fn install_release_macos(
temp_dir: &InstallerDir,
downloaded_dmg: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
let running_app_filename = running_app_path
.file_name()
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
let mount_path = temp_dir.path().join("Coop");
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
mounted_app_path.push("/");
let output = Command::new("hdiutil")
.args(["attach", "-nobrowse"])
.arg(&downloaded_dmg)
.arg("-mountroot")
.arg(temp_dir.path())
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to mount: {:?}",
String::from_utf8_lossy(&output.stderr)
);
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
let _unmounter = MacOsUnmounter {
mount_path: mount_path.clone(),
background_executor: cx.background_executor(),
};
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&mounted_app_path)
.arg(&running_app_path)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
async fn install_release_linux(
temp_dir: &InstallerDir,
downloaded_tar_gz: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
// Extract the tar.gz file
let extracted = temp_dir.path().join("coop");
smol::fs::create_dir_all(&extracted)
.await
.context("failed to create directory to extract update")?;
let output = Command::new("tar")
.arg("-xzf")
.arg(&downloaded_tar_gz)
.arg("-C")
.arg(&extracted)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to extract {:?} to {:?}: {:?}",
downloaded_tar_gz,
extracted,
String::from_utf8_lossy(&output.stderr)
);
// Find the extracted app directory
let mut entries = smol::fs::read_dir(&extracted).await?;
let mut app_dir = None;
use smol::stream::StreamExt;
while let Some(entry) = entries.next().await {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
app_dir = Some(path);
break;
}
}
let from = app_dir.context("No app directory found in archive")?;
// Copy to the current installation directory
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&from)
.arg(
running_app_path
.parent()
.context("No parent directory for app")?,
)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app from {:?} to {:?}: {:?}",
from,
running_app_path.parent(),
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
//const CREATE_NO_WINDOW: u32 = 0x08000000;
let system_root = std::env::var("SYSTEMROOT");
let powershell_path = system_root.as_ref().map_or_else(
|_| "powershell.exe".to_string(),
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
);
let mut installer_path = std::ffi::OsString::new();
installer_path.push("\"");
installer_path.push(&downloaded_installer);
installer_path.push("\"");
let output = Command::new(powershell_path)
//.creation_flags(CREATE_NO_WINDOW)
.args(["-NoProfile", "-WindowStyle", "Hidden"])
.args(["Start-Process"])
.arg(installer_path)
.arg("-ArgumentList")
.args(["/P", "/R"])
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to start installer: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "browser-signer-proxy"
version = "0.1.0"
edition.workspace = true
description = "Nostr browser signer (NIP-07) proxy using smol async runtime"
license = "MIT"
repository = "https://github.com/nostrdevkit/nostr"
publish = false
[dependencies]
atomic-destructor = "0.2"
event-listener = "5"
nostr.workspace = true
opaquerr = "0.1"
serde.workspace = true
serde_json.workspace = true
smol.workspace = true
tracing = { version = "0.1", features = ["std"] }
uuid = { version = "1.23", features = ["serde", "v4"] }
-55
View File
@@ -1,55 +0,0 @@
# browser-signer-proxy
Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications.
This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy)
using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio.
## Description
This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension
(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to
request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through
the browser extension.
The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener`
and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely.
## Usage
```rust
use browser_signer_proxy::prelude::*;
async fn example() -> Result<(), Error> {
// Create the proxy with default options (localhost:7400)
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
// Open the proxy URL in a browser
webbrowser::open(&proxy.url())?;
// Start the proxy server
proxy.start().await?;
// Use it as an async Nostr signer
let public_key = proxy.get_public_key_async().await?;
println!("Connected with public key: {public_key}");
Ok(())
}
```
## Differences from the tokio-based version
| Feature | tokio (original) | smol (this crate) |
|---|---|---|
| Async runtime | `tokio` | `smol` |
| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 |
| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` |
| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` |
| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` |
| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` |
| Task spawning | `tokio::spawn` | `smol::spawn` |
## License
This project is distributed under the MIT software license.
-185
View File
@@ -1,185 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coop — Web Signer Proxy</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&display=swap" rel="stylesheet">
<style>
:root {
--brand: #F8FF37;
--ink: #111111;
--ink-soft: #333333;
--muted: #666666;
--paper: #FFFFFF;
--edge: rgba(17, 17, 17, 0.14);
--radius-sm: 1rem;
--radius-md: 1.5rem;
--radius-lg: 2.5rem;
--green: #2E8B57;
--red: #D32F2F;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
min-height: 100%;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--ink);
background: var(--brand);
-webkit-font-smoothing: antialiased;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.card {
background: var(--paper);
border-radius: var(--radius-md);
padding: 2.5rem;
max-width: 440px;
width: 100%;
box-shadow: 0 8px 0 rgba(17, 17, 17, 0.12), 0 2px 20px rgba(17, 17, 17, 0.06);
}
.logo {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.75rem;
}
.logo__mark {
width: 2.5rem;
height: 2.5rem;
background: var(--ink);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.2rem;
color: var(--brand);
letter-spacing: -0.02em;
}
.logo__text {
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.3rem;
letter-spacing: -0.02em;
}
.heading {
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.6rem;
letter-spacing: -0.03em;
line-height: 1.15;
margin: 0 0 0.6rem;
}
.subtitle {
font-size: 0.95rem;
color: var(--ink-soft);
margin: 0 0 1.75rem;
line-height: 1.5;
}
.status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.95rem;
transition: background 300ms ease, color 300ms ease;
}
.status--checking {
background: rgba(17, 17, 17, 0.05);
color: var(--muted);
}
.status--connected {
background: rgba(46, 139, 87, 0.1);
color: var(--green);
}
.status--error {
background: rgba(211, 47, 47, 0.08);
color: var(--red);
}
.status__dot {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
flex-shrink: 0;
}
.status--checking .status__dot {
background: var(--muted);
animation: pulse 1.2s ease-in-out infinite;
}
.status--connected .status__dot {
background: var(--green);
}
.status--error .status__dot {
background: var(--red);
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
.hint {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--edge);
font-size: 0.8rem;
color: var(--muted);
line-height: 1.5;
}
.hint strong {
color: var(--ink-soft);
}
</style>
</head>
<body>
<div class="card">
<h1 class="heading">Web Signer</h1>
<p class="subtitle">
This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely.
</p>
<div id="nip07-status" class="status status--checking">
<div class="status__dot"></div>
<span id="nip07-status-text">Checking extension…</span>
</div>
<div class="hint">
<strong>Keep this tab open</strong> while using the app — it automatically handles sign-in requests in the background.
</div>
</div>
<script src="proxy.js"></script>
</body>
</html>
-156
View File
@@ -1,156 +0,0 @@
let isPolling = false;
async function pollForRequests() {
if (isPolling) return;
isPolling = true;
try {
const response = await fetch('/api/pending');
const data = await response.json();
console.log('Polled for requests, got:', data);
// Process any new requests
if (data.requests && data.requests.length > 0) {
console.log(`Processing ${data.requests.length} requests`);
for (const request of data.requests) {
await handleNip07Request(request);
}
}
} catch (error) {
console.error('Polling error:', error);
updateStatus('Error: ' + error.message, 'error');
}
isPolling = false;
}
async function handleNip07Request(request) {
console.log('Handling request:', request);
try {
let result;
if (!window.nostr) {
throw new Error('NIP-07 extension not available');
}
switch (request.method) {
case 'get_public_key':
console.log('Calling nostr.getPublicKey()');
result = await window.nostr.getPublicKey();
console.log('Got public key:', result);
break;
case 'sign_event':
console.log('Calling nostr.signEvent() with:', request.params);
result = await window.nostr.signEvent(request.params);
console.log('Got signed event:', result);
break;
case 'nip04_encrypt':
console.log('Calling nostr.nip04.encrypt()');
result = await window.nostr.nip04.encrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip04_decrypt':
console.log('Calling nostr.nip04.decrypt()');
result = await window.nostr.nip04.decrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip44_encrypt':
console.log('Calling nostr.nip44.encrypt()');
result = await window.nostr.nip44.encrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip44_decrypt':
console.log('Calling nostr.nip44.decrypt()');
result = await window.nostr.nip44.decrypt(
request.params.public_key,
request.params.content
);
break;
default:
throw new Error(`Unknown method: ${request.method}`);
}
// Send response back to server
const responsePayload = {
id: request.id,
result: result,
error: null
};
console.log('Sending response:', responsePayload);
await fetch('/api/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(responsePayload)
});
console.log('Response sent successfully');
updateStatus('Request processed successfully', 'connected');
} catch (error) {
console.error('Error handling request:', error);
// Send error response back to server
const errorPayload = {
id: request.id,
result: null,
error: error.message
};
console.log('Sending error response:', errorPayload);
await fetch('/api/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(errorPayload)
});
updateStatus('Error: ' + error.message, 'error');
}
}
function updateStatus(message, state) {
const container = document.getElementById('nip07-status');
const textEl = document.getElementById('nip07-status-text');
if (container && textEl) {
container.className = 'status status--' + state;
textEl.textContent = message;
}
}
// Start polling when page loads
window.addEventListener('load', () => {
console.log('NIP-07 Proxy loaded');
// Check if NIP-07 extension is available
if (window.nostr) {
console.log('NIP-07 extension detected');
updateStatus('Connected — ready', 'connected');
} else {
console.log('NIP-07 extension not found');
updateStatus('No NIP-07 extension found', 'error');
}
// Start polling every 500 ms
setInterval(pollForRequests, 500);
});
-51
View File
@@ -1,51 +0,0 @@
// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Error types for the browser signer proxy.
opaquerr::define_kind! {
/// Nostr browser signer proxy error kind.
pub ErrorKind {
/// Nostr protocol error.
Protocol => "nostr protocol error",
/// I/O error.
IO => "I/O error",
/// JSON error.
Json => "JSON error",
/// The operation timed out.
Timeout => "timeout",
/// The operation cannot be completed in the current state.
State => "invalid state",
/// Anything not covered by the stable categories above.
Other => "other error",
}
}
opaquerr::define_error! {
/// Nostr browser signer proxy error.
pub Error(ErrorKind)
from {
nostr::error::Error => ErrorKind::Protocol,
std::io::Error => ErrorKind::IO,
serde_json::Error => ErrorKind::Json,
}
}
impl Error {
pub(crate) fn generic<S>(message: S) -> Self
where
S: Into<String>,
{
Self::new(ErrorKind::Other, message.into())
}
pub(crate) fn timeout() -> Self {
Self::simple(ErrorKind::Timeout)
}
pub(crate) fn shutdown() -> Self {
Self::with_static_message(ErrorKind::State, "server is shutdown")
}
}
-764
View File
@@ -1,764 +0,0 @@
use std::collections::HashMap;
use std::future::Future;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
use event_listener::Event as ShutdownEvent;
use nostr::prelude::*;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize, Serializer};
use serde_json::{Value, json};
use smol::channel;
use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use smol::lock::Mutex;
use smol::net::{TcpListener, TcpStream};
use uuid::Uuid;
mod error;
pub mod prelude;
pub use self::error::Error;
const DEFAULT_HTML: &str = include_str!("../index.html");
const JS: &str = include_str!("../proxy.js");
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
#[derive(Debug, Deserialize)]
struct Message {
id: Uuid,
error: Option<String>,
result: Option<Value>,
}
impl Message {
fn into_result(self) -> Result<Value, String> {
if let Some(error) = self.error {
Err(error)
} else {
Ok(self.result.unwrap_or(Value::Null))
}
}
}
#[derive(Debug, Clone, Copy)]
enum RequestMethod {
GetPublicKey,
SignEvent,
Nip04Encrypt,
Nip04Decrypt,
Nip44Encrypt,
Nip44Decrypt,
}
impl RequestMethod {
fn as_str(&self) -> &str {
match self {
Self::GetPublicKey => "get_public_key",
Self::SignEvent => "sign_event",
Self::Nip04Encrypt => "nip04_encrypt",
Self::Nip04Decrypt => "nip04_decrypt",
Self::Nip44Encrypt => "nip44_encrypt",
Self::Nip44Decrypt => "nip44_decrypt",
}
}
}
impl Serialize for RequestMethod {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize)]
struct RequestData {
id: Uuid,
method: RequestMethod,
params: Value,
}
impl RequestData {
#[inline]
fn new(method: RequestMethod, params: Value) -> Self {
Self {
id: Uuid::new_v4(),
method,
params,
}
}
}
#[derive(Serialize)]
struct Requests<'a> {
requests: &'a [RequestData],
}
impl<'a> Requests<'a> {
#[inline]
fn new(requests: &'a [RequestData]) -> Self {
Self { requests }
}
#[inline]
fn len(&self) -> usize {
self.requests.len()
}
}
/// Params for NIP-04 and NIP-44 encryption/decryption
#[derive(Serialize)]
struct CryptoParams<'a> {
public_key: &'a PublicKey,
content: &'a str,
}
impl<'a> CryptoParams<'a> {
#[inline]
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
Self {
public_key,
content,
}
}
}
#[derive(Debug)]
struct ProxyState {
/// Requests waiting to be picked up by browser
pub outgoing_requests: Mutex<Vec<RequestData>>,
/// Map of request ID to response sender
pub pending_responses: Mutex<PendingResponseMap>,
/// Last time the client asked for the pending requests
pub last_pending_request: Arc<AtomicU64>,
}
/// Configuration options for [`BrowserSignerProxy`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserSignerProxyOptions {
/// Request timeout for the signer extension. Default is 30 seconds.
pub timeout: Duration,
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
pub addr: SocketAddr,
/// Custom HTML page.
// NOTE: not `Option` to move it between threads without reference counter
pub custom_html: &'static str,
}
#[derive(Debug, Clone)]
struct InnerBrowserSignerProxy {
/// Configuration options for the proxy
options: BrowserSignerProxyOptions,
/// Internal state of the proxy including request queues
state: Arc<ProxyState>,
/// Notification trigger for graceful shutdown
shutdown: Arc<ShutdownEvent>,
/// Flag to indicate if the server is shutdown
is_shutdown: Arc<AtomicBool>,
/// Flag indicating if the server is started
is_started: Arc<AtomicBool>,
}
impl AtomicDestroyer for InnerBrowserSignerProxy {
fn on_destroy(&self) {
self.shutdown();
}
}
impl InnerBrowserSignerProxy {
#[inline]
fn is_shutdown(&self) -> bool {
self.is_shutdown.load(Ordering::SeqCst)
}
fn shutdown(&self) {
// Mark the server as shutdown
self.is_shutdown.store(true, Ordering::SeqCst);
// Notify all waiters that the proxy is shutting down
self.shutdown.notify(usize::MAX);
}
}
/// Nostr Browser Signer Proxy
///
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
#[derive(Debug, Clone)]
pub struct BrowserSignerProxy {
inner: AtomicDestructor<InnerBrowserSignerProxy>,
}
impl Default for BrowserSignerProxyOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
custom_html: "",
}
}
}
impl BrowserSignerProxyOptions {
/// Sets the timeout duration.
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Sets the IP address.
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
self.addr = SocketAddr::new(new_ip, self.addr.port());
self
}
/// Sets the port number.
pub const fn port(mut self, new_port: u16) -> Self {
self.addr = SocketAddr::new(self.addr.ip(), new_port);
self
}
/// Sets a custom html page.
///
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
/// which will handle communication with the server and update the element
/// with id `nip07-proxy-status` with the status.
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
self.custom_html = custom_html;
self
}
}
impl BrowserSignerProxy {
/// Construct a new browser signer proxy
pub fn new(options: BrowserSignerProxyOptions) -> Self {
let state = ProxyState {
outgoing_requests: Mutex::new(Vec::new()),
pending_responses: Mutex::new(HashMap::new()),
last_pending_request: Arc::new(AtomicU64::new(0)),
};
Self {
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
options,
state: Arc::new(state),
shutdown: Arc::new(ShutdownEvent::new()),
is_shutdown: Arc::new(AtomicBool::new(false)),
is_started: Arc::new(AtomicBool::new(false)),
}),
}
}
/// Indicates whether the server is currently running.
#[inline]
pub fn is_started(&self) -> bool {
self.inner.is_started.load(Ordering::SeqCst)
}
/// Checks if there is an open browser tab ready to respond to requests by
/// verifying the time since the last pending request.
#[inline]
pub fn is_session_active(&self) -> bool {
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
}
/// Get the signer proxy webpage URL
#[inline]
pub fn url(&self) -> String {
format!("http://{}", self.inner.options.addr)
}
/// Start the proxy server.
///
/// If this is not called explicitly, the server will be automatically
/// started on the first interaction with the signer.
pub async fn start(&self) -> Result<(), Error> {
// Ensure is not shutdown
if self.inner.is_shutdown() {
return Err(Error::shutdown());
}
// Mark the proxy as started and check if was already started
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
// Immediately return if already started
if is_started {
return Ok(());
}
let listener: TcpListener = match TcpListener::bind(self.inner.options.addr).await {
Ok(listener) => listener,
Err(e) => {
// Undo the started flag if binding fails
self.inner.is_started.store(false, Ordering::SeqCst);
return Err(Error::from(e));
}
};
let addr: SocketAddr = self.inner.options.addr;
let state: Arc<ProxyState> = self.inner.state.clone();
let custom_html: &'static str = self.inner.options.custom_html;
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
smol::spawn(async move {
tracing::info!("Starting proxy server on {addr}");
loop {
// Race between accepting a new connection and shutdown signal
let shutdown_listener = shutdown.listen();
enum AcceptEvent {
Connection(Result<(TcpStream, SocketAddr), std::io::Error>),
Shutdown,
}
let event = smol::future::or(
async { AcceptEvent::Connection(listener.accept().await) },
async {
shutdown_listener.await;
AcceptEvent::Shutdown
},
)
.await;
match event {
AcceptEvent::Connection(Ok((stream, _))) => {
let state: Arc<ProxyState> = state.clone();
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
smol::spawn(async move {
let shutdown_listener = shutdown.listen();
smol::future::or(
async {
handle_connection(stream, state, custom_html).await;
},
async {
shutdown_listener.await;
tracing::debug!(
"Closing connection, proxy server is shutting down."
);
},
)
.await;
})
.detach();
}
AcceptEvent::Connection(Err(e)) => {
tracing::error!("Failed to accept connection: {e}");
}
AcceptEvent::Shutdown => break,
}
}
tracing::info!("Proxy server shut down.");
})
.detach();
Ok(())
}
#[inline]
async fn store_pending_response(&self, id: Uuid, tx: channel::Sender<Result<Value, String>>) {
let mut pending_responses = self.inner.state.pending_responses.lock().await;
pending_responses.insert(id, tx);
}
#[inline]
async fn store_outgoing_request(&self, request: RequestData) {
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
outgoing_requests.push(request);
}
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
where
T: DeserializeOwned,
{
// Start the proxy if not already started
self.start().await?;
// Construct the request
let request: RequestData = RequestData::new(method, params);
// Create a bounded channel of size 1 as a oneshot replacement
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
// Store the response sender
self.store_pending_response(request.id, tx).await;
// Add to outgoing requests queue
self.store_outgoing_request(request).await;
// Wait for response with timeout
let response = race_timeout(self.inner.options.timeout, rx.recv()).await;
match response {
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
Ok(Err(error)) => Err(Error::generic(error)),
Err(TimeoutError) => Err(Error::timeout()),
}
}
#[inline]
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
self.request(RequestMethod::GetPublicKey, json!({})).await
}
#[inline]
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
let event: Event = self
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
.await?;
event.verify()?;
Ok(event)
}
#[inline]
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
.await
}
}
impl AsyncGetPublicKey for BrowserSignerProxy {
type Error = Error;
#[inline]
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
Box::pin(async move { self._get_public_key().await })
}
}
impl AsyncSignEvent for BrowserSignerProxy {
type Error = Error;
#[inline]
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
Box::pin(async move { self._sign_event(unsigned).await })
}
}
impl AsyncNip04 for BrowserSignerProxy {
type Error = Error;
fn nip04_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
}
fn nip04_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
}
}
impl AsyncNip44 for BrowserSignerProxy {
type Error = Error;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip44_encrypt(public_key, content).await })
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip44_decrypt(public_key, payload).await })
}
}
// ── Minimal HTTP server ──────────────────────────────────────────────────
/// Handle a single HTTP connection.
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
let mut reader = BufReader::new(stream);
// Read the request line
let mut request_line = String::new();
if reader.read_line(&mut request_line).await.is_err() {
return;
}
let request_line = request_line.trim_end().to_string();
// Parse method, path, and HTTP version from request line
let parts: Vec<&str> = request_line.split_whitespace().collect();
if parts.len() < 2 {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let method = parts[0].to_uppercase();
let path = parts[1].to_string();
// Read headers until empty line
let mut headers = Vec::new();
let mut content_length: usize = 0;
loop {
let mut line = String::new();
if reader.read_line(&mut line).await.is_err() {
return;
}
let line = line.trim_end().to_string();
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = value.trim().parse().unwrap_or(0);
}
headers.push(line);
}
match (method.as_str(), path.as_str()) {
// Serve the HTML proxy page
("GET", "/") => {
let html = if custom_html.is_empty() {
DEFAULT_HTML
} else {
custom_html
};
send_response(&mut reader, 200, "OK", "text/html", html).await;
}
// Serve the JS proxy script
("GET", "/proxy.js") => {
send_response(&mut reader, 200, "OK", "application/javascript", JS).await;
}
// Browser polls this endpoint to get pending requests
("GET", "/api/pending") => {
state
.last_pending_request
.store(current_time(), Ordering::SeqCst);
let mut outgoing = state.outgoing_requests.lock().await;
let requests = Requests::new(&outgoing);
let json = match serde_json::to_string(&requests) {
Ok(j) => j,
Err(e) => {
tracing::error!("Failed to serialize pending requests: {e}");
send_response(&mut reader, 500, "Internal Server Error", "", "").await;
return;
}
};
tracing::debug!("Sending {} pending requests to browser", requests.len());
// Clear the outgoing requests after sending them
outgoing.clear();
send_response_cors_json(&mut reader, 200, "OK", &json).await;
}
// Receive response from browser extension
("POST", "/api/response") => {
let mut body_bytes = vec![0u8; content_length];
if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let message: Message = match serde_json::from_slice(&body_bytes) {
Ok(json) => json,
Err(e) => {
tracing::error!("Failed to parse response body: {e}");
send_response(&mut reader, 400, "Invalid JSON", "", "").await;
return;
}
};
tracing::debug!("Received response from browser: {message:?}");
let id: Uuid = message.id;
let mut pending = state.pending_responses.lock().await;
match pending.remove(&id) {
Some(sender) => {
// Use try_send since we already hold the lock
let _ = sender.try_send(message.into_result());
tracing::info!("Forwarded response for request {id}");
}
None => tracing::warn!("No pending request found for {id}"),
}
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// CORS preflight
("OPTIONS", _) => {
let response = "HTTP/1.1 200 OK\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
let _ = reader.get_mut().write_all(response.as_bytes()).await;
let _ = reader.get_mut().flush().await;
}
// 404 - not found
_ => {
send_response(&mut reader, 404, "Not Found", "", "").await;
}
}
}
/// Write an HTTP response to the stream.
async fn send_response(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers and JSON content type.
async fn send_response_cors_json(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
response.push_str("Content-Type: application/json\r\n");
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers.
async fn send_response_cors(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
// ── Timeout helper ───────────────────────────────────────────────────────
/// An error indicating that an operation timed out.
#[derive(Debug)]
struct TimeoutError;
/// Races a channel receive against a duration.
///
/// Returns the channel value on success, or [`TimeoutError`] if the duration
/// elapses first or the channel is closed.
async fn race_timeout<T>(
duration: Duration,
recv: impl Future<Output = Result<T, channel::RecvError>>,
) -> Result<T, TimeoutError> {
enum Event<T> {
Value(T),
ChannelClosed,
Timeout,
}
let event = smol::future::or(
async {
match recv.await {
Ok(value) => Event::Value(value),
Err(_) => Event::ChannelClosed,
}
},
async {
smol::Timer::after(duration).await;
Event::Timeout
},
)
.await;
match event {
Event::Value(value) => Ok(value),
Event::ChannelClosed | Event::Timeout => Err(TimeoutError),
}
}
// ── Utility ──────────────────────────────────────────────────────────────
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
/// time is before the epoch, returns 0.
#[inline]
fn current_time() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
}
@@ -1,14 +0,0 @@
// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Prelude
#![allow(unknown_lints)]
#![allow(ambiguous_glob_reexports)]
#![doc(hidden)]
pub use nostr::prelude::*;
pub use crate::error::{Error, ErrorKind};
pub use crate::*;
+50 -52
View File
@@ -1,5 +1,5 @@
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap, HashSet, hash_map};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, RwLock};
@@ -80,9 +80,6 @@ pub struct ChatRegistry {
/// Chat rooms
rooms: Vec<Entity<Room>>,
/// O(1) room lookup by room ID
room_index: HashMap<u64, Entity<Room>>,
/// Events that failed to unwrap for any reason
trash: Entity<BTreeSet<FailedMessage>>,
@@ -173,7 +170,6 @@ impl ChatRegistry {
Self {
rooms: vec![],
room_index: HashMap::new(),
trash: cx.new(|_| BTreeSet::default()),
seen: Arc::new(RwLock::new(HashMap::default())),
event_map: Arc::new(RwLock::new(HashMap::default())),
@@ -440,9 +436,12 @@ impl ChatRegistry {
self.tracking.load(Ordering::Acquire)
}
/// Get a weak reference to a room by its ID
pub fn room(&self, id: &u64, _cx: &App) -> Option<WeakEntity<Room>> {
self.room_index.get(id).map(|room| room.downgrade())
/// Get a weak reference to a room by its ID.
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms
.iter()
.find(|this| &this.read(cx).id == id)
.map(|this| this.downgrade())
}
/// Get all rooms based on the filter.
@@ -512,11 +511,7 @@ impl ChatRegistry {
};
let room: Room = room.into().organize(&public_key);
let room_id = room.id;
let entity = cx.new(|_| room);
self.room_index.insert(room_id, entity.clone());
self.rooms.insert(0, entity);
self.rooms.insert(0, cx.new(|_| room));
cx.emit(ChatEvent::Ping);
cx.notify();
@@ -529,11 +524,9 @@ impl ChatRegistry {
// Get the room's ID.
let id = room.read(cx).id;
// If the room is new, add it to the registry and index.
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
let entity = room.to_owned();
e.insert(entity.clone());
self.rooms.insert(0, entity);
// If the room is new, add it to the registry.
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
self.rooms.insert(0, room.to_owned());
}
// Emit the open room event deferred to avoid re-entrant reads
@@ -544,23 +537,17 @@ impl ChatRegistry {
/// Close a room.
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
if self.room_index.contains_key(&id) {
self.room_index.remove(&id);
self.rooms.retain(|r| r.read(cx).id != id);
if self.rooms.iter().any(|r| r.read(cx).id == id) {
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(ChatEvent::CloseRoom(id));
});
}
}
/// Sort rooms by their created at. Only notifies if order changed.
/// Sort rooms by their created at.
pub fn sort(&mut self, cx: &mut Context<Self>) {
let before: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
if before != after {
cx.notify();
}
cx.notify();
}
/// Finding rooms based on a query.
@@ -587,7 +574,6 @@ impl ChatRegistry {
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear();
self.room_index.clear();
self.trash.update(cx, |this, cx| {
this.clear();
cx.notify();
@@ -615,9 +601,7 @@ impl ChatRegistry {
});
} else {
let new_room_id = new_room.id;
let entity = cx.new(|_| new_room);
self.room_index.insert(new_room_id, entity.clone());
self.rooms.push(entity);
self.rooms.push(cx.new(|_| new_room));
let new_index = self.rooms.len();
room_map.insert(new_room_id, new_index);
@@ -627,7 +611,13 @@ impl ChatRegistry {
/// Load all rooms from the database.
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let task = self.get_rooms_task(cx);
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task = self.get_rooms_from_database(public_key, cx);
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
@@ -649,53 +639,61 @@ impl ChatRegistry {
}
/// Create a task to load rooms from the database
fn get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
fn get_rooms_from_database(
&self,
public_key: PublicKey,
cx: &App,
) -> Task<Result<HashSet<Room>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
let contacts = client
.database()
.contacts_public_keys(public_key)
.await
.unwrap_or_default();
// Query all cached rumor events (works with both old and new cache formats)
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), "14");
let events = client.database().query(filter).await?;
let mut rooms: HashSet<Room> = HashSet::new();
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
for raw in events.into_iter() {
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
&& rumor.tags.public_keys().next().is_some()
&& rumor.tags.public_keys().peekable().peek().is_some()
{
if rumor.pubkey != public_key
&& !rumor.tags.public_keys().any(|k| k == public_key)
{
continue;
}
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
}
}
let mut rooms = HashSet::with_capacity(grouped.len());
for (_id, mut messages) in grouped.into_iter() {
messages.sort_by_key(|m| Reverse(m.created_at));
for (_id, messages) in grouped.into_iter() {
let latest = messages.iter().max_by_key(|m| m.created_at).unwrap();
let room = Room::from(latest).organize(&public_key);
// Always use the latest message
let Some(latest) = messages.first() else {
continue;
};
// Construct the room from the latest message.
//
// Call `.organize` to ensure the current user is at the end of the list.
let mut room = Room::from(latest).organize(&public_key);
// Check if the user has responded to the room
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
// Check if public keys are from the user's contacts
let is_contact = room.members.iter().any(|k| contacts.contains(k));
let room = if user_sent || is_contact {
room.kind(RoomKind::Ongoing)
} else {
room
};
// Set the room's kind based on status
if user_sent || is_contact {
room = room.kind(RoomKind::Ongoing);
}
rooms.insert(room);
}
@@ -715,7 +713,7 @@ impl ChatRegistry {
return;
};
match self.room_index.get(&message.room).cloned() {
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
Some(room) => {
room.update(cx, |this, cx| {
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
+3 -3
View File
@@ -271,8 +271,8 @@ impl Room {
}
/// Returns the members of the room
pub fn members(&self) -> &[PublicKey] {
&self.members
pub fn members(&self) -> Vec<PublicKey> {
self.members.clone()
}
/// Checks if the room has more than two members (group)
@@ -356,7 +356,7 @@ impl Room {
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let members = self.members().to_vec();
let members = self.members();
cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default()
-1
View File
@@ -25,5 +25,4 @@ serde.workspace = true
linkify = "0.10.0"
pulldown-cmark = "0.13.1"
regex = "1"
+47 -96
View File
@@ -1,5 +1,5 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, LazyLock, RwLock};
use std::collections::{BTreeMap, HashSet};
use std::sync::{Arc, RwLock};
pub use actions::*;
use anyhow::{Context as AnyhowContext, Error};
@@ -11,14 +11,12 @@ use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, svg,
white,
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, div,
img, list, px, red, relative, svg, white,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use regex::Regex;
use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload};
@@ -40,11 +38,6 @@ use crate::text::RenderedText;
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
/// Regex matching strings that consist entirely of emoji characters,
/// zero-width joiners, variation selectors, and keycap combiners.
static EMOJI_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
mod actions;
mod text;
@@ -66,9 +59,6 @@ pub struct ChatPanel {
/// All messages (sorted by created_at)
messages: Vec<Message>,
/// O(1) message lookup by EventId
message_index: HashMap<EventId, usize>,
/// All reactions
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
@@ -176,7 +166,6 @@ impl ChatPanel {
focus_handle: cx.focus_handle(),
id,
messages,
message_index: HashMap::new(),
reactions: BTreeMap::new(),
room,
list_state,
@@ -242,29 +231,23 @@ impl ChatPanel {
while let Ok(status) = rx.recv_async().await {
{
let mut map = reports.write().unwrap();
let status_id = match &*status {
SendStatus::Ok { id, .. } => *id,
SendStatus::Failed { id, .. } => *id,
};
// Find the matching report and update it (exit early on first match)
'outer: for reports_list in map.values_mut() {
for report in reports_list.iter_mut() {
for reports in map.values_mut() {
for report in reports.iter_mut() {
let Some(output) = report.output.as_mut() else {
continue;
};
if *output.id() != status_id {
continue;
}
match &*status {
SendStatus::Ok { relay, .. } => {
output.success.insert(relay.clone(), EventSendStatus::Sent);
SendStatus::Ok { id, relay } => {
if output.id() == id {
output.success.insert(relay.clone(), EventSendStatus::Sent);
}
}
SendStatus::Failed { relay, message, .. } => {
output.failed.insert(relay.clone(), message.clone());
SendStatus::Failed { id, relay, message } => {
if output.id() == id {
output.failed.insert(relay.clone(), message.clone());
}
}
}
break 'outer;
}
}
}
@@ -276,44 +259,30 @@ impl ChatPanel {
/// Subscribe to room events
fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(room) = self.room.upgrade() else {
return;
};
self.subscriptions.push(cx.subscribe_in(
&room,
window,
move |this, _room, event, window, cx| {
match event {
RoomEvent::Incoming(message) => {
if message.rumor.kind == Kind::Reaction {
this.insert_reaction(&message.rumor, cx);
} else {
this.insert_message(message, false, cx);
if !window.is_window_active() {
cx.show_system_notification(SystemNotification {
tag: "message".into(),
title: "New Message".into(),
body: "You have a new message.".into(),
actions: vec![SystemNotificationAction {
id: "open".into(),
label: "Open".into(),
}],
});
if let Some(room) = self.room.upgrade() {
self.subscriptions.push(cx.subscribe_in(
&room,
window,
move |this, _room, event, window, cx| {
match event {
RoomEvent::Incoming(message) => {
if message.rumor.kind == Kind::Reaction {
this.insert_reaction(&message.rumor, cx);
} else {
this.insert_message(message, false, cx);
}
}
}
RoomEvent::Reload => {
// Defer to avoid re-entrant read on Room while
// emit_refresh holds a write lock (via refresh_rooms).
cx.defer_in(window, |this, window, cx| {
this.get_messages(window, cx);
});
}
};
},
));
RoomEvent::Reload => {
// Defer to avoid re-entrant read on Room while
// emit_refresh holds a write lock (via refresh_rooms).
cx.defer_in(window, |this, window, cx| {
this.get_messages(window, cx);
});
}
};
},
));
}
}
/// Load all messages belonging to this room
@@ -382,17 +351,6 @@ impl ChatPanel {
return;
}
// If replying to exactly one message with only a valid emoji,
// send as a reaction instead of a text message
if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty()
{
for reply in &replies {
self.send_reaction(&content, reply, window, cx);
}
self.clear(window, cx);
return;
}
self.send_message(&content, replies, false, window, cx);
}
@@ -514,10 +472,6 @@ impl ChatPanel {
if let Err(pos) = self.messages.binary_search(&msg) {
self.messages.insert(pos, msg);
// Rebuild message index after insertion (indices from pos to end shift)
for (i, message) in self.messages.iter().enumerate().skip(pos) {
self.message_index.insert(message.id, i);
}
self.list_state.splice(old_len..old_len, 1);
if scroll {
@@ -560,25 +514,22 @@ impl ChatPanel {
}
/// Check if a message has any reports
fn has_reports(&self, id: &EventId) -> bool {
self.reports_by_id.read().unwrap().contains_key(id)
fn has_reports(&self, id: &EventId, _cx: &App) -> bool {
self.reports_by_id.read().unwrap().get(id).is_some()
}
/// Clone reports for a message (used for modal display, not called during render)
fn sent_reports(&self, id: &EventId) -> Option<Vec<SendReport>> {
fn sent_reports(&self, id: &EventId, _cx: &App) -> Option<Vec<SendReport>> {
self.reports_by_id.read().unwrap().get(id).cloned()
}
/// Get a message by its ID (O(1) lookup)
/// Get a message by its ID
fn message(&self, id: &EventId) -> Option<&Message> {
self.message_index
.get(id)
.and_then(|&ix| self.messages.get(ix))
self.messages.iter().find(|msg| &msg.id == id)
}
/// Get a reaction by its target ID (returns reference, no allocation)
fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] {
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
/// Get a reaction by its target ID
fn reaction(&self, id: &EventId) -> Vec<(SharedString, PublicKey)> {
self.reactions.get(id).cloned().unwrap_or_default()
}
/// Check if a message has any reactions
@@ -961,7 +912,7 @@ impl ChatPanel {
let replies = message.replies_to.as_slice();
let has_replies = !replies.is_empty();
let has_reactions = self.has_reaction(&id);
let has_reports = self.has_reports(&id);
let has_reports = self.has_reports(&id, cx);
// Hide avatar setting
let hide_avatar = AppSettings::get_hide_avatar(cx);
@@ -1144,7 +1095,7 @@ impl ChatPanel {
// Group reactions by emoji and collect authors for each
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
for (emoji, author) in reactions {
for (emoji, author) in &reactions {
grouped.entry(emoji.clone()).or_default().push(*author);
}
@@ -1176,7 +1127,7 @@ impl ChatPanel {
}
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
let reports = self.sent_reports(id);
let reports = self.sent_reports(id, cx);
let pending = reports
.as_ref()
+1
View File
@@ -15,3 +15,4 @@ anyhow.workspace = true
smallvec.workspace = true
flume.workspace = true
log.workspace = true
urlencoding = "2.1.3"
+1 -1
View File
@@ -1,10 +1,10 @@
use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use instant::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
+9 -1
View File
@@ -5,6 +5,8 @@ use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::Announcement;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
/// Person
#[derive(Debug, Clone)]
pub struct Person {
@@ -109,7 +111,13 @@ impl Person {
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| picture.into())
.map(|picture| {
let encoded_picture = urlencoding::encode(picture);
let url = format!(
"{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
);
url.into()
})
.unwrap_or_else(|| "brand/avatar.png".into())
}
-1
View File
@@ -28,7 +28,6 @@ mime_guess = "2.0.4"
nostr-memory.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" }
nostr-lmdb.workspace = true
smol.workspace = true
gpui_tokio.workspace = true
+7 -94
View File
@@ -1,11 +1,8 @@
use std::collections::HashMap;
use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))]
use browser_signer_proxy::prelude::*;
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
use gpui_tokio::Tokio;
use instant::Duration;
use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*;
@@ -27,7 +24,7 @@ pub use nip4e::*;
pub use nip05::*;
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
pub fn init(window: &mut Window, cx: &mut App) {
// rustls uses the `aws_lc_rs` provider by default
// This only errors if the default provider has already
// been installed. We can ignore this `Result`.
@@ -40,7 +37,7 @@ pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
#[cfg(not(target_arch = "wasm32"))]
gpui_tokio::init(cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
}
struct GlobalNostrRegistry(Entity<NostrRegistry>);
@@ -61,16 +58,16 @@ pub enum StateEvent {
}
impl StateEvent {
pub fn signer_changed(&self) -> bool {
matches!(self, StateEvent::SignerChanged)
}
pub fn error<T>(error: T) -> Self
where
T: Into<String>,
{
Self::Error(error.into())
}
pub fn signer_changed(&self) -> bool {
matches!(self, StateEvent::SignerChanged)
}
}
/// Nostr Registry
@@ -103,7 +100,7 @@ impl NostrRegistry {
}
/// Create a new nostr instance
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone());
@@ -136,10 +133,6 @@ impl NostrRegistry {
if cfg!(target_arch = "wasm32") {
cx.emit(StateEvent::NoSigner);
} else if let Some(secret) = cli_key {
// Use CLI-provided key -- same path as get_user_credential
let keys = Keys::new(secret);
this.set_signer(keys, cx);
} else {
this.get_user_credential(cx);
}
@@ -265,11 +258,6 @@ impl NostrRegistry {
this.set_signer(signer, cx);
cx.notify();
})?;
} else if content == "proxy" {
#[cfg(not(target_arch = "wasm32"))]
this.update(cx, |this, cx| {
this.connect_proxy(cx);
})?;
}
}
_ => {
@@ -315,81 +303,6 @@ impl NostrRegistry {
})
}
/// Start the browser proxy
#[cfg(not(target_arch = "wasm32"))]
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
let (tx, rx) = flume::bounded::<String>(1);
self.tasks.push(Tokio::spawn_result(cx, {
let proxy = proxy.clone();
async move {
// Start the proxy and get the web url
proxy.start().await?;
// Notify GPUI
let url = proxy.url();
tx.send(url).ok();
Ok(())
}
}));
self.tasks.push(Tokio::spawn_result(cx, {
let proxy = proxy.clone();
async move {
loop {
if proxy.is_session_active() {
break;
}
smol::Timer::after(Duration::from_secs(1)).await;
}
Ok(())
}
}));
self.tasks.push(cx.spawn({
let proxy = proxy.clone();
async move |this, cx| {
while let Ok(url) = rx.recv_async().await {
this.update(cx, |this, cx| {
let save = cx.write_credentials(USER_KEYRING, "proxy", b"proxy");
cx.background_spawn(async move { save.await.ok() }).detach();
cx.open_url(&url);
this.set_signer(proxy.clone(), cx);
})?;
}
Ok(())
}
}));
// Monitor the session, if the browser disconnects, notify user to reconnect
self.tasks.push(cx.spawn({
let proxy = proxy.clone();
let executor = cx.background_executor().clone();
async move |this, cx| {
// Wait for the signer to be confirmed (timeout is 30s)
executor.timer(Duration::from_secs(30)).await;
loop {
executor.timer(Duration::from_secs(5)).await;
if !proxy.is_session_active() {
_ = this.update(cx, |this, cx| {
// Only notify if this proxy is still the active signer
if this.current_user.is_some() {
this.signer.swap_inner(Keys::generate());
this.current_user = None;
cx.emit(StateEvent::NoSigner);
cx.notify();
}
});
break;
}
}
Ok(())
}
}));
}
/// Get the public key of a NIP-05 address
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
let client = self.client();
+6 -4
View File
@@ -1,8 +1,8 @@
use gpui::prelude::FluentBuilder;
use gpui::{
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
Window, div, img, px,
IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
px,
};
use theme::ActiveTheme;
@@ -26,7 +26,9 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
/// ```
/// use ui::{Avatar};
///
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red());
/// Avatar::new("path/to/image.png")
/// .grayscale(true)
/// .border_color(gpui::red());
/// ```
#[derive(IntoElement)]
pub struct Avatar {
@@ -128,7 +130,7 @@ impl RenderOnce for Avatar {
self.image
.size(image_size)
.rounded_full()
.object_fit(ObjectFit::Cover)
.object_fit(gpui::ObjectFit::Fill)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || {
img("brand/avatar.png")
+3 -16
View File
@@ -51,7 +51,7 @@ impl Render for DragPanel {
.overflow_hidden()
.whitespace_nowrap()
.rounded(cx.theme().radius)
.text_sm()
.text_xs()
.text_color(cx.theme().text)
.text_ellipsis()
.when(cx.theme().shadow, |this| this.shadow_xs())
@@ -312,7 +312,6 @@ impl TabPanel {
cx.emit(PanelEvent::ZoomOut);
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
fn detach_panel(
@@ -322,22 +321,10 @@ impl TabPanel {
cx: &mut Context<Self>,
) {
let panel_view = panel.view();
let removed_ix = self.panels.iter().position(|p| p.view() == panel_view);
self.panels.retain(|p| p.view() != panel_view);
if self.active_ix >= self.panels.len() {
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
} else if let Some(removed_ix) = removed_ix {
if removed_ix < self.active_ix {
self.active_ix = self.active_ix.saturating_sub(1);
} else if removed_ix == self.active_ix {
// The active panel was removed and another panel shifted into
// its position. Activate the new panel at the same index.
if let Some(new_active) = self.panels.get(self.active_ix) {
new_active.set_active(true, cx);
}
self.focus_active_panel(window, cx);
}
}
}
@@ -626,7 +613,7 @@ impl TabPanel {
div()
.w_full()
.text_ellipsis()
.text_sm()
.text_xs()
.child(panel.title(cx)),
)
.when(state.draggable, |this| {
@@ -700,8 +687,8 @@ impl TabPanel {
.on_click(cx.listener({
let panel = panel.clone();
move |view, _ev, window, cx| {
cx.stop_propagation();
view.remove_panel(&panel, window, cx);
view.set_active_ix(ix, window, cx);
}
})),
)
+5 -5
View File
@@ -1,4 +1,5 @@
use std::rc::Rc;
use instant::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -6,7 +7,6 @@ use gpui::{
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
};
use instant::Duration;
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm};
@@ -359,8 +359,8 @@ impl RenderOnce for Modal {
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.;
let mut padding_right = px(16.);
let mut padding_left = px(16.);
let mut padding_right = px(8.);
let mut padding_left = px(8.);
if let Some(pl) = self.style.padding.left {
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
@@ -452,8 +452,8 @@ impl RenderOnce for Modal {
.when_some(self.max_width, |this, w| this.max_w(w))
.child(
div()
.px_4()
.h_8()
.px_2()
.h_4()
.w_full()
.flex()
.items_center()
+1 -1
View File
@@ -14,7 +14,7 @@ pub fn v_flex() -> Div {
/// Returns a `Div` as divider.
pub fn divider(cx: &App) -> Div {
div().my_1().w_full().h_px().bg(cx.theme().border_variant)
div().my_2().w_full().h_px().bg(cx.theme().border_variant)
}
macro_rules! font_weight {
+3 -17
View File
@@ -183,10 +183,12 @@ impl RenderOnce for Tab {
.items_center()
.flex_shrink_0()
.h(TABBAR_HEIGHT)
.relative()
.overflow_hidden()
.text_color(fg)
.text_sm()
.when(!self.selected && !self.disabled, |this| {
this.hover(|this| this.text_color(cx.theme().secondary_foreground))
})
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
@@ -220,21 +222,5 @@ impl RenderOnce for Tab {
this.on_click(move |event, window, cx| on_click(event, window, cx))
})
})
.child(
div()
.absolute()
.bottom_0()
.left_0()
.right_0()
.h_0p5()
.when(self.selected && !self.disabled, |this| {
this.bg(cx.theme().element_active)
})
.when(!self.selected && !self.disabled, |this| {
this.invisible().group_hover("", |this| {
this.visible().bg(cx.theme().secondary_background)
})
}),
)
}
}
-2
View File
@@ -14,13 +14,11 @@ chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
settings = { path = "../settings" }
person = { path = "../person" }
auto_update = { path = "../auto_update" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
nostr-connect.workspace = true
browser-signer-proxy = { path = "../browser-signer-proxy" }
anyhow.workspace = true
serde.workspace = true
+4 -35
View File
@@ -10,7 +10,7 @@ use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState};
use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex};
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
#[derive(Debug)]
pub struct ImportIdentity {
@@ -164,14 +164,6 @@ impl ImportIdentity {
}));
}
#[cfg(not(target_arch = "wasm32"))]
fn proxy(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.connect_proxy(cx);
});
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
@@ -207,13 +199,10 @@ impl ImportIdentity {
impl Render for ImportIdentity {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
const MSG: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
let is_wasm = cfg!(target_arch = "wasm32");
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://");
v_flex()
.size_full()
@@ -238,20 +227,13 @@ impl Render for ImportIdentity {
.child(Input::new(&self.pass_input)),
)
})
.when(bunker_warning, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_warning)
.child(div().child(BUNKER_WARN)),
)
})
.when(key_warning, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_warning)
.child(div().child(KEY_WARN)),
.child(div().font_semibold().child("Warning"))
.child(div().child(MSG)),
)
}),
)
@@ -266,19 +248,6 @@ impl Render for ImportIdentity {
this.login(window, cx);
})),
)
.child(divider(cx))
.when(!is_wasm, |this| {
this.child(
Button::new("proxy")
.label("Connect via Web Extension (Experimental)")
.ghost_alt()
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.proxy(cx);
})),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
+38 -30
View File
@@ -16,7 +16,7 @@ use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
cx.new(|cx| Screening::new(public_key, window, cx))
@@ -263,7 +263,7 @@ impl Screening {
let contacts = contacts.clone();
let total = contacts.len();
this.title("Mutual contacts").child(
this.title(SharedString::from("Mutual contacts")).child(
v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
@@ -342,7 +342,7 @@ impl Render for Screening {
.h_7()
.justify_center()
.rounded_full()
.bg(cx.theme().elevated_surface_background)
.bg(cx.theme().surface_background)
.text_sm()
.truncate()
.text_ellipsis()
@@ -355,8 +355,7 @@ impl Render for Screening {
.gap_1()
.child(
Button::new("njump")
.icon(IconName::Link)
.label("njump.me")
.label("View on njump.me")
.secondary()
.small()
.rounded()
@@ -387,18 +386,21 @@ impl Render for Screening {
.text_sm()
.child(status_badge(Some(self.followed), cx))
.child(
v_flex().text_sm().child("Contact").child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
v_flex()
.text_sm()
.child(SharedString::from("Contact"))
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
),
)
.child(
@@ -413,7 +415,7 @@ impl Render for Screening {
.child(
h_flex()
.gap_0p5()
.child("Activity on Public Relays")
.child(SharedString::from("Activity on Public Relays"))
.child(
Button::new("active")
.icon(IconName::Info)
@@ -482,8 +484,25 @@ impl Render for Screening {
.gap_2()
.child(status_badge(Some(mutuals > 0), cx))
.child(
h_flex()
v_flex()
.text_sm()
.child(
h_flex()
.gap_0p5()
.child(SharedString::from("Mutual contacts"))
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.on_click(cx.listener(
move |this, _, window, cx| {
this.mutual_contacts(window, cx);
},
)),
),
)
.child(
div()
.line_clamp(1)
@@ -495,17 +514,6 @@ impl Render for Screening {
SharedString::from(NO_MUTUAL)
}
}),
)
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.disabled(mutuals == 0)
.on_click(cx.listener(move |this, _, window, cx| {
this.mutual_contacts(window, cx);
})),
),
),
),
+2 -21
View File
@@ -2,7 +2,6 @@ use std::sync::Arc;
use ::settings::AppSettings;
use anyhow::Error;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry};
use common::{CoopImageCache, download_dir};
use device::{DeviceEvent, DeviceRegistry};
@@ -45,12 +44,13 @@ struct MsgRelayNotification;
#[action(namespace = workspace, no_json)]
enum Command {
ToggleTheme,
Update,
RefreshMessagingRelays,
BackupEncryption,
ImportEncryption,
RefreshEncryption,
ResetEncryption,
ShowRelayList,
ShowMessaging,
ShowProfile,
@@ -379,14 +379,6 @@ impl Workspace {
Command::ImportEncryption => {
self.import_encryption(window, cx);
}
Command::Update => {
let auto_updater = AutoUpdater::global(cx);
auto_updater.update(cx, |this, cx| {
this.updater.update(cx, |updater, cx| {
updater.check(cx);
});
});
}
}
}
@@ -594,11 +586,6 @@ impl Workspace {
Box::new(Command::ToggleTheme),
)
.separator()
.menu_with_icon(
"Check for Updates",
IconName::Device,
Box::new(Command::Update),
)
.menu_with_icon(
"Settings",
IconName::Settings,
@@ -610,7 +597,6 @@ impl Workspace {
}
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let updater = AutoUpdater::global(cx);
let chat = ChatRegistry::global(cx);
let nip4e_enabled = AppSettings::get_nip4e(cx);
let nostr = NostrRegistry::global(cx);
@@ -622,15 +608,10 @@ impl Workspace {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
let announcement = profile.announcement();
let updater_idle = updater.read(cx).idle(cx);
h_flex()
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
.gap_2()
.when(!updater_idle, |this| {
let status = updater.read(cx).status(cx);
this.child(div().text_xs().italic().child(status))
})
.when(nip4e_enabled, |this| {
this.child(
Button::new("key")
+3 -1
View File
@@ -164,7 +164,9 @@ impl RenderOnce for RoomEntry {
)
.on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
true
// Prevent closing the modal on click
// modal will be automatically closed after closing panel
false
})
});
}
-8
View File
@@ -354,14 +354,6 @@ impl Sidebar {
cx.notify();
});
self.new_requests = false;
// Reset search state when switching to inbox/requests
self.reset(window, cx);
// Clear the find input value
self.find_input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
}
fn render_list_items(
-1
View File
@@ -48,4 +48,3 @@ reqwest_client.workspace = true
log.workspace = true
tracing-subscriber.workspace = true
nostr-sdk.workspace = true
-51
View File
@@ -1,51 +0,0 @@
[package]
name = "coop"
version.workspace = true
edition.workspace = true
publish.workspace = true
[[bin]]
name = "coop"
path = "src/main.rs"
[package.metadata.packager]
name = "Coop"
product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop"
category = "SocialNetworking"
version = "1.0.0-beta5"
out-dir = "../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
icons = [
"resources/32x32.png",
"resources/128x128.png",
"resources/128x128@2x.png",
"resources/icon.icns",
"resources/icon.ico",
]
[dependencies]
assets = { path = "../crates/assets" }
workspace = { path = "../crates/workspace" }
ui = { path = "../crates/ui" }
theme = { path = "../crates/theme" }
common = { path = "../crates/common" }
state = { path = "../crates/state" }
device = { path = "../crates/device" }
chat = { path = "../crates/chat" }
settings = { path = "../crates/settings" }
auto_update = { path = "../crates/auto_update" }
person = { path = "../crates/person" }
gpui.workspace = true
gpui_platform.workspace = true
gpui_linux.workspace = true
gpui_windows.workspace = true
gpui_macos.workspace = true
reqwest_client.workspace = true
log.workspace = true
tracing-subscriber.workspace = true
nostr-sdk.workspace = true
+1 -32
View File
@@ -7,7 +7,6 @@ use gpui::{
actions, point, px, size,
};
use gpui_platform::application;
use nostr_sdk::prelude::SecretKey;
use state::{APP_ID, CLIENT_NAME};
use ui::Root;
@@ -17,14 +16,6 @@ fn main() {
// Initialize logging
tracing_subscriber::fmt::init();
// Parse CLI arguments for --sec <nsec1>
let cli_key = parse_cli_key();
if let Err(ref e) = cli_key {
eprintln!("Failed to parse --sec argument: {e}");
std::process::exit(1);
}
let cli_key = cli_key.unwrap();
// Run application
application()
.with_assets(Assets)
@@ -33,9 +24,6 @@ fn main() {
// Load embedded fonts in assets/fonts
load_embedded_fonts(cx);
// Set app identity
cx.set_app_identity(APP_ID, CLIENT_NAME);
// Register the `quit` function
cx.on_action(quit);
@@ -84,7 +72,7 @@ fn main() {
settings::init(window, cx);
// Initialize the nostr client
state::init(window, cx, cli_key);
state::init(window, cx);
// Initialize person registry
person::init(window, cx);
@@ -134,25 +122,6 @@ fn load_embedded_fonts(cx: &App) {
.unwrap();
}
fn parse_cli_key() -> Result<Option<SecretKey>, String> {
let args: Vec<String> = std::env::args().collect();
let mut i = 0;
while i < args.len() {
if args[i] == "--sec" {
if i + 1 < args.len() {
let nsec = &args[i + 1];
return SecretKey::parse(nsec)
.map(Some)
.map_err(|e| format!("Invalid nsec key '{nsec}': {e}"));
} else {
return Err("--sec requires a value (nsec1...)".to_string());
}
}
i += 1;
}
Ok(None)
}
fn quit(_ev: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 862 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

+1 -1
View File
@@ -59,7 +59,7 @@ pub fn run() -> Result<(), JsValue> {
settings::init(window, cx);
// Initialize the nostr client
state::init(window, cx, None);
state::init(window, cx);
// Initialize person registry
person::init(window, cx);