6 Commits
Author SHA1 Message Date
reya e9f29dc398 stabilize ids and harden release pipeline 2026-11-12 20:42:50 +07:00
reya fd802a8870 harden proxy auth and event ordering 2026-11-12 17:59:28 +07:00
reya fe0ca04817 add plan 2026-11-12 15:41:15 +07:00
reya 8fa79aef33 nip4e: drop processed-requests persistence 2026-11-12 14:39:28 +07:00
reya 8acbbaef5f feat: add onboarding dialog 2026-11-12 13:48:51 +07:00
reya 85f2bb9885 chore: update deps 2026-11-12 09:18:35 +07:00
47 changed files with 3655 additions and 917 deletions
Generated
+146 -100
View File
@@ -226,9 +226,9 @@ dependencies = [
[[package]] [[package]]
name = "annotate-snippets" name = "annotate-snippets"
version = "0.12.17" version = "0.12.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176" checksum = "0258346f92c78dbc5b336b9a5b27c64447aa10643bc64e651db2edb145361564"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"memchr", "memchr",
@@ -1152,7 +1152,7 @@ dependencies = [
[[package]] [[package]]
name = "cashu" name = "cashu"
version = "0.18.0" version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#f8eb1885b8b228226de96d39f3598da62198dcf7" source = "git+https://github.com/cashubtc/cdk#011a8ed294b03dd762c5e4cab700006f01f77e20"
dependencies = [ dependencies = [
"bitcoin", "bitcoin",
"cbor-diag", "cbor-diag",
@@ -1243,7 +1243,7 @@ dependencies = [
[[package]] [[package]]
name = "cdk" name = "cdk"
version = "0.18.0" version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#f8eb1885b8b228226de96d39f3598da62198dcf7" source = "git+https://github.com/cashubtc/cdk#011a8ed294b03dd762c5e4cab700006f01f77e20"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -1275,7 +1275,7 @@ dependencies = [
[[package]] [[package]]
name = "cdk-common" name = "cdk-common"
version = "0.18.0" version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#f8eb1885b8b228226de96d39f3598da62198dcf7" source = "git+https://github.com/cashubtc/cdk#011a8ed294b03dd762c5e4cab700006f01f77e20"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1306,7 +1306,7 @@ dependencies = [
[[package]] [[package]]
name = "cdk-http-client" name = "cdk-http-client"
version = "0.18.0" version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#f8eb1885b8b228226de96d39f3598da62198dcf7" source = "git+https://github.com/cashubtc/cdk#011a8ed294b03dd762c5e4cab700006f01f77e20"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bitreq", "bitreq",
@@ -1333,7 +1333,7 @@ dependencies = [
[[package]] [[package]]
name = "cdk-redb" name = "cdk-redb"
version = "0.18.0" version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#f8eb1885b8b228226de96d39f3598da62198dcf7" source = "git+https://github.com/cashubtc/cdk#011a8ed294b03dd762c5e4cab700006f01f77e20"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"cdk-common", "cdk-common",
@@ -1348,7 +1348,7 @@ dependencies = [
[[package]] [[package]]
name = "cdk-signatory" name = "cdk-signatory"
version = "0.18.0" version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#f8eb1885b8b228226de96d39f3598da62198dcf7" source = "git+https://github.com/cashubtc/cdk#011a8ed294b03dd762c5e4cab700006f01f77e20"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -1463,6 +1463,7 @@ dependencies = [
"gpui-pre", "gpui-pre",
"instant", "instant",
"itertools 0.13.0", "itertools 0.13.0",
"log",
"nip60_ui", "nip60_ui",
"nostr-sdk", "nostr-sdk",
"person", "person",
@@ -1681,6 +1682,7 @@ dependencies = [
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"regex", "regex",
"sha2 0.10.9",
"web-sys", "web-sys",
] ]
@@ -1808,11 +1810,8 @@ dependencies = [
"chat_ui", "chat_ui",
"gpui-kit", "gpui-kit",
"gpui-pre", "gpui-pre",
"gpui-pre-linux",
"gpui-pre-macos",
"gpui-pre-platform", "gpui-pre-platform",
"gpui-pre-reqwest-client", "gpui-pre-reqwest-client",
"gpui-pre-windows",
"log", "log",
"nip29", "nip29",
"nip4e", "nip4e",
@@ -1841,7 +1840,6 @@ dependencies = [
"gpui-kit", "gpui-kit",
"gpui-pre", "gpui-pre",
"gpui-pre-platform", "gpui-pre-platform",
"gpui-pre-web",
"instant", "instant",
"js-sys", "js-sys",
"log", "log",
@@ -3496,9 +3494,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-base" name = "gpui-base"
version = "0.8.1" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d862bc8f2e27710039013d075ac4995892e9687ff207fa530f880cbcd513936f" checksum = "f3e9ae5237737cfd110d0ff513fcd9c20f97d98ebabd0a73164068177f0aca4f"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"anyhow", "anyhow",
@@ -3537,9 +3535,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-component" name = "gpui-component"
version = "0.8.1" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46ce53430332e30f303364eaabfa10ed2aa23dfaf2a147e04370e0e9c423578b" checksum = "e155c4914bd809d966660610d596f7b57c5cd38979ca2c132215057a91f23f03"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"block2 0.6.2", "block2 0.6.2",
@@ -3589,9 +3587,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-component-macros" name = "gpui-component-macros"
version = "0.8.1" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a512c830553e7dddc43c3f8faffc2d2d7bd24db21fe3dc1326dde9c470013768" checksum = "9676a5d12c59ce295b5c82cc18365ad8b96cb683682e2e74668a56fb92544300"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
@@ -3601,9 +3599,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-kit" name = "gpui-kit"
version = "0.8.1" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25210f6e59ea9ffac3a033dbce9edafe5ff0dd2900f894fd1316f54da2fe2b89" checksum = "aca1dedfea73645b60b5893c9c16de0b1396f2cf4251d19e5f03a295c1b61879"
dependencies = [ dependencies = [
"gpui-base", "gpui-base",
"gpui-component", "gpui-component",
@@ -3616,9 +3614,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-kit-assets" name = "gpui-kit-assets"
version = "0.8.1" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c214017b45e5654665f18faa551f58a2c2c8d25efe6632b1a443ba8edf365e96" checksum = "cb5abe0bbd8f9226170c8726e529f6574519e297e9ed1bde86d5fd4d3536079a"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gpui-pre", "gpui-pre",
@@ -3631,9 +3629,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre" name = "gpui-pre"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df4b5f8d0c278308df2f11e0bfe0212dd77d7848ce1bdc4b9d513334414396f6" checksum = "29db89613527079737f53752eab27fb65c9a7dd94979256c65a0e590c9fd34d8"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"anyhow", "anyhow",
@@ -3642,7 +3640,6 @@ dependencies = [
"backtrace", "backtrace",
"bitflags 2.13.2", "bitflags 2.13.2",
"chrono", "chrono",
"ctor",
"derive_more", "derive_more",
"embed-resource", "embed-resource",
"etagere", "etagere",
@@ -3657,6 +3654,7 @@ dependencies = [
"gpui-pre-sum-tree", "gpui-pre-sum-tree",
"gpui-pre-util", "gpui-pre-util",
"heapless 0.9.3", "heapless 0.9.3",
"icu_segmenter",
"image", "image",
"inventory", "inventory",
"itertools 0.14.0", "itertools 0.14.0",
@@ -3686,6 +3684,7 @@ dependencies = [
"taffy", "taffy",
"thiserror 2.0.21", "thiserror 2.0.21",
"ttf-parser", "ttf-parser",
"unicode-segmentation",
"url", "url",
"usvg 0.46.0", "usvg 0.46.0",
"uuid", "uuid",
@@ -3696,9 +3695,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-apple" name = "gpui-pre-apple"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e97cd068d8134b4e073321b946491760aa1e4511e486084a92e73b9174bff78a" checksum = "a460605ec4e8cbcc9b213462a84b1d6cc3efe4ed8f0e18669659f07b2d5c6aec"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-task", "async-task",
@@ -3708,10 +3707,8 @@ dependencies = [
"core-foundation-sys", "core-foundation-sys",
"core-graphics 0.24.0", "core-graphics 0.24.0",
"core-text", "core-text",
"derive_more",
"dispatch2", "dispatch2",
"etagere", "etagere",
"foreign-types 0.5.0",
"futures", "futures",
"gpui-pre", "gpui-pre",
"gpui-pre-collections", "gpui-pre-collections",
@@ -3720,9 +3717,9 @@ dependencies = [
"libc", "libc",
"log", "log",
"mach2 0.6.0", "mach2 0.6.0",
"metal",
"objc2 0.6.5", "objc2 0.6.5",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-text", "objc2-core-text",
"objc2-core-video", "objc2-core-video",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
@@ -3738,9 +3735,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-collections" name = "gpui-pre-collections"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f89bf8941a7437dd761161c552543fb8268b9633fe74e29eafbe0014cd2164b" checksum = "cb60ece401b399fd5ec13911915b407b0c6efa1f6c9a9f66201aaac56c7ec39d"
dependencies = [ dependencies = [
"gpui-pre-util", "gpui-pre-util",
"indexmap 2.14.2", "indexmap 2.14.2",
@@ -3749,9 +3746,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-derive-refineable" name = "gpui-pre-derive-refineable"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2998b1cebbaa0fa004fe4939a513bbf4103c7fb69ae2e84ee73634554e3f7edc" checksum = "aeb2a3f5e6c9f300f432ae7989a81b262953ccfb3f9385be51cdc3303aef8858"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -3760,16 +3757,15 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-http-client" name = "gpui-pre-http-client"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e516820ee56ac357a87ff97b1a2c5f22c8d5dcd2a50cdd15c16949f24464d98e" checksum = "55245de7e3b6e219596fd98bb29555d6e3fe44c055d025960affeb11b894c048"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"futures", "futures",
"http", "http",
"http-body", "http-body",
"log",
"parking_lot", "parking_lot",
"serde", "serde",
"serde_json", "serde_json",
@@ -3779,9 +3775,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-http-client-tls" name = "gpui-pre-http-client-tls"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a487328e5cf704b44f3e6ca0b86b7688f2c488ce66cace071e713df94810d7" checksum = "8198d884ff64f2ae87bce95dfee1d9deeca11f3b8201803e56111015d47b2455"
dependencies = [ dependencies = [
"log", "log",
"rustls", "rustls",
@@ -3791,9 +3787,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-ios" name = "gpui-pre-ios"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a1e3a399df64f3ecc5538727fd0575279bd190959cf97a9e2b698c0310b8" checksum = "10656f6db4bbedb60bd7fcbb8a70f7c7eeee45bc4582fb8933ca807d2ddf52ad"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"block2 0.6.2", "block2 0.6.2",
@@ -3813,9 +3809,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-linux" name = "gpui-pre-linux"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c6ffd2dce7b75cce0bb3382994912f84c2a05389a4a236bb8bd72cb098fc083" checksum = "54bab418d0b7c027af9495f278619b46887362b3bd6446e246c87c5b0fa167df"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_unix", "accesskit_unix",
@@ -3858,9 +3854,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-macos" name = "gpui-pre-macos"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e07a5e5c84f0b2160c194a1c852cc89d1942db66867938ceabcd7f763cb4386" checksum = "2de7d3f4ad51ac387e822495db363fcc1188d349793365731d054444c4cb06ee"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_macos", "accesskit_macos",
@@ -3886,6 +3882,8 @@ dependencies = [
"objc2-core-graphics", "objc2-core-graphics",
"objc2-core-video", "objc2-core-video",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"objc2-uniform-type-identifiers",
"objc2-user-notifications", "objc2-user-notifications",
"parking_lot", "parking_lot",
"raw-window-handle", "raw-window-handle",
@@ -3897,9 +3895,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-macros" name = "gpui-pre-macros"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c95d70a9fe10701533606c6135f9a1d4cd96e87e4e6062b3d426eb57b415daf" checksum = "1c7f67904eced61ed42ae3320a9b4b388aed893f8547fffe9161ec425ad162eb"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro-crate", "proc-macro-crate",
@@ -3910,9 +3908,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-platform" name = "gpui-pre-platform"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a7f07a15187e82ec76491492c7e3245db1b59633d3bedb00b828ffaf67c4b8" checksum = "d2b785bdcbd229f762096aebf4f9ae913bdaa9db49794907cabaf516eb55227b"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"console_error_panic_hook", "console_error_panic_hook",
@@ -3926,9 +3924,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-refineable" name = "gpui-pre-refineable"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ce7e83592bc6c1ead2affd91b333f11b60c3eb7d06cae1605dd56f149fe8674" checksum = "4d197569e3b1fd84170f574bb866e95ecbc2625e7ae9443c877150eaf7d53374"
dependencies = [ dependencies = [
"gpui-pre-derive-refineable", "gpui-pre-derive-refineable",
] ]
@@ -3985,9 +3983,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-reqwest-client" name = "gpui-pre-reqwest-client"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca7eada23ad3c3cff486fd7ab5d92abb82236188cca61cde708adf54cb436226" checksum = "86152c439d502ac61cf3c42483bd41093bdaf9c1240b42367517218d47481866"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -4003,9 +4001,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-scheduler" name = "gpui-pre-scheduler"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87c2c6b0fabcd20e6773a0be30afa1f25637c53a06201bb80ab24a21fe198865" checksum = "b1d3fa97eeb4ea233635b756684affe96db9e29fc2af8cdc269268bd7ff649fc"
dependencies = [ dependencies = [
"async-task", "async-task",
"backtrace", "backtrace",
@@ -4020,9 +4018,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-shared-string" name = "gpui-pre-shared-string"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8068f94355f173558a51998a32985a434af54cc6c7262a060fffc369a505998f" checksum = "63c2ed1e44195079d4461b83a99845aa9a52ce8fcf1516410816670a03d32d94"
dependencies = [ dependencies = [
"schemars 1.2.3", "schemars 1.2.3",
"serde", "serde",
@@ -4031,9 +4029,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-sum-tree" name = "gpui-pre-sum-tree"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1ce66c57e9b5fd6c7a90fa9f9de2f9bb342127d670ca3c8fe5885270079eefa" checksum = "6085e61458366decd21491396b0b90b3adf928f2ec27e170c4d3c9fba455d536"
dependencies = [ dependencies = [
"gpui-pre-ztracing", "gpui-pre-ztracing",
"heapless 0.9.3", "heapless 0.9.3",
@@ -4043,9 +4041,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-util" name = "gpui-pre-util"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162dc29b2d2c0223ee4bb4e9f5e913eab4f62cad0943eb0c1122a74ac82b0768" checksum = "52ead57ff96b5a34e71d64758080347066d667e0c4bd4ff71e85f3a50643fa3e"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"log", "log",
@@ -4054,9 +4052,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-web" name = "gpui-pre-web"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1acd86af2347f0dd8b63c441a5dbd43b3fcd7dac69b340827c3497da5a13d6fb" checksum = "a85e775c6874257c63f9cf51d99f0aba13b3bc615ed8d47e7d5338e3b5941f6a"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -4084,9 +4082,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-wgpu" name = "gpui-pre-wgpu"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce693915768aae38bf788f245af75aae78e3e1179245860fc80ce831d0a8f74e" checksum = "dc76608d385f6d0dcbad44504b1bf669dac3b7d7ffa2885acdb7c1c358caf852"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytemuck", "bytemuck",
@@ -4111,9 +4109,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-windows" name = "gpui-pre-windows"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80670f987b920fba019917b2e222ecf2b377f2a6d77872010c7d284c235837b3" checksum = "7201762420222d5b5ce5bdf0056996199212a3349bfb2457a3a0238c688681f3"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_windows", "accesskit_windows",
@@ -4141,9 +4139,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-ztracing" name = "gpui-pre-ztracing"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "814cc08c526961c88703806a68ba4cf216a1d988ffbbdc673304f4dd25858952" checksum = "1043bbe8d711baf03e774b03bae6521155f017450b866dcaa987a07d978d660a"
dependencies = [ dependencies = [
"gpui-pre-ztracing-macro", "gpui-pre-ztracing-macro",
"tracing", "tracing",
@@ -4151,9 +4149,9 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-ztracing-macro" name = "gpui-pre-ztracing-macro"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0f9dae92b9bc15baa6e530c90bdd4c689d3710bd4d18c3566deb1d6ae182daf" checksum = "85701631a9bbecf47e6d19f145e85e57767e7c50f7192d5b1bf65b5ef43cb373"
[[package]] [[package]]
name = "gpui-updater-core" name = "gpui-updater-core"
@@ -4627,11 +4625,32 @@ checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"litemap", "litemap",
"serde",
"tinystr", "tinystr",
"writeable", "writeable",
"zerovec", "zerovec",
] ]
[[package]]
name = "icu_locale_fallback"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c"
dependencies = [
"icu_locale_core",
"icu_locale_fallback_data",
"icu_provider",
"potential_utf",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_locale_fallback_data"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c"
[[package]] [[package]]
name = "icu_normalizer" name = "icu_normalizer"
version = "2.4.0" version = "2.4.0"
@@ -4681,6 +4700,8 @@ checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"icu_locale_core", "icu_locale_core",
"serde",
"stable_deref_trait",
"writeable", "writeable",
"yoke", "yoke",
"zerofrom", "zerofrom",
@@ -4688,6 +4709,29 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "icu_segmenter"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710"
dependencies = [
"icu_collections",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3"
[[package]] [[package]]
name = "ident_case" name = "ident_case"
version = "1.0.1" version = "1.0.1"
@@ -4993,9 +5037,9 @@ checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4"
[[package]] [[package]]
name = "jiff" name = "jiff"
version = "0.2.42" version = "0.2.43"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fefca06093eeaed707a905795e68206da0edca4edef23d2a3997a3ddbee462aa" checksum = "26699905cc795a23e3949585cc7ba3ee8ade090872a27e4316304a777297894a"
dependencies = [ dependencies = [
"jiff-core", "jiff-core",
"jiff-static", "jiff-static",
@@ -5009,18 +5053,18 @@ dependencies = [
[[package]] [[package]]
name = "jiff-core" name = "jiff-core"
version = "0.1.4" version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a24020db0ee88d1c681d677681bd31f9f4ea189a455425223d52ce4f1508aa" checksum = "ffe6854081f5ac0a3046a9b223729ccd7a20b1e0e8277e44dc0660eedf67dfe2"
dependencies = [ dependencies = [
"log", "log",
] ]
[[package]] [[package]]
name = "jiff-static" name = "jiff-static"
version = "0.2.42" version = "0.2.43"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d3213549742662eea1a18e6c5af9be5dfbabeb7611ab71356a54928ec398f0" checksum = "4b4ff49b2887190c760b86207b55cdcaabbdecaf7a0660d68aa2ed6f652abf0a"
dependencies = [ dependencies = [
"jiff-core", "jiff-core",
"proc-macro2", "proc-macro2",
@@ -5750,21 +5794,6 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "metal"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15"
dependencies = [
"bitflags 2.13.2",
"block",
"core-graphics-types 0.2.0",
"foreign-types 0.5.0",
"log",
"objc",
"paste",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.18" version = "0.3.18"
@@ -6611,6 +6640,7 @@ dependencies = [
"objc2-core-video", "objc2-core-video",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2", "objc2-quartz-core 0.3.2",
"objc2-uniform-type-identifiers",
] ]
[[package]] [[package]]
@@ -6816,6 +6846,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.6.2", "block2 0.6.2",
"dispatch2",
"objc2 0.6.5", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
] ]
@@ -6882,6 +6913,16 @@ dependencies = [
"objc2-quartz-core 0.3.2", "objc2-quartz-core 0.3.2",
] ]
[[package]]
name = "objc2-uniform-type-identifiers"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7902ac02859fc1f7045f8b598c63f1ae0cc7efeaa06a9bc9f3d9a3c955974fa4"
dependencies = [
"objc2 0.6.5",
"objc2-foundation 0.3.2",
]
[[package]] [[package]]
name = "objc2-user-notifications" name = "objc2-user-notifications"
version = "0.3.2" version = "0.3.2"
@@ -7601,6 +7642,8 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [ dependencies = [
"serde_core",
"writeable",
"zerovec", "zerovec",
] ]
@@ -8307,9 +8350,9 @@ dependencies = [
[[package]] [[package]]
name = "rust-embed" name = "rust-embed"
version = "8.13.0" version = "8.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23" checksum = "e98f580cf7a2863aeb6827800283f52cbe6fb3f3bb7c1843783f903c16dcb8ad"
dependencies = [ dependencies = [
"rust-embed-impl", "rust-embed-impl",
"rust-embed-utils", "rust-embed-utils",
@@ -8318,9 +8361,9 @@ dependencies = [
[[package]] [[package]]
name = "rust-embed-impl" name = "rust-embed-impl"
version = "8.13.0" version = "8.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053" checksum = "a93e2f69cf224a6c7df7509aa5ba4a23f56828ee41b040e92ac8762371eb9ba7"
dependencies = [ dependencies = [
"mime_guess", "mime_guess",
"proc-macro2", "proc-macro2",
@@ -8333,9 +8376,9 @@ dependencies = [
[[package]] [[package]]
name = "rust-embed-utils" name = "rust-embed-utils"
version = "8.13.0" version = "8.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" checksum = "915e33dccbe7deb2b8a342dc4c517503fe85e0230ddbae40a4fb0220493dad9b"
dependencies = [ dependencies = [
"globset", "globset",
"sha2 0.11.1", "sha2 0.11.1",
@@ -9784,6 +9827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"serde_core",
"zerovec", "zerovec",
] ]
@@ -10887,9 +10931,9 @@ dependencies = [
[[package]] [[package]]
name = "webbrowser" name = "webbrowser"
version = "1.2.6" version = "1.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe8d20d0214027baf11b8b0cc307c1da7657d8f4db9aa956f7de27a6fb1acbd5" checksum = "73e91ccd4698f2647d6137e8eb17a3f1dcfd63810be9029fb2398f507279b95b"
dependencies = [ dependencies = [
"jni 0.22.4", "jni 0.22.4",
"log", "log",
@@ -12353,6 +12397,7 @@ dependencies = [
"displaydoc", "displaydoc",
"yoke", "yoke",
"zerofrom", "zerofrom",
"zerovec",
] ]
[[package]] [[package]]
@@ -12361,6 +12406,7 @@ version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
dependencies = [ dependencies = [
"serde",
"yoke", "yoke",
"zerofrom", "zerofrom",
"zerovec-derive", "zerovec-derive",
+6 -10
View File
@@ -9,18 +9,14 @@ edition = "2024"
publish = false publish = false
[workspace.dependencies] [workspace.dependencies]
gpui = { package = "gpui-pre", version = "0.3.10" } gpui = { package = "gpui-pre", version = "0.3.12" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.10", features = ["font-kit", "x11", "wayland"] } gpui_platform = { package = "gpui-pre-platform", version = "0.3.12", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { package = "gpui-pre-linux", version = "0.3.10" } gpui_util = { package = "gpui-pre-util", version = "0.3.12" }
gpui_windows = { package = "gpui-pre-windows", version = "0.3.10" } reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.12" }
gpui_macos = { package = "gpui-pre-macos", version = "0.3.10" }
gpui_web = { package = "gpui-pre-web", version = "0.3.10" }
gpui_util = { package = "gpui-pre-util", version = "0.3.10" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.10" }
gpui_tokio = { path = "crates/gpui_tokio" } gpui_tokio = { path = "crates/gpui_tokio" }
gpui-base = "0.8.1" gpui-base = "0.9.1"
gpui-kit = "0.8.1" gpui-kit = "0.9.1"
# Nostr # Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
+21
View File
@@ -63,6 +63,18 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {} impl Global for GlobalAutoUpdater {}
/// The subset of updater state the UI renders, so views can hold it in a small
/// entity instead of observing the whole updater.
#[derive(Clone, PartialEq)]
pub struct UpdateDisplay {
/// Whether the running version is the newest release.
pub up_to_date: bool,
/// Whether a verified update is installed and waiting for a restart.
pub staged: bool,
/// A short, human-readable description of the current status.
pub status: SharedString,
}
pub struct AutoUpdater { pub struct AutoUpdater {
updater: Entity<Updater>, updater: Entity<Updater>,
status: UpdateStatus, status: UpdateStatus,
@@ -173,6 +185,15 @@ impl AutoUpdater {
} }
} }
/// Snapshot the state the UI renders.
pub fn display(&self) -> UpdateDisplay {
UpdateDisplay {
up_to_date: self.up_to_date(),
staged: self.staged(),
status: self.status(),
}
}
/// Check the release host for a newer version, then download and install it. /// Check the release host for a newer version, then download and install it.
pub fn check(&mut self, cx: &mut Context<Self>) { pub fn check(&mut self, cx: &mut Context<Self>) {
self.updater.update(cx, |updater, cx| { self.updater.update(cx, |updater, cx| {
+3 -3
View File
@@ -5,7 +5,7 @@ async function pollForRequests() {
isPolling = true; isPolling = true;
try { try {
const response = await fetch('/api/pending'); const response = await fetch('/api/pending?token=' + encodeURIComponent(window.proxySessionToken));
const data = await response.json(); const data = await response.json();
console.log('Polled for requests, got:', data); console.log('Polled for requests, got:', data);
@@ -94,7 +94,7 @@ async function handleNip07Request(request) {
console.log('Sending response:', responsePayload); console.log('Sending response:', responsePayload);
await fetch('/api/response', { await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -117,7 +117,7 @@ async function handleNip07Request(request) {
console.log('Sending error response:', errorPayload); console.log('Sending error response:', errorPayload);
await fetch('/api/response', { await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+218 -80
View File
@@ -26,6 +26,13 @@ pub use self::error::Error;
const DEFAULT_HTML: &str = include_str!("../index.html"); const DEFAULT_HTML: &str = include_str!("../index.html");
const JS: &str = include_str!("../proxy.js"); const JS: &str = include_str!("../proxy.js");
/// Cap on the request line and headers so a peer cannot force unbounded reads
const MAX_HEADER_BYTES: usize = 16 * 1024;
/// Cap on the request body so a huge Content-Length cannot force a huge allocation
const MAX_BODY_BYTES: usize = 1024 * 1024;
/// Maximum time a connection may stay open before it is dropped
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>; type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -265,6 +272,10 @@ impl BrowserSignerProxy {
let state: Arc<ProxyState> = self.inner.state.clone(); let state: Arc<ProxyState> = self.inner.state.clone();
let custom_html: &'static str = self.inner.options.custom_html; let custom_html: &'static str = self.inner.options.custom_html;
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone(); let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
// Regenerated on every startup, never part of the opened URL
let session_token: String = Uuid::new_v4().simple().to_string();
// Only the exact host:port the server binds may appear in the Host header
let expected_host: String = addr.to_string();
smol::spawn(async move { smol::spawn(async move {
tracing::info!("Starting proxy server on {addr}"); tracing::info!("Starting proxy server on {addr}");
@@ -291,13 +302,30 @@ impl BrowserSignerProxy {
AcceptEvent::Connection(Ok((stream, _))) => { AcceptEvent::Connection(Ok((stream, _))) => {
let state: Arc<ProxyState> = state.clone(); let state: Arc<ProxyState> = state.clone();
let shutdown: Arc<ShutdownEvent> = shutdown.clone(); let shutdown: Arc<ShutdownEvent> = shutdown.clone();
let expected_host: String = expected_host.clone();
let session_token: String = session_token.clone();
smol::spawn(async move { smol::spawn(async move {
let shutdown_listener = shutdown.listen(); let shutdown_listener = shutdown.listen();
smol::future::or( smol::future::or(
async { async {
handle_connection(stream, state, custom_html).await; smol::future::or(
async {
handle_connection(
stream,
state,
custom_html,
expected_host,
session_token,
)
.await;
},
async {
smol::Timer::after(CONNECTION_TIMEOUT).await;
},
)
.await;
}, },
async { async {
shutdown_listener.await; shutdown_listener.await;
@@ -336,6 +364,18 @@ impl BrowserSignerProxy {
outgoing_requests.push(request); outgoing_requests.push(request);
} }
/// Drop a request from both queues so timed-out request data is not retained.
#[inline]
async fn cancel_request(&self, id: Uuid) {
self.inner.state.pending_responses.lock().await.remove(&id);
self.inner
.state
.outgoing_requests
.lock()
.await
.retain(|request| request.id != id);
}
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error> async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
where where
T: DeserializeOwned, T: DeserializeOwned,
@@ -345,6 +385,7 @@ impl BrowserSignerProxy {
// Construct the request // Construct the request
let request: RequestData = RequestData::new(method, params); let request: RequestData = RequestData::new(method, params);
let request_id = request.id;
// Create a bounded channel of size 1 as a oneshot replacement // Create a bounded channel of size 1 as a oneshot replacement
let (tx, rx) = channel::bounded::<Result<Value, String>>(1); let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
@@ -361,7 +402,10 @@ impl BrowserSignerProxy {
match response { match response {
Ok(Ok(res)) => Ok(serde_json::from_value(res)?), Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
Ok(Err(error)) => Err(Error::generic(error)), Ok(Err(error)) => Err(Error::generic(error)),
Err(TimeoutError) => Err(Error::timeout()), Err(TimeoutError) => {
self.cancel_request(request_id).await;
Err(Error::timeout())
}
} }
} }
@@ -372,10 +416,19 @@ impl BrowserSignerProxy {
#[inline] #[inline]
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> { async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
let expected_id: EventId = event.compute_id();
let event: Event = self let event: Event = self
.request(RequestMethod::SignEvent, serde_json::to_value(event)?) .request(RequestMethod::SignEvent, serde_json::to_value(event)?)
.await?; .await?;
event.verify()?; event.verify()?;
if event.id != expected_id {
return Err(Error::generic(format!(
"signed event doesn't match the requested event: expected ID {expected_id}, got {}",
event.id
)));
}
Ok(event) Ok(event)
} }
@@ -437,18 +490,83 @@ impl AsyncNip44 for BrowserSignerProxy {
} }
} }
// ── Minimal HTTP server ────────────────────────────────────────────────── /// Outcome of reading a line with [`read_line_capped`].
enum HeaderLine {
/// A line, including the trailing newline when present
Line(Vec<u8>),
/// Clean EOF with no data
Eof,
/// The line exceeded the allowed length
TooLong,
}
/// Read one `\n`-terminated line with a hard cap so a peer cannot force unbounded buffering.
async fn read_line_capped(
reader: &mut BufReader<TcpStream>,
limit: usize,
) -> Result<HeaderLine, std::io::Error> {
let mut line: Vec<u8> = Vec::new();
loop {
let (data, line_complete) = {
let chunk = reader.fill_buf().await?;
if chunk.is_empty() {
break;
}
match chunk.iter().position(|byte| *byte == b'\n') {
Some(index) => (chunk[..=index].to_vec(), true),
None => (chunk.to_vec(), false),
}
};
if line.len() + data.len() > limit {
return Ok(HeaderLine::TooLong);
}
let taken = data.len();
line.extend_from_slice(&data);
reader.consume(taken);
if line_complete {
return Ok(HeaderLine::Line(line));
}
}
Ok(if line.is_empty() {
HeaderLine::Eof
} else {
HeaderLine::Line(line)
})
}
/// Handle a single HTTP connection. /// Handle a single HTTP connection.
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) { async fn handle_connection(
stream: TcpStream,
state: Arc<ProxyState>,
custom_html: &'static str,
expected_host: String,
session_token: String,
) {
let mut reader = BufReader::new(stream); let mut reader = BufReader::new(stream);
let mut header_bytes: usize = 0;
// Read the request line // Read the request line
let mut request_line = String::new(); let request_line = match read_line_capped(&mut reader, MAX_HEADER_BYTES).await {
if reader.read_line(&mut request_line).await.is_err() { Ok(HeaderLine::Line(line)) => line,
Ok(HeaderLine::Eof) => Vec::new(),
Ok(HeaderLine::TooLong) => {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return; return;
} }
let request_line = request_line.trim_end().to_string(); Err(_) => return,
};
header_bytes += request_line.len();
let request_line = match String::from_utf8(request_line) {
Ok(line) => line.trim_end().to_string(),
Err(_) => {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
};
// Parse method, path, and HTTP version from request line // Parse method, path, and HTTP version from request line
let parts: Vec<&str> = request_line.split_whitespace().collect(); let parts: Vec<&str> = request_line.split_whitespace().collect();
@@ -462,24 +580,72 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
// Read headers until empty line // Read headers until empty line
let mut headers = Vec::new(); let mut headers = Vec::new();
let mut content_length: usize = 0; let mut content_length: usize = 0;
let mut host: Option<String> = None;
loop { loop {
let mut line = String::new(); let line = match read_line_capped(&mut reader, MAX_HEADER_BYTES).await {
if reader.read_line(&mut line).await.is_err() { Ok(HeaderLine::Line(line)) => line,
Ok(HeaderLine::Eof) => break,
Ok(HeaderLine::TooLong) => {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return; return;
} }
let line = line.trim_end().to_string(); Err(_) => return,
};
header_bytes += line.len();
if header_bytes > MAX_HEADER_BYTES {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let line = match String::from_utf8(line) {
Ok(line) => line.trim_end().to_string(),
Err(_) => {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
};
if line.is_empty() { if line.is_empty() {
break; break;
} }
if let Some(value) = line.strip_prefix("content-length:") { if let Some(value) = line.strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap_or(0); content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("Content-Length:") { } else if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = value.trim().parse().unwrap_or(0); content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("host:") {
host = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("Host:") {
host = Some(value.trim().to_string());
} }
headers.push(line); headers.push(line);
} }
match (method.as_str(), path.as_str()) { // Reject oversized bodies before allocating the read buffer
if content_length > MAX_BODY_BYTES {
send_response(&mut reader, 413, "Payload Too Large", "", "").await;
return;
}
// DNS rebinding defense: only the exact bound host:port is accepted
if host.as_deref() != Some(expected_host.as_str()) {
send_response(&mut reader, 403, "Forbidden", "", "").await;
return;
}
let (route, query) = match path.split_once('?') {
Some((route, query)) => (route, Some(query)),
None => (path.as_str(), None),
};
if route.starts_with("/api/") && query_param(query, "token") != Some(session_token.as_str()) {
send_response(&mut reader, 403, "Forbidden", "", "").await;
return;
}
match (method.as_str(), route) {
// Serve the HTML proxy page // Serve the HTML proxy page
("GET", "/") => { ("GET", "/") => {
let html = if custom_html.is_empty() { let html = if custom_html.is_empty() {
@@ -487,7 +653,8 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
} else { } else {
custom_html custom_html
}; };
send_response(&mut reader, 200, "OK", "text/html", html).await; let html = inject_session_token(html, &session_token);
send_response(&mut reader, 200, "OK", "text/html", &html).await;
} }
// Serve the JS proxy script // Serve the JS proxy script
("GET", "/proxy.js") => { ("GET", "/proxy.js") => {
@@ -516,7 +683,7 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
// Clear the outgoing requests after sending them // Clear the outgoing requests after sending them
outgoing.clear(); outgoing.clear();
send_response_cors_json(&mut reader, 200, "OK", &json).await; send_response(&mut reader, 200, "OK", "application/json", &json).await;
} }
// Receive response from browser extension // Receive response from browser extension
("POST", "/api/response") => { ("POST", "/api/response") => {
@@ -535,33 +702,32 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
} }
}; };
tracing::debug!("Received response from browser: {message:?}"); // Never log the response value, it can contain decrypted plaintext
let outcome = if message.error.is_some() {
"error"
} else {
"ok"
};
tracing::debug!(
"Received response from browser: id={} outcome={outcome}",
message.id
);
let id: Uuid = message.id; let id: Uuid = message.id;
let mut pending = state.pending_responses.lock().await; let mut pending = state.pending_responses.lock().await;
match pending.remove(&id) { match pending.remove(&id) {
Some(sender) => { Some(sender) => match sender.try_send(message.into_result()) {
// Use try_send since we already hold the lock Ok(()) => tracing::info!("Forwarded response for request {id}"),
let _ = sender.try_send(message.into_result()); Err(e) => {
tracing::info!("Forwarded response for request {id}"); tracing::warn!("Failed to forward response for request {id}: {e}")
} }
},
None => tracing::warn!("No pending request found for {id}"), None => tracing::warn!("No pending request found for {id}"),
} }
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await; send_response(&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 // 404 - not found
_ => { _ => {
@@ -585,68 +751,44 @@ async fn send_response(
} }
response.push_str(&format!("Content-Length: {}\r\n", body.len())); 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("Connection: close\r\n");
response.push_str("\r\n"); response.push_str("\r\n");
response.push_str(body); response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await; write_response(stream, &response).await;
let _ = stream.flush().await;
} }
/// Write a response with CORS headers and JSON content type. async fn write_response(stream: &mut (impl AsyncWriteExt + Unpin), response: &str) {
async fn send_response_cors_json( if let Err(e) = stream.write_all(response.as_bytes()).await {
stream: &mut (impl AsyncWriteExt + Unpin), tracing::debug!("Failed to write response to peer: {e}");
status: u16, }
status_text: &str, if let Err(e) = stream.flush().await {
body: &str, tracing::debug!("Failed to flush response to peer: {e}");
) { }
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. /// Get a parameter value from an optional query string.
async fn send_response_cors( fn query_param<'a>(query: Option<&'a str>, name: &str) -> Option<&'a str> {
stream: &mut (impl AsyncWriteExt + Unpin), let query = query?;
status: u16, query.split('&').find_map(|pair| {
status_text: &str, let (key, value) = pair.split_once('=')?;
content_type: &str, (key == name).then_some(value)
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())); fn inject_session_token(html: &str, token: &str) -> String {
response.push_str("Access-Control-Allow-Origin: *\r\n"); let script = format!("<script>window.proxySessionToken=\"{token}\";</script>");
response.push_str("Connection: close\r\n"); match html.rfind("</body>") {
response.push_str("\r\n"); Some(index) => format!("{}{script}{}", &html[..index], &html[index..]),
response.push_str(body); None => format!("{html}{script}"),
}
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
} }
// ── Timeout helper ───────────────────────────────────────────────────────
/// An error indicating that an operation timed out. /// An error indicating that an operation timed out.
#[derive(Debug)] #[derive(Debug)]
struct TimeoutError; struct TimeoutError;
/// Races a channel receive against a duration. /// 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>( async fn race_timeout<T>(
duration: Duration, duration: Duration,
recv: impl Future<Output = Result<T, channel::RecvError>>, recv: impl Future<Output = Result<T, channel::RecvError>>,
@@ -677,10 +819,6 @@ async fn race_timeout<T>(
} }
} }
// ── Utility ──────────────────────────────────────────────────────────────
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
/// time is before the epoch, returns 0.
#[inline] #[inline]
fn current_time() -> u64 { fn current_time() -> u64 {
SystemTime::now() SystemTime::now()
+18 -1
View File
@@ -396,10 +396,27 @@ impl ChatRegistry {
} }
if !errors.is_empty() { if !errors.is_empty() {
let mut newly_trashed = 0;
trash.update(cx, |trash, cx| { trash.update(cx, |trash, cx| {
trash.extend(errors); newly_trashed = errors
.iter()
.filter(|message| trash.insert((*message).clone()))
.count();
cx.notify(); cx.notify();
})?; })?;
// Only newly trashed messages emit an error event so repeated
// reprocess attempts of the same message don't re-alert the user
if newly_trashed > 0 {
let reason = if newly_trashed == 1 {
"A message could not be decrypted".to_string()
} else {
format!("{newly_trashed} messages could not be decrypted")
};
this.update(cx, |_, cx| {
cx.emit(ChatEvent::Error(reason));
})?;
}
} }
if eose && tracking.swap(false, Ordering::AcqRel) { if eose && tracking.swap(false, Ordering::AcqRel) {
+1
View File
@@ -18,6 +18,7 @@ gpui.workspace = true
instant.workspace = true instant.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
anyhow.workspace = true anyhow.workspace = true
log.workspace = true
itertools.workspace = true itertools.workspace = true
smallvec.workspace = true smallvec.workspace = true
serde.workspace = true serde.workspace = true
+108 -20
View File
@@ -1,3 +1,4 @@
use std::cmp::Ordering;
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, LazyLock, RwLock}; use std::sync::{Arc, LazyLock, RwLock};
use std::time::Duration; use std::time::Duration;
@@ -220,7 +221,7 @@ impl ChatPanel {
// Define all functions that will run after the current cycle // Define all functions that will run after the current cycle
cx.defer_in(window, |this, window, cx| { cx.defer_in(window, |this, window, cx| {
this.connect(cx); this.connect(window, cx);
this.subscribe_room_events(window, cx); this.subscribe_room_events(window, cx);
this.get_messages(window, cx); this.get_messages(window, cx);
}); });
@@ -269,11 +270,24 @@ impl ChatPanel {
} }
/// Get messaging relays and announcement for each member /// Get messaging relays and announcement for each member
fn connect(&mut self, cx: &mut Context<Self>) { fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(room) = self.room.upgrade() { let Some(room) = self.room.upgrade() else {
let task = room.read(cx).connect(cx); return;
self.tasks.push(task); };
let connect = room.read(cx).connect(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
if let Err(error) = connect.await {
log::error!("Connecting room relays failed: {error}");
this.update_in(cx, |this, window, cx| {
this.notify_failure("Connection failed", error, window, cx);
})?;
} }
Ok(())
}));
} }
/// Subscribe to room events /// Subscribe to room events
@@ -317,23 +331,40 @@ impl ChatPanel {
} }
/// Load all messages belonging to this room /// Load all messages belonging to this room
fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) { fn get_messages(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else { let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else {
return; return;
}; };
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let events = get_messages.await?; match get_messages.await {
Ok(events) => {
// Update message list
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.insert_messages(&events, cx); this.insert_messages(&events, cx);
})?; })?;
}
Err(error) => {
log::error!("Loading room messages failed: {error}");
this.update_in(cx, |this, window, cx| {
this.notify_failure("Failed to load messages", error, window, cx);
})?;
}
}
Ok(()) Ok(())
})); }));
} }
/// Show a failure of a background operation to the user until dismissed
fn notify_failure(&self, title: &'static str, error: Error, window: &mut Window, cx: &mut App) {
window.push_notification(
Notification::error(error.to_string())
.title(title)
.autohide(false),
cx,
);
}
/// Get user input content and merged all attachments if available /// Get user input content and merged all attachments if available
fn get_input_value(&self, cx: &Context<Self>) -> String { fn get_input_value(&self, cx: &Context<Self>) -> String {
// Get input's value // Get input's value
@@ -566,8 +597,9 @@ impl ChatPanel {
for (i, message) in self.messages.iter().enumerate().skip(pos) { for (i, message) in self.messages.iter().enumerate().skip(pos) {
self.message_index.insert(message.id, i); self.message_index.insert(message.id, i);
} }
let len = self.list_state.item_count(); // The welcome row occupies row zero, so message rows sit one ahead
self.list_state.splice(len..len, 1); let row = pos + 1;
self.list_state.splice(row..row, 1);
if scroll { if scroll {
self.list_state.scroll_to(ListOffset { self.list_state.scroll_to(ListOffset {
@@ -582,13 +614,62 @@ impl ChatPanel {
/// Convert and insert a vector of nostr events into the chat panel /// Convert and insert a vector of nostr events into the chat panel
fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) { fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) {
let mut incoming: Vec<Message> = Vec::with_capacity(events.len());
for event in events.iter() { for event in events.iter() {
if event.kind == Kind::Reaction { if event.kind == Kind::Reaction {
self.insert_reaction(event, cx); self.insert_reaction(event, cx);
} else {
incoming.push(event.into());
}
}
incoming.sort();
let old_len = self.messages.len();
let mut stored = std::mem::take(&mut self.messages).into_iter().peekable();
let mut merged: Vec<Message> = Vec::with_capacity(old_len + incoming.len());
let mut insert_start = None;
let mut inserted = 0usize;
for message in incoming {
while let Some(old) = stored.next_if(|old| old.cmp(&message) == Ordering::Less) {
merged.push(old);
}
let duplicate = stored
.peek()
.is_some_and(|old| old.cmp(&message) == Ordering::Equal)
|| merged
.last()
.is_some_and(|last| last.cmp(&message) == Ordering::Equal);
if duplicate {
continue; continue;
} }
// Bulk inserting messages, so no need to scroll to the latest message
self.insert_message(event, false, cx); if inserted == 0 {
insert_start = Some(merged.len());
}
inserted += 1;
merged.push(message);
}
merged.extend(stored);
self.messages = merged;
if let Some(start) = insert_start {
self.message_index = self
.messages
.iter()
.enumerate()
.map(|(index, message)| (message.id, index))
.collect();
let row = start + 1;
self.list_state.splice(row..row, inserted);
cx.notify();
} }
} }
@@ -598,16 +679,23 @@ impl ChatPanel {
return; return;
} }
let content = SharedString::from(&event.content);
let emojis = emojis_from_tags(&event.tags); let emojis = emojis_from_tags(&event.tags);
let image = Message::reaction_image(&event.content, &emojis) let image = Message::reaction_image(&event.content, &emojis)
.map(|url| SharedUri::from(url.to_string())); .map(|url| SharedUri::from(url.to_string()));
for id in event.tags.event_ids() { for id in event.tags.event_ids() {
self.reactions.entry(id).or_default().push(( let entries = self.reactions.entry(id).or_default();
SharedString::from(&event.content), // NIP-25: a newer reaction by the same author replaces their previous one
image.clone(), if let Some(existing) = entries
event.pubkey, .iter_mut()
)); .find(|(_, _, author)| *author == event.pubkey)
{
*existing = (content.clone(), image.clone(), event.pubkey);
} else {
entries.push((content.clone(), image.clone(), event.pubkey));
}
} }
cx.notify(); cx.notify();
@@ -1101,7 +1189,7 @@ impl ChatPanel {
svg() svg()
.path("brand/coop.svg") .path("brand/coop.svg")
.size_12() .size_12()
.text_color(cx.theme().list_active), .text_color(cx.theme().muted_foreground),
) )
.child(PRIVATE_NOTICE) .child(PRIVATE_NOTICE)
.into_any_element() .into_any_element()
+1
View File
@@ -9,6 +9,7 @@ gpui.workspace = true
nostr.workspace = true nostr.workspace = true
instant.workspace = true instant.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
sha2.workspace = true
itertools.workspace = true itertools.workspace = true
chrono.workspace = true chrono.workspace = true
+99 -23
View File
@@ -1,20 +1,18 @@
use std::hash::{DefaultHasher, Hash, Hasher};
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use sha2::{Digest, Sha256};
const HEX_DIGITS: [u8; 16] = *b"0123456789abcdef";
pub trait EventExt { pub trait EventExt {
/// Id over the event's participants, stable across Rust versions.
fn uniq_id(&self) -> u64; fn uniq_id(&self) -> u64;
fn extract_public_keys(&self) -> Vec<PublicKey>; fn extract_public_keys(&self) -> Vec<PublicKey>;
} }
impl EventExt for Event { impl EventExt for Event {
fn uniq_id(&self) -> u64 { fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new(); pubkey_set_uniq_id(&self.extract_public_keys())
let mut pubkeys: Vec<PublicKey> = self.extract_public_keys();
pubkeys.sort();
pubkeys.hash(&mut hasher);
hasher.finish()
} }
fn extract_public_keys(&self) -> Vec<PublicKey> { fn extract_public_keys(&self) -> Vec<PublicKey> {
@@ -27,31 +25,62 @@ impl EventExt for Event {
impl EventExt for UnsignedEvent { impl EventExt for UnsignedEvent {
fn uniq_id(&self) -> u64 { fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new(); pubkey_set_uniq_id(&self.extract_public_keys())
let mut pubkeys: Vec<PublicKey> = vec![];
// Add all public keys from event
pubkeys.push(self.pubkey);
pubkeys.extend(self.tags.public_keys().collect::<Vec<_>>());
// Generate unique hash
pubkeys
.into_iter()
.unique()
.sorted()
.collect::<Vec<_>>()
.hash(&mut hasher);
hasher.finish()
} }
fn extract_public_keys(&self) -> Vec<PublicKey> { fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect(); let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
public_keys.push(self.pubkey); public_keys.push(self.pubkey);
public_keys.into_iter().unique().sorted().collect() public_keys.into_iter().unique().sorted().collect()
} }
} }
/// Digest over NUL-separated parts, so part boundaries stay unambiguous.
fn parts_digest(parts: &[&str]) -> [u8; 32] {
let mut hasher = Sha256::new();
for part in parts {
hasher.update(part.as_bytes());
hasher.update([0]);
}
hasher.finalize().into()
}
/// Stable id over parts: hex-encoded SHA-256 truncated to 128 bits.
pub fn stable_id(parts: &[&str]) -> String {
let digest = parts_digest(parts);
let mut id = String::with_capacity(32);
for byte in &digest[..16] {
id.push(HEX_DIGITS[usize::from(byte >> 4)] as char);
id.push(HEX_DIGITS[usize::from(byte & 0x0f)] as char);
}
id
}
/// Stable 64-bit id over parts, for callers that key ids on `u64`.
pub fn stable_id_u64(parts: &[&str]) -> u64 {
let digest = parts_digest(parts);
let mut lead = [0u8; 8];
for (slot, byte) in lead.iter_mut().zip(digest.iter()) {
*slot = *byte;
}
u64::from_be_bytes(lead)
}
/// Hashes the sorted hex forms of the participant set.
fn pubkey_set_uniq_id(public_keys: &[PublicKey]) -> u64 {
let mut parts: Vec<String> = public_keys.iter().map(|key| key.to_string()).collect();
parts.sort();
let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
stable_id_u64(&parts)
}
/// Custom emoji (NIP-30) declared by `emoji` tags, first per shortcode wins /// Custom emoji (NIP-30) declared by `emoji` tags, first per shortcode wins
pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> { pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> {
let mut emojis: Vec<(String, Url)> = Vec::new(); let mut emojis: Vec<(String, Url)> = Vec::new();
@@ -100,4 +129,51 @@ mod tests {
Url::parse("https://example.com/soapbox.png").unwrap() Url::parse("https://example.com/soapbox.png").unwrap()
); );
} }
fn event_with_participants(public_key: PublicKey, participants: Vec<PublicKey>) -> Event {
let tags = Tags::from_list(participants.into_iter().map(Tag::public_key).collect());
Event::new(
EventId::from_byte_array([0; 32]),
public_key,
Timestamp::from(0u64),
Kind::TextNote,
tags,
"content".to_string(),
Signature::from_byte_array([0; 64]),
)
}
#[test]
fn uniq_id_ignores_participant_order() {
let me = Keys::new(SecretKey::generate()).public_key();
let alice = Keys::new(SecretKey::generate()).public_key();
let bob = Keys::new(SecretKey::generate()).public_key();
let first = event_with_participants(me, vec![alice, bob]);
let second = event_with_participants(me, vec![bob, alice]);
assert_eq!(first.uniq_id(), second.uniq_id());
}
#[test]
fn uniq_id_tracks_participants() {
let me = Keys::new(SecretKey::generate()).public_key();
let alice = Keys::new(SecretKey::generate()).public_key();
let bob = Keys::new(SecretKey::generate()).public_key();
let with_alice = event_with_participants(me, vec![alice]);
let with_bob = event_with_participants(me, vec![bob]);
assert_ne!(with_alice.uniq_id(), with_bob.uniq_id());
}
#[test]
fn stable_id_is_deterministic_128_bit_hex() {
let id = stable_id(&["alpha", "beta"]);
assert_eq!(id.len(), 32);
assert!(id.chars().all(|character| character.is_ascii_hexdigit()));
assert_eq!(id, stable_id(&["alpha", "beta"]));
}
} }
+141 -45
View File
@@ -1,65 +1,161 @@
use std::sync::LazyLock;
use gpui::SharedUri; use gpui::SharedUri;
use regex::Regex; use regex::Regex;
/// Extracts media URLs from a string and returns both /// Matches image URLs with common image extensions
/// the extracted URLs and the string with media URLs removed static IMAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
struct MediaExtractor { Regex::new(
image_regex: Regex, r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
} )
.unwrap()
});
/// Extracts media URLs from a string and returns the URLs and the cleaned text
struct MediaExtractor;
impl MediaExtractor { impl MediaExtractor {
/// Creates a new MediaExtractor with compiled regex patterns
fn new() -> Self {
MediaExtractor {
// Match common image extensions
image_regex: Regex::new(
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
).unwrap(),
}
}
/// Extracts all media URLs from a string /// Extracts all media URLs from a string
fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> { fn extract_media_urls(text: &str) -> Vec<SharedUri> {
let mut urls = Vec::new(); IMAGE_RE
.find_iter(text)
// Extract image URLs .map(|capture| capture.as_str().to_string().into())
for capture in self.image_regex.find_iter(text) { .collect()
urls.push(capture.as_str().to_string().into());
}
urls
} }
/// Removes all media URLs from a string and returns the cleaned text /// Removes all media URLs from a string and returns the cleaned text
fn remove_media_urls(&self, text: &str) -> String { fn remove_media_urls(text: &str) -> String {
let mut result = text.to_string(); let mut cleaned = String::with_capacity(text.len());
let mut last_end = 0;
// Whitespace bordered a removed URL, so one space stands in for it
let mut spaced = false;
// Remove image URLs for url in IMAGE_RE.find_iter(text) {
result = self.image_regex.replace_all(&result, "").to_string(); let raw_gap = &text[last_end..url.start()];
let surrounded = raw_gap.ends_with(char::is_whitespace)
|| text[url.end()..].starts_with(char::is_whitespace);
// Clean up extra whitespace that might result from removal let gap = if last_end == 0 {
self.cleanup_text(&result) raw_gap.trim_end()
} else {
raw_gap.trim()
};
if !gap.is_empty() {
if spaced && !cleaned.is_empty() {
cleaned.push(' ');
}
cleaned.push_str(gap);
}
spaced |= surrounded;
last_end = url.end();
}
let tail = text[last_end..].trim_start();
if spaced && !cleaned.is_empty() && !tail.is_empty() {
cleaned.push(' ');
}
cleaned.push_str(tail);
cleaned.trim().to_string()
} }
/// Extracts media URLs and removes them from the string, returning both /// Extracts media URLs and removes them from the string, returning both
fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) { fn extract_and_remove(text: &str) -> (Vec<SharedUri>, String) {
let urls = self.extract_media_urls(text); (
let cleaned_text = self.remove_media_urls(text); Self::extract_media_urls(text),
(urls, cleaned_text) Self::remove_media_urls(text),
} )
/// Helper function to clean up text after URL removal
fn cleanup_text(&self, text: &str) -> String {
let text = text.trim();
// Remove multiple consecutive spaces
let re = Regex::new(r"\s+").unwrap();
re.replace_all(text, " ").trim().to_string()
} }
} }
/// Convenience function for one-time extraction and removal /// Convenience function for one-time extraction and removal
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) { pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
let extractor = MediaExtractor::new(); MediaExtractor::extract_and_remove(text)
extractor.extract_and_remove(text) }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multi_line_text_without_urls_keeps_line_breaks() {
let text = "first line\n\nsecond line";
let (urls, cleaned) = extract_and_remove_media_urls(text);
assert!(urls.is_empty());
assert_eq!(cleaned, text);
}
#[test]
fn removes_url_and_tidies_around_it() {
let (urls, cleaned) =
extract_and_remove_media_urls("check https://example.com/pic.png here");
assert_eq!(urls, vec![SharedUri::from("https://example.com/pic.png")]);
assert_eq!(cleaned, "check here");
}
#[test]
fn removing_url_keeps_other_line_breaks() {
let (urls, cleaned) = extract_and_remove_media_urls(
"first\nsecond https://example.com/pic.png third\nfourth",
);
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "first\nsecond third\nfourth");
}
#[test]
fn url_on_its_own_line_joins_neighbors_with_one_space() {
let (urls, cleaned) =
extract_and_remove_media_urls("first\n\nhttps://example.com/pic.png\n\nlast");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "first last");
}
#[test]
fn multiple_urls_collapse_to_single_spaces() {
let (urls, cleaned) = extract_and_remove_media_urls(
"a https://example.com/a.png b https://example.com/b.png c",
);
assert_eq!(urls.len(), 2);
assert_eq!(cleaned, "a b c");
}
#[test]
fn message_with_only_a_url_becomes_empty() {
let (urls, cleaned) = extract_and_remove_media_urls("https://example.com/pic.png");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "");
}
#[test]
fn digits_glued_to_url_scheme_prevent_match() {
let text = "v1.2https://example.com/pic.png";
let (urls, cleaned) = extract_and_remove_media_urls(text);
assert!(urls.is_empty());
assert_eq!(cleaned, text);
}
#[test]
fn digits_around_removed_url_survive() {
let (urls, cleaned) = extract_and_remove_media_urls("123 https://example.com/pic.png 456");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "123 456");
}
#[test]
fn emoji_around_removed_url_survives() {
let (urls, cleaned) = extract_and_remove_media_urls("👍 https://example.com/pic.png 👍");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "👍 👍");
}
} }
+36 -42
View File
@@ -184,20 +184,11 @@ impl Iterator for FindMatches<'_> {
} }
} }
enum HandleMatch {
Token(Token),
Recursion,
}
pub struct NostrParserIter<'a> { pub struct NostrParserIter<'a> {
/// The original text /// The original text
text: &'a str, text: &'a str,
/// Matches found /// Matches found
matches: FindMatches<'a>, matches: FindMatches<'a>,
/// A pending match
pending_match: Option<Match>,
/// Last match end index
last_match_end: usize,
} }
impl<'a> NostrParserIter<'a> { impl<'a> NostrParserIter<'a> {
@@ -205,62 +196,65 @@ impl<'a> NostrParserIter<'a> {
Self { Self {
text, text,
matches: FindMatches::new(text), matches: FindMatches::new(text),
pending_match: None,
last_match_end: 0,
} }
} }
fn handle_match(&mut self, mat: Match) -> HandleMatch { fn handle_match(&self, mat: Match) -> Option<Token> {
// Update last match end
self.last_match_end = mat.end;
// Extract the matched string // Extract the matched string
let data: &str = &self.text[mat.start..mat.end]; let data: &str = &self.text[mat.start..mat.end];
// Parse NIP-21 URI // Parse NIP-21 URI
match Nip21::parse(data) { match Nip21::parse(data) {
Ok(uri) => HandleMatch::Token(Token { Ok(uri) => Some(Token {
value: uri, value: uri,
range: mat.start..mat.end, range: mat.start..mat.end,
}), }),
// If the nostr URI parsing is invalid, skip it // If the nostr URI parsing is invalid, skip it
Err(_) => HandleMatch::Recursion, Err(_) => None,
} }
} }
} }
impl<'a> Iterator for NostrParserIter<'a> { impl Iterator for NostrParserIter<'_> {
type Item = Token; type Item = Token;
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
// Handle a pending match // Iterative, not recursive: message text controls the match count
if let Some(pending_match) = self.pending_match.take() { loop {
return match self.handle_match(pending_match) { let mat = self.matches.next()?;
HandleMatch::Token(token) => Some(token),
HandleMatch::Recursion => self.next(), if let Some(token) = self.handle_match(mat) {
}; return Some(token);
}
}
}
} }
match self.matches.next() { #[cfg(test)]
Some(mat) => { mod tests {
// Skip any text before this match use super::*;
if mat.start > self.last_match_end {
// Update pending match
// This will be handled at next iteration, in `handle_match` method.
self.pending_match = Some(mat);
// Skip the text before the match // A long run of scanner matches that fail `Nip21::parse`: the count is
self.last_match_end = mat.start; // attacker-controlled, so it must be consumed iteratively.
return self.next(); #[test]
fn skips_long_run_of_invalid_uris() {
let content = "nostr:1q".repeat(50_000);
let tokens: Vec<Token> = NostrParser::new().parse(&content).collect();
assert!(tokens.is_empty());
} }
// Handle match #[test]
match self.handle_match(mat) { fn yields_valid_uri_after_invalid_run() {
HandleMatch::Token(token) => Some(token), let public_key = Keys::new(SecretKey::generate()).public_key();
HandleMatch::Recursion => self.next(), let npub = public_key.to_bech32().unwrap();
} let content = format!("{}nostr:{npub}", "nostr:1q".repeat(50_000));
}
None => None, let tokens: Vec<Token> = NostrParser::new().parse(&content).collect();
}
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].range.start, 400_000);
assert_eq!(tokens[0].value, Nip21::Pubkey(public_key));
} }
} }
+16 -3
View File
@@ -13,7 +13,7 @@ use crate::LOCAL_KEYS;
use crate::protocol::{ use crate::protocol::{
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
TimelineWindow, tag_value, TimelineWindow, is_newer_event, tag_value,
}; };
const REPLAY_LIMIT: usize = 100; const REPLAY_LIMIT: usize = 100;
@@ -91,7 +91,8 @@ pub struct Group {
membership: Membership, membership: Membership,
membership_events: Vec<Event>, membership_events: Vec<Event>,
elsewhere: Option<GroupKey>, elsewhere: Option<GroupKey>,
state_ids: HashMap<Kind, EventId>, /// The last accepted (created_at, id) per state kind.
state_ids: HashMap<Kind, (Timestamp, EventId)>,
rows: Vec<Row>, rows: Vec<Row>,
index: HashSet<EventId>, index: HashSet<EventId>,
reactions: HashMap<EventId, Vec<Reaction>>, reactions: HashMap<EventId, Vec<Reaction>>,
@@ -694,6 +695,15 @@ impl Group {
} }
fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool { fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool {
// Relays deliver same-kind state events in any order.
if !is_newer_event(
self.state_ids.get(&event.kind).copied(),
event.created_at,
event.id,
) {
return false;
}
match event.kind { match event.kind {
Kind::GroupMetadata => match GroupMetadata::parse(event) { Kind::GroupMetadata => match GroupMetadata::parse(event) {
Ok(metadata) => self.metadata = Some(metadata), Ok(metadata) => self.metadata = Some(metadata),
@@ -724,7 +734,10 @@ impl Group {
_ => return false, _ => return false,
} }
self.state_ids.insert(event.kind, event.id) != Some(event.id) self.state_ids
.insert(event.kind, (event.created_at, event.id));
true
} }
fn adopt_roster_membership(&mut self, me: PublicKey) -> bool { fn adopt_roster_membership(&mut self, me: PublicKey) -> bool {
+37 -11
View File
@@ -76,6 +76,8 @@ pub struct GroupsRegistry {
groups: Vec<Entity<Group>>, groups: Vec<Entity<Group>>,
previews: HashMap<GroupKey, Entity<Group>>, previews: HashMap<GroupKey, Entity<Group>>,
list: GroupList, list: GroupList,
/// The (created_at, id) of the last accepted group list event.
list_stamp: Option<(Timestamp, EventId)>,
browse: HashMap<SubscriptionId, RelayUrl>, browse: HashMap<SubscriptionId, RelayUrl>,
routes: HashMap<SubscriptionId, GroupKey>, routes: HashMap<SubscriptionId, GroupKey>,
observers: HashMap<GroupKey, Subscription>, observers: HashMap<GroupKey, Subscription>,
@@ -132,6 +134,7 @@ impl GroupsRegistry {
groups: Vec::new(), groups: Vec::new(),
previews: HashMap::new(), previews: HashMap::new(),
list: GroupList::default(), list: GroupList::default(),
list_stamp: None,
browse: HashMap::new(), browse: HashMap::new(),
routes: HashMap::new(), routes: HashMap::new(),
observers: HashMap::new(), observers: HashMap::new(),
@@ -455,6 +458,8 @@ impl GroupsRegistry {
self.fork_authors.clear(); self.fork_authors.clear();
self.syncing.clear(); self.syncing.clear();
self.list = GroupList::default(); self.list = GroupList::default();
// A new signer's list events must not be judged against the old stamp.
self.list_stamp = None;
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
@@ -488,24 +493,26 @@ impl GroupsRegistry {
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
// Show the cached groups before the relays answer. // Show the cached groups before the relays answer.
let task: Task<Result<GroupList, Error>> = cx.background_spawn(async move { let task: Task<Result<(GroupList, Option<Event>), Error>> =
cx.background_spawn(async move {
let me = signer.get_public_key_async().await?; let me = signer.get_public_key_async().await?;
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1); let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
match client.database().query(filter).await?.into_iter().next() { match client.database().query(filter).await?.into_iter().next() {
Some(event) => { Some(event) => {
let list = GroupList::parse(&event)?; let list =
Ok(Self::hydrate_list(list, &signer, &me).await) Self::hydrate_list(GroupList::parse(&event)?, &signer, &me).await;
Ok((list, Some(event)))
} }
None => Ok(GroupList::default()), None => Ok((GroupList::default(), None)),
} }
}); });
self.tasks self.tasks
.push(cx.spawn(async move |this, cx| match task.await { .push(cx.spawn(async move |this, cx| match task.await {
Ok(list) => { Ok((list, event)) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.merge(list, cx); this.merge(list, event.as_ref(), cx);
}) })
.ok(); .ok();
} }
@@ -538,7 +545,16 @@ impl GroupsRegistry {
self.watch_forks(cx); self.watch_forks(cx);
} }
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) { fn merge(&mut self, list: GroupList, event: Option<&Event>, cx: &mut Context<Self>) {
if let Some(event) = event {
// Relays replay list events out of order; only strictly newer applies.
if !is_newer_event(self.list_stamp, event.created_at, event.id) {
return;
}
self.list_stamp = Some((event.created_at, event.id));
}
// Carry the event's private section so a later publish can preserve it. // Carry the event's private section so a later publish can preserve it.
self.list self.list
.set_content(list.content().map(str::to_owned), list.content_readable()); .set_content(list.content().map(str::to_owned), list.content_readable());
@@ -589,20 +605,21 @@ impl GroupsRegistry {
}; };
if list.content().is_none() { if list.content().is_none() {
self.merge(list, cx); self.merge(list, Some(&event), cx);
return; return;
} }
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let Some(me) = nostr.read(cx).current_user() else { let Some(me) = nostr.read(cx).current_user() else {
self.merge(list, cx); self.merge(list, Some(&event), cx);
return; return;
}; };
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let list = Self::hydrate_list(list, &signer, &me).await; let list = Self::hydrate_list(list, &signer, &me).await;
this.update(cx, |this, cx| this.merge(list, cx)).ok(); this.update(cx, |this, cx| this.merge(list, Some(&event), cx))
.ok();
})); }));
} }
@@ -864,7 +881,7 @@ impl GroupsRegistry {
let me = nostr.read(cx).current_user(); let me = nostr.read(cx).current_user();
let list = self.list.clone(); let list = self.list.clone();
self.tasks.push(cx.spawn(async move |_this, _cx| { self.tasks.push(cx.spawn(async move |this, cx| {
// Re-encrypt the private section when it carries local entries; a // Re-encrypt the private section when it carries local entries; a
// section this session could not read is carried verbatim. // section this session could not read is carried verbatim.
let content = match (list.has_private(), list.content_readable()) { let content = match (list.has_private(), list.content_readable()) {
@@ -893,6 +910,15 @@ impl GroupsRegistry {
} }
}; };
// Our own publish is the newest list until a newer event arrives.
if let Err(error) = this.update(cx, |this, _cx| {
if is_newer_event(this.list_stamp, event.created_at, event.id) {
this.list_stamp = Some((event.created_at, event.id));
}
}) {
log::warn!("recording the published list failed: {error}");
}
match client.send_event(&event).to_nip65().await { match client.send_event(&event).to_nip65().await {
Ok(output) if output.failed.is_empty() => {} Ok(output) if output.failed.is_empty() => {}
Ok(output) => { Ok(output) => {
+26 -5
View File
@@ -1,10 +1,9 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::collections::hash_map::DefaultHasher;
use std::fmt; use std::fmt;
use std::hash::{Hash, Hasher};
use std::str::FromStr; use std::str::FromStr;
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use common::stable_id_u64;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
pub(crate) const TIMELINE_WINDOW: usize = 50; pub(crate) const TIMELINE_WINDOW: usize = 50;
@@ -50,6 +49,15 @@ pub(crate) fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
.and_then(|tag| tag.content()) .and_then(|tag| tag.content())
} }
/// Whether `(created_at, id)` is strictly newer than the last accepted stamp.
pub(crate) fn is_newer_event(
accepted: Option<(Timestamp, EventId)>,
created_at: Timestamp,
id: EventId,
) -> bool {
accepted.is_none_or(|last| (created_at, id) > last)
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GroupId(String); pub struct GroupId(String);
@@ -280,9 +288,7 @@ impl GroupKey {
} }
pub fn uniq_id(&self) -> u64 { pub fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new(); stable_id_u64(&[self.relay.as_str(), self.id.as_str()])
self.hash(&mut hasher);
hasher.finish()
} }
} }
@@ -1152,4 +1158,19 @@ mod tests {
let malformed = Tag::custom("previous", ["nothex"]); let malformed = Tag::custom("previous", ["nothex"]);
assert_eq!(window.references_seen(Some(&malformed)), Some(false)); assert_eq!(window.references_seen(Some(&malformed)), Some(false));
} }
#[test]
fn is_newer_event_accepts_only_strictly_newer_events() {
let lower = EventId::from_byte_array([0x00; 32]);
let higher = EventId::from_byte_array([0xff; 32]);
let earlier = Timestamp::from(100);
let later = Timestamp::from(200);
assert!(is_newer_event(None, earlier, lower));
assert!(!is_newer_event(Some((earlier, lower)), earlier, lower));
assert!(is_newer_event(Some((earlier, lower)), later, lower));
assert!(!is_newer_event(Some((later, lower)), earlier, higher));
assert!(is_newer_event(Some((earlier, lower)), earlier, higher));
assert!(!is_newer_event(Some((earlier, higher)), earlier, lower));
}
} }
+5 -6
View File
@@ -1,7 +1,6 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use common::stable_id_u64;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use crate::{GroupId, GroupKey}; use crate::{GroupId, GroupKey};
@@ -25,10 +24,10 @@ pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId {
} }
pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId { pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
let mut hasher = DefaultHasher::new(); SubscriptionId::new(format!(
relay.hash(&mut hasher); "{BROWSE_PREFIX}{:08x}",
stable_id_u64(&[relay.as_str()]) as u32
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32)) ))
} }
pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId { pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId {
+84 -141
View File
@@ -1,5 +1,5 @@
use std::cell::Cell; use std::cell::Cell;
use std::collections::{HashSet, VecDeque}; use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
@@ -19,14 +19,12 @@ use nostr_sdk::prelude::*;
use person::Directory; use person::Directory;
use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS}; use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS};
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{Announcement, NostrRegistry, UniversalSigner, credentials}; use state::{Announcement, NostrRegistry, UniversalSigner};
use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys}; use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys};
use ui::Avatar; use ui::Avatar;
mod storage; mod storage;
/// Keep the durable processed-request log bounded
const PROCESSED_REQUESTS_LIMIT: usize = 256;
/// Ignore key requests older than a day so stale events on relays cannot resurface /// Ignore key requests older than a day so stale events on relays cannot resurface
const REQUEST_MAX_AGE: u64 = SECONDS_PER_DAY; const REQUEST_MAX_AGE: u64 = SECONDS_PER_DAY;
@@ -79,14 +77,14 @@ pub struct DeviceRegistry {
app_keys: Option<Keys>, app_keys: Option<Keys>,
/// The key request currently awaiting user approval on this device /// The key request currently awaiting user approval on this device
pending_request: Option<PendingRequest>, pending_request: Option<PendingRequest>,
/// Handled key-sync request ids, bounded and persisted
processed_requests: VecDeque<EventId>,
/// Whether an announcement has been made for this device /// Whether an announcement has been made for this device
pub(crate) announcement_existed: Arc<AtomicBool>, pub(crate) announcement_existed: Arc<AtomicBool>,
/// Whether the local key is stale against the current announcement. /// Whether the local key is stale against the current announcement.
resync: Arc<AtomicBool>, resync: Arc<AtomicBool>,
/// Whether a key rotation is in flight, to coalesce overlapping requests /// Whether a key rotation is in flight, to coalesce overlapping requests
rotating: bool, rotating: bool,
/// Whether a key approval is in flight, to guard against a double click
approving: bool,
/// Person directory for requesters shown in the approval dialog /// Person directory for requesters shown in the approval dialog
directory: Entity<Directory>, directory: Entity<Directory>,
/// Relay notification listener /// Relay notification listener
@@ -110,7 +108,6 @@ impl DeviceRegistry {
/// Create a new device registry instance /// Create a new device registry instance
fn new(cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx); let settings = AppSettings::global(cx);
@@ -153,20 +150,14 @@ impl DeviceRegistry {
}), }),
); );
cx.defer(move |cx| {
let _ = entity.update(cx, |this, cx| {
this.handle_notifications(cx);
});
});
Self { Self {
signer, signer,
app_keys: None, app_keys: None,
pending_request: None, pending_request: None,
processed_requests: VecDeque::new(),
announcement_existed: Arc::new(AtomicBool::new(false)), announcement_existed: Arc::new(AtomicBool::new(false)),
resync: Arc::new(AtomicBool::new(false)), resync: Arc::new(AtomicBool::new(false)),
rotating: false, rotating: false,
approving: false,
directory: Directory::new(cx), directory: Directory::new(cx),
notification_listener: None, notification_listener: None,
tasks: vec![], tasks: vec![],
@@ -184,8 +175,6 @@ impl DeviceRegistry {
let announcement_existed = self.announcement_existed.clone(); let announcement_existed = self.announcement_existed.clone();
let (tx, rx) = flume::bounded::<Event>(100); let (tx, rx) = flume::bounded::<Event>(100);
let read = credentials::read_processed_requests(cx);
self.notification_listener = Some(cx.background_spawn(async move { self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications(); let mut notifications = client.notifications();
let mut processed_events = HashSet::new(); let mut processed_events = HashSet::new();
@@ -200,41 +189,35 @@ impl DeviceRegistry {
continue; continue;
} }
match event.kind { let relevant = matches!(
Kind::Custom(10044) if current_user == Some(event.pubkey) => { event.kind,
Kind::Custom(10044) | Kind::Custom(4454) | Kind::Custom(4455)
) && current_user == Some(event.pubkey);
if !relevant {
continue;
}
if event.kind == Kind::Custom(10044) {
announcement_existed.store(true, Ordering::Relaxed); announcement_existed.store(true, Ordering::Relaxed);
tx.send_async(event.into_owned()).await?;
} }
Kind::Custom(4454) if current_user == Some(event.pubkey) => {
tx.send_async(event.into_owned()).await?; let event_id = event.id;
} // Dropping keeps the listener alive when the consumer is gone;
Kind::Custom(4455) if current_user == Some(event.pubkey) => { // missed 4e events are re-synced on the next signer change
tx.send_async(event.into_owned()).await?; if let Err(error) = tx.send_async(event.into_owned()).await {
} log::error!("Dropping device key event {event_id}: {error}");
_ => {}
} }
} }
} }
// The stream only ends without being replaced when the relay source is
// gone, after which no 4e event is processed anymore
log::error!("Device key notification listener stopped");
Ok(()) Ok(())
})); }));
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
// Hydrate the processed-request log before routing any events
match read.await {
Ok(bytes) => {
let processed = bytes
.map(|bytes| parse_processed_requests(&bytes))
.unwrap_or_default();
this.update(cx, |this, _cx| {
this.processed_requests = processed;
})
.ok();
}
Err(e) => log::error!("Failed to read processed key-sync requests: {e}"),
}
while let Ok(event) = rx.recv_async().await { while let Ok(event) = rx.recv_async().await {
match event.kind { match event.kind {
Kind::Custom(10044) => { Kind::Custom(10044) => {
@@ -261,36 +244,11 @@ impl DeviceRegistry {
})); }));
} }
/// Record a handled key-sync request id, persisting a bounded log
fn mark_request_processed(&mut self, event_id: EventId, cx: &mut Context<Self>) {
if self.processed_requests.contains(&event_id) {
return;
}
self.processed_requests.push_back(event_id);
while self.processed_requests.len() > PROCESSED_REQUESTS_LIMIT {
self.processed_requests.pop_front();
}
let bytes: Vec<u8> = self
.processed_requests
.iter()
.flat_map(|id| id.to_hex().into_bytes())
.collect();
let write = credentials::write_processed_requests(cx, &bytes);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Failed to persist processed key-sync requests: {e}");
}
})
.detach();
}
/// Withdraw the pending approval. /// Withdraw the pending approval.
fn resolve_pending_request(&mut self, cx: &mut Context<Self>) { fn resolve_pending_request(&mut self, cx: &mut Context<Self>) {
let Some(pending) = self.pending_request.take() else { if self.pending_request.take().is_none() {
return; return;
}; }
self.mark_request_processed(pending.event.id, cx);
if let Some(window) = cx.windows().first().copied() { if let Some(window) = cx.windows().first().copied() {
window window
@@ -453,10 +411,16 @@ impl DeviceRegistry {
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let result: Result<Keys, Error> = async { let result: Result<Keys, Error> = async {
// Archive the current key before replacing it // Archive the current key before replacing it; a failed load or
if let Ok(old) = get_keys(&client, &signer).await { // archive aborts the rotation so the old key is never overwritten
archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?; // without a record
} let old = get_keys(&client, &signer)
.await
.context("Failed to load the current encryption key before rotation")?;
archive_key(&client, &signer, &old.secret_key().to_secret_hex())
.await
.context("Failed to archive the current encryption key")?;
let created = this let created = this
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))? .update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
@@ -504,6 +468,7 @@ impl DeviceRegistry {
let elapsed = Timestamp::now() let elapsed = Timestamp::now()
.as_secs() .as_secs()
.saturating_sub(keys.created_at.as_secs()); .saturating_sub(keys.created_at.as_secs());
if elapsed < u64::from(interval_days) * SECONDS_PER_DAY { if elapsed < u64::from(interval_days) * SECONDS_PER_DAY {
return Ok(()); return Ok(());
} }
@@ -530,7 +495,6 @@ impl DeviceRegistry {
let secret = keys.secret_key().to_secret_hex(); let secret = keys.secret_key().to_secret_hex();
let n = keys.public_key(); let n = keys.public_key();
let created_at = Timestamp::now();
cx.background_spawn(async move { cx.background_spawn(async move {
// Construct an announcement event // Construct an announcement event
@@ -540,7 +504,7 @@ impl DeviceRegistry {
.await?; .await?;
// Persist the key locally before announcing it // Persist the key locally before announcing it
set_keys(&client, &signer, &secret, created_at).await?; set_keys(&client, &signer, &secret, Timestamp::now()).await?;
// Publish announcement // Publish announcement
client client
@@ -826,13 +790,21 @@ impl DeviceRegistry {
/// Approve requests for device keys from other devices /// Approve requests for device keys from other devices
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) { fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
// An approval already in flight must not send the device secret twice;
// the toast's disabled state only applies after its next re-render
if self.approving {
return;
}
self.approving = true;
cx.notify();
let approved_id = event.id;
let event = event.clone();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let event = event.clone();
let request_id = event.id;
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Get device keys // Get device keys
let keys = get_keys(&client, &signer).await?; let keys = get_keys(&client, &signer).await?;
@@ -889,20 +861,41 @@ impl DeviceRegistry {
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await { match task.await {
Ok(_) => { Ok(_) => {
this.update(cx, |this, cx| { let superseded = this.update(cx, |this, cx| {
// Record the request as handled so it cannot resurface this.approving = false;
this.mark_request_processed(request_id, cx);
// Allow future requests to surface again
this.pending_request = None;
cx.notify(); cx.notify();
// Keep a newer request's pending state and toast
if this
.pending_request
.as_ref()
.is_some_and(|pending| pending.event.id == approved_id)
{
this.pending_request = None;
false
} else {
true
}
})?; })?;
if !superseded {
cx.update(|window, cx| { cx.update(|window, cx| {
window.remove_notification::<DeviceNotification>(cx); window.remove_notification::<DeviceNotification>(cx);
}) })
.ok(); .ok();
} }
}
Err(e) => { Err(e) => {
this.update(cx, |this, cx| {
this.approving = false;
cx.notify();
// Pushing a fresh toast replaces the wedged one whose
// button stays disabled
if let Some(pending) = this.pending_request.take() {
this.show_approval(pending.event, pending.client_key, cx);
}
})?;
cx.update(|window, cx| { cx.update(|window, cx| {
window.push_notification( window.push_notification(
Notification::error(e.to_string()).autohide(false), Notification::error(e.to_string()).autohide(false),
@@ -933,51 +926,27 @@ impl DeviceRegistry {
return; return;
}; };
let read = credentials::read_processed_requests(cx); match self.pending_request.take() {
cx.spawn(async move |this, cx| {
let processed = match read.await {
Ok(bytes) => bytes
.map(|bytes| parse_processed_requests(&bytes).contains(&event.id))
.unwrap_or(false),
Err(e) => {
log::error!("Failed to read processed key-sync requests: {e}");
false
}
};
this.update(cx, |this, cx| {
if processed || this.processed_requests.contains(&event.id) {
return;
}
match this.pending_request.take() {
// A retry signs a new request id but keeps the same client key // A retry signs a new request id but keeps the same client key
Some(mut pending) if pending.client_key == client_key => { Some(mut pending) if pending.client_key == client_key => {
this.mark_request_processed(event.id, cx);
pending.event = event; pending.event = event;
this.pending_request = Some(pending); self.pending_request = Some(pending);
} }
// A different client key is a remounted requester // A different client key is a remounted requester
other => { other => {
if let Some(pending) = other { if other.is_some()
this.mark_request_processed(pending.event.id, cx); && let Some(window) = cx.windows().first().copied()
if let Some(window) = cx.windows().first().copied() { {
window window
.update(cx, |_view, window, cx| { .update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx); window.remove_notification::<DeviceNotification>(cx);
}) })
.ok(); .ok();
} }
} self.show_approval(event, client_key, cx);
this.show_approval(event, client_key, cx);
} }
} }
})
.ok();
})
.detach();
} }
/// Build and surface the approval notification for a key request /// Build and surface the approval notification for a key request
@@ -1103,19 +1072,6 @@ impl DeviceRegistry {
) )
.into_any_element() .into_any_element()
}) })
.on_close({
let entity = entity.clone();
let request_id = event.id;
move |_window, cx| {
// Closing without approving rejects the request, so it must not resurface
entity
.update(cx, |this, cx| {
this.mark_request_processed(request_id, cx);
})
.ok();
}
})
.action(move |_this, _window, _cx| { .action(move |_this, _window, _cx| {
let view = entity.clone(); let view = entity.clone();
let event = event.clone(); let event = event.clone();
@@ -1127,9 +1083,10 @@ impl DeviceRegistry {
.on_click({ .on_click({
let loading = Rc::clone(&loading); let loading = Rc::clone(&loading);
move |_ev, window, cx| { move |_ev, window, cx| {
// Set loading state to true
loading.set(true); loading.set(true);
// Process to approve the request // The toast caches its render, so force a redraw
// to disable the button while the approval is in flight
window.refresh();
view.update(cx, |this, cx| { view.update(cx, |this, cx| {
this.approve(&event, window, cx); this.approve(&event, window, cx);
}) })
@@ -1140,20 +1097,6 @@ impl DeviceRegistry {
} }
} }
/// Parse concatenated hex event ids
fn parse_processed_requests(bytes: &[u8]) -> VecDeque<EventId> {
let Ok(hex) = std::str::from_utf8(bytes) else {
return VecDeque::new();
};
hex.as_bytes()
.as_chunks::<64>()
.0
.iter()
.filter_map(|chunk| EventId::from_hex(std::str::from_utf8(chunk).ok()?).ok())
.collect()
}
/// Extract the client public key from an event. /// Extract the client public key from an event.
fn client_pubkey_from_event(event: &Event) -> Option<PublicKey> { fn client_pubkey_from_event(event: &Event) -> Option<PublicKey> {
let key = event let key = event
+77 -8
View File
@@ -55,6 +55,9 @@ pub struct HistoryEntry {
pub created_at: Timestamp, pub created_at: Timestamp,
} }
/// How many times a failed nutzap redemption is retried before giving up
const MAX_REDEEM_ATTEMPTS: u32 = 3;
pub struct WalletRegistry { pub struct WalletRegistry {
wallet: Option<keys::WalletData>, wallet: Option<keys::WalletData>,
tokens: HashMap<EventId, TokenEvent>, tokens: HashMap<EventId, TokenEvent>,
@@ -69,6 +72,10 @@ pub struct WalletRegistry {
pending_nutzaps: Vec<Event>, pending_nutzaps: Vec<Event>,
/// Nutzaps with a redemption job in flight /// Nutzaps with a redemption job in flight
redeeming: HashSet<EventId>, redeeming: HashSet<EventId>,
/// Redemption attempts per nutzap, reset on success
redeem_attempts: HashMap<EventId, u32>,
/// A job errored after possibly mutating the store
publish_failed: bool,
store: Option<Arc<WalletRedbDatabase>>, store: Option<Arc<WalletRedbDatabase>>,
wallets: HashMap<MintUrl, Wallet>, wallets: HashMap<MintUrl, Wallet>,
loading: bool, loading: bool,
@@ -119,6 +126,8 @@ impl WalletRegistry {
redeemed: HashSet::new(), redeemed: HashSet::new(),
pending_nutzaps: Vec::new(), pending_nutzaps: Vec::new(),
redeeming: HashSet::new(), redeeming: HashSet::new(),
redeem_attempts: HashMap::new(),
publish_failed: false,
store: None, store: None,
wallets: HashMap::new(), wallets: HashMap::new(),
loading: false, loading: false,
@@ -184,6 +193,16 @@ impl WalletRegistry {
); );
} }
/// Drop a pending invoice at the user's request.
pub fn dismiss_mint_quote(&mut self, quote_id: String, cx: &mut Context<Self>) {
if !self.quotes.iter().any(|quote| quote.quote_id == quote_id) {
return;
}
self.quotes.retain(|quote| quote.quote_id != quote_id);
self.emit_updated(cx);
}
/// Pay a lightning invoice from the wallet. /// Pay a lightning invoice from the wallet.
pub fn melt(&mut self, invoice: String, cx: &mut Context<Self>) { pub fn melt(&mut self, invoice: String, cx: &mut Context<Self>) {
let Some(mint_url) = self.primary_mint(cx) else { let Some(mint_url) = self.primary_mint(cx) else {
@@ -378,6 +397,8 @@ impl WalletRegistry {
self.redeemed.clear(); self.redeemed.clear();
self.pending_nutzaps.clear(); self.pending_nutzaps.clear();
self.redeeming.clear(); self.redeeming.clear();
self.redeem_attempts.clear();
self.publish_failed = false;
self.store = None; self.store = None;
self.wallets.clear(); self.wallets.clear();
self.loading = false; self.loading = false;
@@ -397,6 +418,7 @@ impl WalletRegistry {
let signer = nostr.read(cx).signer().clone(); let signer = nostr.read(cx).signer().clone();
let (tx, rx) = flume::bounded::<Event>(100); let (tx, rx) = flume::bounded::<Event>(100);
let consumer_signer = signer.clone();
self.notification_listener = Some(cx.background_spawn(async move { self.notification_listener = Some(cx.background_spawn(async move {
const MAX_PROCESSED: usize = 1000; const MAX_PROCESSED: usize = 1000;
@@ -417,7 +439,7 @@ impl WalletRegistry {
| Kind::CashuWalletSpendingHistory | Kind::CashuWalletSpendingHistory
); );
let wallet_event = ours && is_cashu || is_token_deletion(&event); let wallet_event = ours && (is_cashu || is_token_deletion(&event));
let nutzap = event.kind == Kind::Custom(9321) let nutzap = event.kind == Kind::Custom(9321)
&& me.is_some_and(|me| event.tags.public_keys().any(|key| key == me)); && me.is_some_and(|me| event.tags.public_keys().any(|key| key == me));
@@ -443,21 +465,22 @@ impl WalletRegistry {
})); }));
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let me = consumer_signer.get_public_key_async().await.ok();
while let Ok(event) = rx.recv_async().await { while let Ok(event) = rx.recv_async().await {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.absorb(event, cx); this.absorb(event, me, cx);
}) })
.ok(); .ok();
} }
})); }));
} }
fn absorb(&mut self, event: Event, cx: &mut Context<Self>) { fn absorb(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
match event.kind { match event.kind {
Kind::CashuWallet => self.absorb_wallet(event, cx), Kind::CashuWallet => self.absorb_wallet(event, cx),
Kind::CashuWalletUnspentProof => self.absorb_token(event, cx), Kind::CashuWalletUnspentProof => self.absorb_token(event, cx),
Kind::CashuWalletSpendingHistory => self.absorb_history(event, cx), Kind::CashuWalletSpendingHistory => self.absorb_history(event, cx),
Kind::EventDeletion => self.absorb_deletion(event, cx), Kind::EventDeletion => self.absorb_deletion(event, me, cx),
Kind::Custom(9321) => self.absorb_nutzap(event, cx), Kind::Custom(9321) => self.absorb_nutzap(event, cx),
_ => {} _ => {}
} }
@@ -549,7 +572,15 @@ impl WalletRegistry {
})); }));
} }
fn absorb_deletion(&mut self, event: Event, cx: &mut Context<Self>) { fn absorb_deletion(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
if me != Some(event.pubkey) {
log::warn!(
"ignoring kind 5 deletion {} from a foreign author",
event.id
);
return;
}
let mut removed_any = false; let mut removed_any = false;
for tag in event.tags.iter() { for tag in event.tags.iter() {
if tag.kind() != "e" { if tag.kind() != "e" {
@@ -807,6 +838,9 @@ impl WalletRegistry {
self.job_running = true; self.job_running = true;
let job_handle = job.clone();
let publish_failed = self.publish_failed;
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let result = Tokio::spawn_result(cx, async move { let result = Tokio::spawn_result(cx, async move {
let ctx = ops::JobCtx { let ctx = ops::JobCtx {
@@ -815,6 +849,7 @@ impl WalletRegistry {
signer, signer,
client, client,
tokens, tokens,
publish_failed,
}; };
job.run(ctx).await job.run(ctx).await
@@ -823,7 +858,7 @@ impl WalletRegistry {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.job_running = false; this.job_running = false;
this.apply_outcome(result, cx); this.apply_outcome(job_handle, result, cx);
for job in std::mem::take(&mut this.job_queued) { for job in std::mem::take(&mut this.job_queued) {
this.schedule_job(job, cx); this.schedule_job(job, cx);
@@ -833,18 +868,51 @@ impl WalletRegistry {
})); }));
} }
fn apply_outcome(&mut self, result: Result<ops::Outcome, Error>, cx: &mut Context<Self>) { fn apply_outcome(
&mut self,
job: ops::Job,
result: Result<ops::Outcome, Error>,
cx: &mut Context<Self>,
) {
let outcome = match result { let outcome = match result {
Ok(outcome) => outcome, Ok(outcome) => outcome,
Err(error) => { Err(error) => {
if !matches!(job, ops::Job::CreateQuote { .. }) {
self.publish_failed = true;
}
if !matches!(job, ops::Job::CreateQuote { .. } | ops::Job::Reconcile) {
self.schedule_job(ops::Job::Reconcile, cx);
}
if let ops::Job::RedeemNutzap { nutzap_id, .. } = &job {
self.redeeming.remove(nutzap_id);
let attempts = self.redeem_attempts.entry(*nutzap_id).or_default();
*attempts += 1;
if *attempts <= MAX_REDEEM_ATTEMPTS {
self.job_queued.push(job.clone());
} else {
log::warn!(
"giving up redeeming nutzap {nutzap_id} after {MAX_REDEEM_ATTEMPTS} attempts"
);
}
}
self.emit_error(format!("{error:#}"), cx); self.emit_error(format!("{error:#}"), cx);
return; return;
} }
}; };
match outcome { match outcome {
ops::Outcome::Idle => {} ops::Outcome::Idle => {
if matches!(job, ops::Job::ClaimQuote { .. }) {
self.emit_error("Invoice is not paid yet".to_string(), cx);
}
}
ops::Outcome::Reconciled { updates } => { ops::Outcome::Reconciled { updates } => {
self.publish_failed = false;
let changed = !updates.is_empty(); let changed = !updates.is_empty();
for update in updates { for update in updates {
@@ -905,6 +973,7 @@ impl WalletRegistry {
amount, amount,
} => { } => {
self.redeeming.remove(&nutzap_id); self.redeeming.remove(&nutzap_id);
self.redeem_attempts.remove(&nutzap_id);
self.redeemed.insert(nutzap_id); self.redeemed.insert(nutzap_id);
self.apply_token_update(update); self.apply_token_update(update);
self.emit_updated(cx); self.emit_updated(cx);
+117 -27
View File
@@ -361,6 +361,8 @@ pub(crate) struct JobCtx<S> {
pub(crate) client: Client, pub(crate) client: Client,
/// Relay token events at schedule time. /// Relay token events at schedule time.
pub(crate) tokens: HashMap<EventId, TokenEvent>, pub(crate) tokens: HashMap<EventId, TokenEvent>,
/// A previous job errored after possibly mutating the store
pub(crate) publish_failed: bool,
} }
impl<S> JobCtx<S> impl<S> JobCtx<S>
@@ -426,11 +428,71 @@ where
log::warn!("saga recovery failed for {mint_url}: {error:#}"); log::warn!("saga recovery failed for {mint_url}: {error:#}");
} }
let minted = wallet.mint_unissued_quotes().await?; let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
if minted == Amount::ZERO { let states = vec![State::Pending, State::Reserved, State::PendingSpent];
let in_flight = wallet.get_proofs_by_states(states).await?;
let in_flight_ys = proof_ys(in_flight.iter())?;
let mut relay_ys = HashSet::new();
let mut unseen = Vec::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint_url.to_string())
{
for proof in &token.proofs {
let info = match proof_info(proof, mint_url) {
Ok(info) => info,
Err(error) => {
log::warn!("skipping proof: {error:#}");
continue;
}
};
if !relay_ys.insert(info.y) {
continue; continue;
} }
if local_ys.contains(&info.y) || in_flight_ys.contains(&info.y) {
continue;
}
unseen.push(info);
}
}
if unseen.is_empty() {
continue;
}
let unseen_proofs: Proofs = unseen.iter().map(|info| info.proof.clone()).collect();
let spent = match wallet.check_proofs_spent(unseen_proofs).await {
Ok(states) => states
.into_iter()
.filter(|state| state.state == State::Spent)
.map(|state| state.y)
.collect::<HashSet<_>>(),
Err(error) => {
log::warn!("mint state check failed, accepting unseen proofs: {error:#}");
HashSet::new()
}
};
let added: Vec<ProofInfo> = unseen
.into_iter()
.filter(|info| !spent.contains(&info.y))
.collect();
if !added.is_empty() {
wallet.localstore.update_proofs(added, Vec::new()).await?;
}
}
for (mint_url, wallet) in &self.wallets {
let minted = wallet.mint_unissued_quotes().await?;
if minted != Amount::ZERO {
let update = self let update = self
.sync_token_events( .sync_token_events(
wallet, wallet,
@@ -452,39 +514,39 @@ where
updates.push(update); updates.push(update);
} }
for (mint_url, wallet) in &self.wallets { let relay_ys = relay_proof_ys(&tokens, mint_url);
let mut relay_ys = HashSet::new(); let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
let mut added = Vec::new();
for token in tokens if local_ys != relay_ys {
.values() if self.publish_failed {
.filter(|token| token.mint.as_str() == mint_url.to_string()) let update = self
{ .sync_token_events(
for proof in &token.proofs { wallet,
let info = match proof_info(proof, mint_url) { nostr_mint(mint_url)?,
Ok(info) => info, &previous_ids(&tokens, mint_url),
Err(error) => { None,
log::warn!("skipping proof: {error:#}"); )
continue; .await?;
}
};
if relay_ys.insert(info.y) {
added.push(info);
}
}
}
let unspent = wallet.get_unspent_proofs().await?; for id in &update.destroyed {
let removed = unspent tokens.remove(id);
.iter() }
tokens.insert(update.event_id, update.token.clone());
updates.push(update);
} else {
let removed = wallet
.get_unspent_proofs()
.await?
.into_iter()
.filter(|proof| match proof.y() { .filter(|proof| match proof.y() {
Ok(y) => !relay_ys.contains(&y), Ok(y) => !relay_ys.contains(&y),
Err(_) => false, Err(_) => false,
}) })
.map(|proof| proof.y()) .map(|proof| proof.y())
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
wallet.localstore.update_proofs(Vec::new(), removed).await?;
wallet.localstore.update_proofs(added, removed).await?; }
}
} }
Ok(Outcome::Reconciled { updates }) Ok(Outcome::Reconciled { updates })
@@ -499,6 +561,34 @@ fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<Ev
.collect() .collect()
} }
fn proof_ys<'a>(
proofs: impl Iterator<Item = &'a Proof>,
) -> Result<HashSet<cdk::nuts::PublicKey>, Error> {
proofs.map(|proof| proof.y().map_err(Error::from)).collect()
}
/// Proof ys referenced by the relay token events of one mint
fn relay_proof_ys(
tokens: &HashMap<EventId, TokenEvent>,
mint: &MintUrl,
) -> HashSet<cdk::nuts::PublicKey> {
let mut ys = HashSet::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint.to_string())
{
for proof in &token.proofs {
match proof_info(proof, mint) {
Ok(info) => {
ys.insert(info.y);
}
Err(error) => log::warn!("skipping proof: {error:#}"),
}
}
}
ys
}
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them. /// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
async fn recipient_wallet_info<S>( async fn recipient_wallet_info<S>(
ctx: &JobCtx<S>, ctx: &JobCtx<S>,
+111 -2
View File
@@ -1,7 +1,7 @@
use std::ops::Range; use std::ops::Range;
use std::rc::Rc; use std::rc::Rc;
use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry}; use ::nip60::{HistoryEntry, MintQuoteEntry, WalletEvent, WalletRegistry};
use common::TimestampExt; use common::TimestampExt;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
@@ -13,6 +13,7 @@ use gpui::{
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dock::{Panel, PanelEvent}; use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput}; use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::notification::Notification; use gpui_kit::component::notification::Notification;
@@ -108,6 +109,111 @@ impl WalletPanel {
) )
} }
fn render_quotes(&self, quotes: Vec<MintQuoteEntry>, cx: &Context<Self>) -> Option<AnyElement> {
if quotes.is_empty() {
return None;
}
Some(
v_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Pending invoices"),
)
.children(
quotes
.iter()
.map(|quote| self.render_quote_entry(quote, cx)),
)
.into_any_element(),
)
}
fn render_quote_entry(&self, quote: &MintQuoteEntry, cx: &Context<Self>) -> impl IntoElement {
let wallet = WalletRegistry::global(cx);
let quote_id = quote.quote_id.clone();
v_flex()
.gap_1()
.w_full()
.child(
h_flex()
.justify_between()
.gap_2()
.child(
h_flex()
.gap_2()
.min_w_0()
.child(
div()
.flex_shrink_0()
.text_color(cx.theme().success)
.child(SharedString::from(format!("{} sats", quote.amount))),
)
.child(
div()
.truncate()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(quote.mint.to_string())),
),
)
.child(
h_flex()
.flex_shrink_0()
.gap_1()
.child(
Button::new(format!("mint-quote-check-{quote_id}"))
.label("Check")
.tooltip("Check payment and claim")
.ghost()
.small()
.on_click({
let wallet = wallet.clone();
let quote_id = quote_id.clone();
move |_, _, cx| {
wallet.update(cx, |registry, cx| {
registry.claim_mint_quote(quote_id.clone(), cx)
});
}
}),
)
.child(
Clipboard::new(format!("mint-quote-copy-{quote_id}"))
.value(quote.request.clone())
.tooltip("Copy invoice")
.small(),
)
.child(
Button::new(format!("mint-quote-dismiss-{quote_id}"))
.icon(IconName::Close)
.tooltip("Dismiss")
.ghost()
.small()
.on_click({
let wallet = wallet.clone();
move |_, _, cx| {
wallet.update(cx, |registry, cx| {
registry.dismiss_mint_quote(quote_id.clone(), cx)
});
}
}),
),
),
)
.child(
div()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis_middle()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(quote.request.clone())),
)
}
fn render_history( fn render_history(
&mut self, &mut self,
history: Vec<HistoryEntry>, history: Vec<HistoryEntry>,
@@ -762,7 +868,9 @@ impl Focusable for WalletPanel {
impl Render for WalletPanel { impl Render for WalletPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let history = WalletRegistry::global(cx).read(cx).history().to_vec(); let wallet = WalletRegistry::global(cx);
let history = wallet.read(cx).history().to_vec();
let quotes = wallet.read(cx).mint_quotes().to_vec();
v_flex() v_flex()
.size_full() .size_full()
@@ -771,6 +879,7 @@ impl Render for WalletPanel {
.text_xs() .text_xs()
.child(self.render_balance(cx)) .child(self.render_balance(cx))
.child(self.render_actions()) .child(self.render_actions())
.children(self.render_quotes(quotes, cx))
.child( .child(
v_flex() v_flex()
.gap_2() .gap_2()
+45 -20
View File
@@ -1,5 +1,5 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::RwLock; use std::sync::{Arc, RwLock};
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use common::emojis_from_tags; use common::emojis_from_tags;
@@ -64,7 +64,7 @@ enum Dispatch {
#[derive(Debug)] #[derive(Debug)]
pub struct PersonRegistry { pub struct PersonRegistry {
/// Public keys with a metadata request in flight /// Public keys with a metadata request in flight
in_flight: RwLock<HashSet<PublicKey>>, in_flight: Arc<RwLock<HashSet<PublicKey>>>,
/// Web of trust graph of the signed-in user /// Web of trust graph of the signed-in user
wot: Entity<WebOfTrust>, wot: Entity<WebOfTrust>,
sender: flume::Sender<PublicKey>, sender: flume::Sender<PublicKey>,
@@ -102,8 +102,12 @@ impl PersonRegistry {
let client3 = client.clone(); let client3 = client.clone();
let executor = cx.background_executor().clone(); let executor = cx.background_executor().clone();
let in_flight = Arc::new(RwLock::new(HashSet::new()));
let in_flight_requests = Arc::clone(&in_flight);
tasks.push(cx.background_spawn(async move { tasks.push(cx.background_spawn(async move {
Self::handle_requests(&client3, &metadata_rx, executor).await; Self::handle_requests(&client3, &metadata_rx, executor, &in_flight_requests).await;
})); }));
tasks.push(cx.spawn(async move |this, cx| { tasks.push(cx.spawn(async move |this, cx| {
@@ -140,7 +144,7 @@ impl PersonRegistry {
}); });
Self { Self {
in_flight: RwLock::new(HashSet::new()), in_flight,
wot, wot,
sender: metadata_tx, sender: metadata_tx,
tasks, tasks,
@@ -205,6 +209,7 @@ impl PersonRegistry {
client: &Client, client: &Client,
rx: &flume::Receiver<PublicKey>, rx: &flume::Receiver<PublicKey>,
executor: BackgroundExecutor, executor: BackgroundExecutor,
in_flight: &RwLock<HashSet<PublicKey>>,
) { ) {
let mut batch: HashSet<PublicKey> = HashSet::new(); let mut batch: HashSet<PublicKey> = HashSet::new();
@@ -216,34 +221,44 @@ impl PersonRegistry {
result = rx.recv_async().fuse() => match result { result = rx.recv_async().fuse() => match result {
Ok(public_key) => public_key, Ok(public_key) => public_key,
Err(_) => { Err(_) => {
if !batch.is_empty() Self::flush_batch(client, &mut batch, in_flight).await;
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
return; return;
} }
}, },
() = timer => { () = timer => {
if !batch.is_empty() Self::flush_batch(client, &mut batch, in_flight).await;
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
continue; continue;
} }
}; };
batch.insert(request); batch.insert(request);
// Process the batch if it's full // Process the batch if it's full
if batch.len() >= 20 if batch.len() >= 20 {
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await Self::flush_batch(client, &mut batch, in_flight).await;
{
log::warn!("Failed to get metadata batch: {e}");
} }
} }
} }
/// Send one metadata request for the batch, releasing its keys for retry on failure
async fn flush_batch(
client: &Client,
batch: &mut HashSet<PublicKey>,
in_flight: &RwLock<HashSet<PublicKey>>,
) {
let batch = std::mem::take(batch);
if batch.is_empty() {
return;
}
if let Err(error) = get_metadata(client, batch.iter().cloned()).await {
Self::release_keys(in_flight, batch.iter().cloned());
log::warn!(
"Failed to get metadata for {} public keys, released for retry: {error}",
batch.len()
);
}
}
/// Subscribe to the signed-in user's emoji list /// Subscribe to the signed-in user's emoji list
fn get_emojis(&mut self, cx: &mut Context<Self>) { fn get_emojis(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -334,11 +349,21 @@ impl PersonRegistry {
/// Allow future fetches for a key once its metadata has arrived /// Allow future fetches for a key once its metadata has arrived
fn release_in_flight(&self, public_key: &PublicKey) { fn release_in_flight(&self, public_key: &PublicKey) {
let mut in_flight = match self.in_flight.write() { Self::release_keys(&self.in_flight, [*public_key]);
}
/// Remove the given keys from the in-flight set
fn release_keys(
in_flight: &RwLock<HashSet<PublicKey>>,
keys: impl IntoIterator<Item = PublicKey>,
) {
let mut in_flight = match in_flight.write() {
Ok(in_flight) => in_flight, Ok(in_flight) => in_flight,
Err(poisoned) => poisoned.into_inner(), Err(poisoned) => poisoned.into_inner(),
}; };
in_flight.remove(public_key); for public_key in keys {
in_flight.remove(&public_key);
}
} }
} }
+147 -19
View File
@@ -28,6 +28,14 @@ pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
/// Built-in image shown behind chat and group panels /// Built-in image shown behind chat and group panels
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg"; pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
fn default_onboarded() -> bool {
true
}
fn default_shadow() -> bool {
true
}
/// The user's appearance preference, persisted with the rest of the settings. /// The user's appearance preference, persisted with the rest of the settings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AppearanceMode { pub enum AppearanceMode {
@@ -78,6 +86,9 @@ pub struct ThemeSettings {
pub radius: Option<f32>, pub radius: Option<f32>,
/// Corner radius for large elements like dialogs; follows the OS theme when unset /// Corner radius for large elements like dialogs; follows the OS theme when unset
pub radius_lg: Option<f32>, pub radius_lg: Option<f32>,
/// Draw soft shadows on inputs, buttons and dialogs
#[serde(default = "default_shadow")]
pub shadow: bool,
} }
impl Default for ThemeSettings { impl Default for ThemeSettings {
@@ -87,6 +98,7 @@ impl Default for ThemeSettings {
focus_ring: false, focus_ring: false,
radius: None, radius: None,
radius_lg: None, radius_lg: None,
shadow: true,
} }
} }
} }
@@ -145,6 +157,12 @@ pub struct Settings {
/// Hide the notice that messages in public groups are not encrypted /// Hide the notice that messages in public groups are not encrypted
#[serde(default)] #[serde(default)]
pub hide_group_notice: bool, pub hide_group_notice: bool,
/// Enable the NIP-60 wallet
#[serde(default)]
pub wallet: bool,
/// Whether the first-run onboarding has been completed
#[serde(default = "default_onboarded")]
pub onboarded: bool,
/// Tuning applied on top of the native OS theme /// Tuning applied on top of the native OS theme
#[serde(default)] #[serde(default)]
pub theme: ThemeSettings, pub theme: ThemeSettings,
@@ -178,10 +196,12 @@ impl Default for Settings {
appearance_mode: AppearanceMode::default(), appearance_mode: AppearanceMode::default(),
hide_avatar: false, hide_avatar: false,
screening: true, screening: true,
wot_auto_trust: true, wot_auto_trust: false,
nip4e: false, nip4e: false,
nip4e_rotation_days: None, nip4e_rotation_days: None,
hide_group_notice: false, hide_group_notice: false,
wallet: false,
onboarded: false,
theme: ThemeSettings::default(), theme: ThemeSettings::default(),
theme_light: None, theme_light: None,
theme_dark: None, theme_dark: None,
@@ -203,6 +223,11 @@ impl Global for GlobalAppSettings {}
pub struct AppSettings { pub struct AppSettings {
/// Settings /// Settings
inner: Entity<Settings>, inner: Entity<Settings>,
/// The pending save task, so writes to the settings file complete in order
#[cfg(not(target_arch = "wasm32"))]
save_task: Option<Task<()>>,
/// Set after a failed load so its own save cannot overwrite the file with defaults
suppress_next_save: bool,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>, _subscriptions: SmallVec<[Subscription; 2]>,
} }
@@ -222,6 +247,9 @@ macro_rules! setting_accessors {
inner.$field = value; inner.$field = value;
cx.notify(); cx.notify();
}); });
// Notify the handle too, so observers of AppSettings
// react to the change
cx.notify();
}); });
} }
} }
@@ -257,6 +285,8 @@ setting_accessors! {
pub panel_background_path: Option<PathBuf>, pub panel_background_path: Option<PathBuf>,
pub theme_light: Option<String>, pub theme_light: Option<String>,
pub theme_dark: Option<String>, pub theme_dark: Option<String>,
pub wallet: bool,
pub onboarded: bool,
} }
setting_getters! { setting_getters! {
@@ -290,11 +320,16 @@ impl AppSettings {
// Run at the end of current cycle // Run at the end of current cycle
cx.defer(move |cx| { cx.defer(move |cx| {
entity.update(cx, |this, cx| this.load(cx)).ok(); if let Err(error) = entity.update(cx, |this, cx| this.load(cx)) {
log::error!("failed to load settings: {error:#}");
}
}); });
Self { Self {
inner, inner,
#[cfg(not(target_arch = "wasm32"))]
save_task: None,
suppress_next_save: false,
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -314,6 +349,8 @@ impl AppSettings {
edit(&mut inner.theme); edit(&mut inner.theme);
cx.notify(); cx.notify();
}); });
// Notify the handle too, so observers of AppSettings react
cx.notify();
}); });
} }
@@ -326,6 +363,7 @@ impl AppSettings {
let styled = Theme::global_mut(cx); let styled = Theme::global_mut(cx);
styled.font_size = px(tuning.font_size); styled.font_size = px(tuning.font_size);
styled.focus_ring = tuning.focus_ring; styled.focus_ring = tuning.focus_ring;
styled.shadow = tuning.shadow;
if let Some(radius) = tuning.radius { if let Some(radius) = tuning.radius {
styled.radius = px(radius); styled.radius = px(radius);
} }
@@ -353,27 +391,46 @@ impl AppSettings {
/// Load settings /// Load settings
fn load(&mut self, cx: &mut Context<Self>) { fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move { let task: Task<Result<Option<Settings>, Error>> = cx.background_spawn(async move {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
let path = config_dir().join(".settings"); let path = config_dir().join(".settings");
if let Ok(content) = smol::fs::read_to_string(&path).await { match smol::fs::read_to_string(&path).await {
return Ok(serde_json::from_str(&content)?); Ok(content) => {
let settings: Settings =
serde_json::from_str(&content).map_err(|error| {
anyhow!("failed to parse {}: {error}", path.display())
})?;
return Ok(Some(settings));
}
// No settings file yet on a fresh install, so keep the defaults
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(anyhow!("failed to read {}: {error}", path.display()));
}
} }
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
if let Some(content) = common::web_store::get(SETTINGS_KEY)? { if let Some(content) = common::web_store::get(SETTINGS_KEY)? {
return Ok(serde_json::from_str(&content)?); let settings: Settings = serde_json::from_str(&content)
.map_err(|error| anyhow!("failed to parse saved settings: {error}"))?;
return Ok(Some(settings));
} }
} }
Err(anyhow!("Not found")) Ok(None)
}); });
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let mut settings = task.await.unwrap_or(Settings::default()); let (mut settings, load_failed) = match task.await {
Ok(settings) => (settings.unwrap_or_default(), false),
Err(error) => {
log::error!("failed to load settings, keeping defaults: {error:#}");
(Settings::default(), true)
}
};
// Move settings still pointed at the old default file server over to the new one // Move settings still pointed at the old default file server over to the new one
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) { if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
@@ -381,31 +438,63 @@ impl AppSettings {
} }
// Update settings // Update settings
this.update(cx, |this, cx| { if let Err(error) = this.update(cx, |this, cx| {
// A failed load must not trigger the save that would overwrite the file
this.suppress_next_save = load_failed;
this.set_settings(settings, cx); this.set_settings(settings, cx);
this.apply_theme(cx); this.apply_theme(cx);
cx.refresh_windows(); cx.refresh_windows();
}) }) {
.ok(); log::error!("failed to apply loaded settings: {error:#}");
}
}) })
.detach(); .detach();
} }
/// Save settings /// Save settings
pub fn save(&mut self, cx: &mut Context<Self>) { pub fn save(&mut self, cx: &mut Context<Self>) {
// Consume the failed-load suppression, so the next user change saves again
if self.suppress_next_save {
self.suppress_next_save = false;
return;
}
let settings = self.inner.read(cx); let settings = self.inner.read(cx);
if let Ok(content) = serde_json::to_string(&settings) { let content = match serde_json::to_string(settings) {
Ok(content) => content,
Err(error) => {
log::error!("failed to serialize settings: {error:#}");
return;
}
};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
cx.background_spawn(async move { {
let previous_task = self.save_task.take();
self.save_task = Some(cx.background_spawn(async move {
// Await the previous write, so saves to the file complete in order
if let Some(previous_task) = previous_task {
previous_task.await;
}
let path = config_dir().join(".settings"); let path = config_dir().join(".settings");
smol::fs::write(&path, content).await.ok(); let temporary_path = config_dir().join(".settings.tmp");
})
.detach(); let result: std::io::Result<()> = async {
smol::fs::write(&temporary_path, &content).await?;
smol::fs::rename(&temporary_path, &path).await
}
.await;
if let Err(error) = result {
log::error!("failed to save settings to {}: {error}", path.display());
}
}));
}
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) { if let Err(error) = common::web_store::set(SETTINGS_KEY, &content) {
log::warn!("failed to persist settings: {e}"); log::error!("failed to persist settings: {error:#}");
}
} }
} }
@@ -582,6 +671,11 @@ fn persisted_content() -> Option<String> {
common::web_store::get(SETTINGS_KEY).ok().flatten() common::web_store::get(SETTINGS_KEY).ok().flatten()
} }
/// Read the persisted onboarding flag synchronously.
pub fn persisted_onboarded() -> bool {
persisted_settings().is_some_and(|settings| settings.onboarded)
}
pub fn persisted_appearance_mode() -> AppearanceMode { pub fn persisted_appearance_mode() -> AppearanceMode {
persisted_settings() persisted_settings()
.map(|settings| settings.appearance_mode) .map(|settings| settings.appearance_mode)
@@ -672,6 +766,40 @@ mod tests {
assert_eq!(settings.theme, ThemeSettings::default()); assert_eq!(settings.theme, ThemeSettings::default());
assert_eq!(settings.theme_light, None); assert_eq!(settings.theme_light, None);
assert_eq!(settings.theme_dark, None); assert_eq!(settings.theme_dark, None);
// Files written before onboarding and the wallet flag existed count
// as onboarded with the wallet off
assert!(settings.onboarded);
assert!(!settings.wallet);
}
/// Fresh installs start un-onboarded with optional features off, and the
/// flags round-trip through serde
#[test]
fn onboarding_and_wallet_persist() {
let settings = Settings::default();
assert!(!settings.onboarded);
assert!(!settings.wallet);
assert!(!settings.wot_auto_trust);
assert!(settings.theme.shadow);
let settings: Settings = serde_json::from_str(
r#"{
"appearance_mode": "Dark",
"hide_avatar": false,
"screening": true,
"wot_auto_trust": true,
"nip4e": false,
"trusted_relays": [],
"file_server": "https://nostr.download/",
"onboarded": true,
"wallet": true
}"#,
)
.unwrap();
assert!(settings.onboarded);
assert!(settings.wallet);
} }
/// The theme selection round-trips through serde. /// The theme selection round-trips through serde.
+52 -31
View File
@@ -1,5 +1,5 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock; use std::sync::{Mutex, OnceLock};
use std::time::Duration; use std::time::Duration;
use anyhow::{Context as _, Result, anyhow}; use anyhow::{Context as _, Result, anyhow};
@@ -30,7 +30,7 @@ impl SpeechRecognizer for ParakeetRecognizer {
cx: &mut App, cx: &mut App,
) -> Result<Box<dyn RecognitionSession>, SpeechError> { ) -> Result<Box<dyn RecognitionSession>, SpeechError> {
if super::model::installed() { if super::model::installed() {
worker().send_or_log(Command::Warm(super::model::dir())); send(Command::Warm(super::model::dir())).map_err(SpeechError::recognizer)?;
} else if super::model::status().is_downloading() { } else if super::model::status().is_downloading() {
return Err(SpeechError::recognizer(anyhow!( return Err(SpeechError::recognizer(anyhow!(
"The speech model is still downloading. Try again when it finishes." "The speech model is still downloading. Try again when it finishes."
@@ -79,7 +79,11 @@ impl RecognitionSession for ParakeetSession {
} }
let (sender, receiver) = flume::unbounded(); let (sender, receiver) = flume::unbounded();
worker().send_or_log(Command::Transcribe { samples, sender });
if let Err(error) = send(Command::Transcribe { samples, sender }) {
sink.error(SpeechError::recognizer(error), cx);
return;
}
self._result = Some(cx.spawn(async move |cx| { self._result = Some(cx.spawn(async move |cx| {
let result = receiver.recv_async().await; let result = receiver.recv_async().await;
@@ -109,23 +113,50 @@ enum Command {
Unload, Unload,
} }
/// A handle to the single inference worker. fn worker_slot() -> &'static Mutex<Option<flume::Sender<Command>>> {
fn worker() -> &'static flume::Sender<Command> { static WORKER: OnceLock<Mutex<Option<flume::Sender<Command>>>> = OnceLock::new();
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new(); WORKER.get_or_init(|| Mutex::new(None))
WORKER.get_or_init(|| {
let (sender, receiver) = flume::unbounded::<Command>();
let spawned = std::thread::Builder::new()
.name("speech-parakeet".into())
.spawn(move || run_worker(receiver));
if let Err(error) = spawned {
log::error!("failed to spawn the speech worker: {error}");
} }
sender /// A handle to the live worker, spawning a replacement after an idle exit.
}) fn worker() -> Result<flume::Sender<Command>> {
let mut slot = worker_slot()
.lock()
.unwrap_or_else(|error| error.into_inner());
if slot.as_ref().is_some_and(|sender| sender.is_disconnected()) {
*slot = None;
}
if let Some(sender) = slot.as_ref() {
return Ok(sender.clone());
}
let (sender, receiver) = flume::unbounded::<Command>();
match std::thread::Builder::new()
.name("speech-parakeet".into())
.spawn(move || run_worker(receiver))
{
Ok(_) => {
*slot = Some(sender.clone());
Ok(sender)
}
Err(error) => {
log::error!("failed to spawn the speech worker: {error}");
Err(anyhow!("Could not start the speech worker: {error}"))
}
}
}
/// Deliver a command, retrying once on a fresh worker if the previous one exited.
fn send(command: Command) -> Result<()> {
let command = match worker()?.send(command) {
Ok(()) => return Ok(()),
Err(error) => error.into_inner(),
};
// The worker can exit between the liveness check and the send, so retry once.
worker()?.send(command).context("the speech worker exited")
} }
fn run_worker(receiver: flume::Receiver<Command>) { fn run_worker(receiver: flume::Receiver<Command>) {
@@ -192,21 +223,11 @@ fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) ->
.map_err(|error| anyhow!("Could not transcribe this recording: {error}")) .map_err(|error| anyhow!("Could not transcribe this recording: {error}"))
} }
trait SendOrLog {
fn send_or_log(&self, command: Command);
}
impl SendOrLog for flume::Sender<Command> {
fn send_or_log(&self, command: Command) {
if self.send(command).is_err() {
log::debug!("speech worker is gone; command dropped");
}
}
}
/// Retire cached weights, e.g. after the model was deleted. /// Retire cached weights, e.g. after the model was deleted.
pub(crate) fn unload() { pub(crate) fn unload() {
worker().send_or_log(Command::Unload); if let Err(error) = send(Command::Unload) {
log::warn!("failed to unload the speech model: {error}");
}
} }
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> { fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
+1
View File
@@ -51,6 +51,7 @@ sqlite-wasm-vfs = "0.2"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" } browser-signer-proxy = { path = "../browser-signer-proxy" }
nostr-lmdb.workspace = true nostr-lmdb.workspace = true
nostr-memory.workspace = true
smol.workspace = true smol.workspace = true
gpui_tokio.workspace = true gpui_tokio.workspace = true
rustls = "0.23" rustls = "0.23"
+1 -55
View File
@@ -1,13 +1,10 @@
use anyhow::Result; use anyhow::Result;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use common::web_store; use common::web_store;
use gpui::{App, AppContext as _, Task}; use gpui::{App, Task};
use crate::USER_KEYRING; use crate::USER_KEYRING;
/// The storage key for the log of handled NIP-4e key-sync request ids
const PROCESSED_REQUESTS_KEY: &str = "coop/nip4e/processed-requests";
/// Read the stored credential as `(username, secret)` /// Read the stored credential as `(username, secret)`
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
pub(crate) fn read(cx: &App) -> Task<Result<Option<(String, Vec<u8>)>>> { pub(crate) fn read(cx: &App) -> Task<Result<Option<(String, Vec<u8>)>>> {
@@ -52,54 +49,3 @@ pub(crate) fn delete(cx: &App) -> Task<Result<()>> {
Task::ready(web_store::remove(USER_KEYRING)) Task::ready(web_store::remove(USER_KEYRING))
} }
} }
/// Read named bytes from the platform keyring,
/// falling back to browser storage on web where no keyring exists
fn read_named(cx: &App, key: &str) -> Task<Result<Option<Vec<u8>>>> {
#[cfg(not(target_arch = "wasm32"))]
{
let key = key.to_owned();
let read = cx.read_credentials(&key);
cx.background_spawn(async move { Ok(read.await?.map(|(_, secret)| secret)) })
}
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
let stored = web_store::get(key).map(|content| content.map(|s| s.into_bytes()));
Task::ready(stored)
}
}
/// Write named bytes to the platform keyring,
/// falling back to browser storage on web where no keyring exists
fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
#[cfg(not(target_arch = "wasm32"))]
{
let key = key.to_owned();
let secret = secret.to_vec();
let write = cx.write_credentials(&key, "", &secret);
cx.background_spawn(write)
}
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
let content = match std::str::from_utf8(secret) {
Ok(content) => content,
Err(e) => return Task::ready(Err(anyhow::anyhow!("secret is not UTF-8: {e}"))),
};
Task::ready(web_store::set(key, content))
}
}
/// Read the log of handled key-sync request ids, as concatenated hex ids
pub fn read_processed_requests(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
read_named(cx, PROCESSED_REQUESTS_KEY)
}
/// Persist the log of handled key-sync request ids, as concatenated hex ids
pub fn write_processed_requests(cx: &App, ids: &[u8]) -> Task<Result<()>> {
write_named(cx, PROCESSED_REQUESTS_KEY, ids)
}
+4 -6
View File
@@ -403,7 +403,7 @@ pub async fn download_and_decrypt_file(
.clone() .clone()
.unwrap_or_else(|| fallback_name(&file.url)); .unwrap_or_else(|| fallback_name(&file.url));
let local = store_file(&name, &file.mime, &data).await?; let local = store_file(&file.mime, &data).await?;
Ok(DownloadedFile { Ok(DownloadedFile {
local, local,
@@ -415,10 +415,11 @@ pub async fn download_and_decrypt_file(
} }
/// Persist decrypted bytes somewhere the platform can render or open them /// Persist decrypted bytes somewhere the platform can render or open them
async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Error> { async fn store_file(mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
let digest = sha256_hex(name.as_bytes()); // Key the cache entry on the content hash, so same-named attachments can't collide
let digest = sha256_hex(data);
let extension = mime_guess::get_mime_extensions_str(mime) let extension = mime_guess::get_mime_extensions_str(mime)
.and_then(|extensions| extensions.first()) .and_then(|extensions| extensions.first())
.copied() .copied()
@@ -428,7 +429,6 @@ async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Er
.join("coop-files") .join("coop-files")
.join(format!("{digest}.{extension}")); .join(format!("{digest}.{extension}"));
if smol::fs::metadata(&path).await.is_err() {
let Some(parent) = path.parent() else { let Some(parent) = path.parent() else {
bail!("Invalid file path"); bail!("Invalid file path");
}; };
@@ -438,14 +438,12 @@ async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Er
let partial = path.with_extension("download"); let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?; smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?; smol::fs::rename(&partial, &path).await?;
}
Ok(LocalFile::Path(path)) Ok(LocalFile::Path(path))
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
let _ = name;
let url = crate::web_file::blob_url(mime, data)?; let url = crate::web_file::blob_url(mime, data)?;
Ok(LocalFile::Url(url)) Ok(LocalFile::Url(url))
} }
+33 -8
View File
@@ -16,6 +16,8 @@ use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*; use nostr_gossip_memory::prelude::*;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use nostr_lmdb::prelude::*; use nostr_lmdb::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use nostr_memory::prelude::MemoryDatabase;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
mod blossom; mod blossom;
@@ -114,15 +116,27 @@ impl NostrRegistry {
let signer = UniversalSigner::new(Keys::generate()); let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone()); let authenticator = SignerAuthenticator::new(signer.clone());
// NostrLmdb::open schedules nothing back onto the GPUI executor, so blocking here can't deadlock
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let database = cx.foreground_executor().block_on(async move { let (database, database_error) = match cx
NostrLmdb::open(config_dir().join("nostr")) .foreground_executor()
.await .block_on(NostrLmdb::open(config_dir().join("nostr")))
.expect("Failed to initialize database") {
}); Ok(database) => (Arc::new(database) as Arc<dyn NostrDatabase>, None),
Err(e) => {
log::error!("Failed to open the local database, falling back to memory: {e}");
(
Arc::new(MemoryDatabase::unbounded()) as Arc<dyn NostrDatabase>,
Some(format!(
"Local database unavailable, using an in-memory database instead: {e}"
)),
)
}
};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
let database = web_database::take(); let (database, database_error) = (web_database::take(), None::<String>);
// Construct the nostr client // Construct the nostr client
let client = ClientBuilder::default() let client = ClientBuilder::default()
@@ -141,6 +155,10 @@ impl NostrRegistry {
// Connect to bootstrap relays once the registry has been returned to the app // Connect to bootstrap relays once the registry has been returned to the app
cx.defer(move |cx| { cx.defer(move |cx| {
let _ = entity.update(cx, |this, cx| { let _ = entity.update(cx, |this, cx| {
if let Some(error) = database_error {
cx.emit(StateEvent::error(error));
}
this.connect_bootstrap_relays(cx); this.connect_bootstrap_relays(cx);
if let Some(secret) = cli_key { if let Some(secret) = cli_key {
@@ -445,8 +463,14 @@ impl NostrRegistry {
}; };
let keys = Keys::generate(); let keys = Keys::generate();
let credential = with_master_key(&uri_string, &keys);
let write = credentials::write(cx, "bunker", credential.as_bytes()); // The URI embeds the client key, so never persist it where web stores it unencrypted
#[cfg(not(target_arch = "wasm32"))]
let write =
credentials::write(cx, "bunker", with_master_key(&uri_string, &keys).as_bytes());
#[cfg(target_arch = "wasm32")]
let write = Task::ready(Ok::<(), Error>(()));
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let signer = NostrConnect::builder(connect_uri, keys) let signer = NostrConnect::builder(connect_uri, keys)
@@ -709,6 +733,7 @@ fn extract_master_key(credential: &str) -> (&str, Keys) {
} }
// Embed the master key in a bunker URI so reconnects reuse the same local key // Embed the master key in a bunker URI so reconnects reuse the same local key
#[cfg(not(target_arch = "wasm32"))]
fn with_master_key(uri: &str, keys: &Keys) -> String { fn with_master_key(uri: &str, keys: &Keys) -> String {
let separator = if uri.contains('?') { '&' } else { '?' }; let separator = if uri.contains('?') { '&' } else { '?' };
let nsec = keys.secret_key().to_bech32().expect("infallible"); let nsec = keys.secret_key().to_bech32().expect("infallible");
+1
View File
@@ -2,6 +2,7 @@ pub mod import;
pub mod join_group; pub mod join_group;
pub mod new_chat; pub mod new_chat;
pub mod new_group; pub mod new_group;
pub mod onboarding;
pub mod profile; pub mod profile;
pub mod relays; pub mod relays;
pub mod restore; pub mod restore;
+3
View File
@@ -60,6 +60,9 @@ impl NewChat {
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| { let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event { if let InputEvent::PressEnter { .. } = event {
this.submit(window, cx) this.submit(window, cx)
} else if matches!(event, InputEvent::Change) {
// The contact list only re-filters when the dialog re-renders
cx.notify();
} }
}); });
+477
View File
@@ -0,0 +1,477 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, AppContext, Context, Div, Entity, Hsla, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
div, px,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dialog::{Confirm, DialogButtonProps, DialogFooter};
use gpui_kit::component::input::{InputEvent, InputState, NumberInput};
use gpui_kit::component::separator::Separator;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::theme::{ThemeColor, try_parse_color};
use gpui_kit::component::{
ActiveTheme, Theme, ThemeConfig, ThemeRegistry, WindowExt, h_flex, v_flex,
};
use gpui_kit::prelude::FluentBuilder;
use rand::seq::IndexedRandom;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use ui::{settings_block, settings_row};
use super::settings::{FONT_SIZE_RANGE, RADIUS_RANGE};
/// Open the first-run onboarding dialog
pub fn open(window: &mut Window, cx: &mut App) {
let view = cx.new(|cx| Onboarding::new(window, cx));
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(640.))
.close_button(false)
.overlay_closable(false)
.keyboard(false)
.title("Welcome to Coop")
.child(view.clone())
.button_props(
DialogButtonProps::default()
.ok_text("Get started")
.show_cancel(false)
.on_ok(|_, _, cx| {
AppSettings::update_onboarded(true, cx);
// Returning true closes the dialog
true
}),
)
.footer(
DialogFooter::new().child(
Button::new("get-started")
.label("Get started")
.primary()
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
}),
),
)
});
}
pub struct Onboarding {
font_size_input: Entity<InputState>,
radius_input: Entity<InputState>,
light_themes: Vec<Rc<ThemeConfig>>,
dark_themes: Vec<Rc<ThemeConfig>>,
_subscriptions: SmallVec<[Subscription; 3]>,
}
impl Onboarding {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let theme = AppSettings::get_theme(cx);
let (light_themes, dark_themes) = Self::sample_themes(cx);
let font_size_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(theme.font_size.to_string())
.step(1.)
.min(FONT_SIZE_RANGE.0 as f64)
.max(FONT_SIZE_RANGE.1 as f64)
});
let radius_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(
theme
.radius
.map(|value| value.to_string())
.unwrap_or_default(),
)
.placeholder("System")
.step(1.)
.min(RADIUS_RANGE.0 as f64)
.max(RADIUS_RANGE.1 as f64)
});
let mut subscriptions = smallvec![];
subscriptions.push({
let state = font_size_input.clone();
cx.subscribe(&state, move |_, state, event: &InputEvent, cx| {
if !matches!(event, InputEvent::Change) {
return;
}
if let Some(font_size) = Self::number_at(&state, FONT_SIZE_RANGE, cx) {
AppSettings::update_theme(|theme| theme.font_size = font_size, cx);
AppSettings::apply_theme_style(cx);
}
})
});
// One radius control tunes both radii so the whole UI stays coherent
subscriptions.push({
let state = radius_input.clone();
cx.subscribe(&state, move |_, state, event: &InputEvent, cx| {
if !matches!(event, InputEvent::Change) {
return;
}
let radius = Self::number_at(&state, RADIUS_RANGE, cx);
AppSettings::update_theme(
move |theme| {
theme.radius = radius;
theme.radius_lg = radius;
},
cx,
);
AppSettings::apply_theme_style(cx);
})
});
Self {
font_size_input,
radius_input,
light_themes,
dark_themes,
_subscriptions: subscriptions,
}
}
/// The number in a theme input, clamped to the range, empty is None
fn number_at(state: &Entity<InputState>, range: (f32, f32), cx: &App) -> Option<f32> {
let (min, max) = range;
state
.read(cx)
.value()
.trim()
.parse::<f32>()
.ok()
.map(|value| value.clamp(min, max))
}
/// Three light and three dark bundled themes, sampled at random.
fn sample_themes(cx: &App) -> (Vec<Rc<ThemeConfig>>, Vec<Rc<ThemeConfig>>) {
let active = Theme::global(cx).theme_name().clone();
let mut rng = rand::rng();
let (lights, darks): (Vec<_>, Vec<_>) = ThemeRegistry::global(cx)
.sorted_themes()
.into_iter()
.cloned()
.partition(|theme| !theme.mode.is_dark());
let mut sample = |group: &[Rc<ThemeConfig>]| {
let mut picks: Vec<_> = group.sample(&mut rng, 3).cloned().collect();
let active_config = group.iter().find(|theme| theme.name == active);
let already_sampled = picks.iter().any(|theme| theme.name == active);
if let (Some(config), Some(slot)) = (active_config, picks.first_mut())
&& !already_sampled
{
*slot = config.clone();
}
picks
};
(sample(&lights), sample(&darks))
}
/// The theme picker: a demo block per sampled bundled theme
fn themes_view(&self, cx: &App) -> AnyElement {
let active = Theme::global(cx).theme_name().clone();
settings_block(
"Theme",
"Customize UI colors. More in Settings.",
v_flex()
.gap_3()
.child(Self::theme_group("Light", &self.light_themes, &active, cx))
.child(Self::theme_group("Dark", &self.dark_themes, &active, cx)),
cx,
)
.into_any_element()
}
fn theme_group(
label: &'static str,
themes: &[Rc<ThemeConfig>],
active: &SharedString,
cx: &App,
) -> Div {
v_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child(label),
)
.child(
div().flex().flex_wrap().gap_2().children(
themes
.iter()
.map(|config| Self::theme_card(config, active, cx)),
),
)
}
/// A card painting a mini app mockup in the theme's own colors
fn theme_card(config: &Rc<ThemeConfig>, active: &SharedString, cx: &App) -> impl IntoElement {
let selected = &config.name == active;
let name = config.name.clone();
let preview = Self::theme_preview(config);
let border = if selected {
cx.theme().list_active_border
} else {
preview.border
};
// Square block; the bigger inset keeps the mock window small inside it
let block = div()
.flex()
.w_full()
.aspect_square()
.p_4()
.rounded(cx.theme().radius_lg)
.overflow_hidden()
.bg(preview.background)
.border_1()
.border_color(border)
.hover(|this| this.border_color(cx.theme().border));
let block = if selected { block.border_2() } else { block };
v_flex()
.id(SharedString::from(format!("theme-{name}")))
.flex_1()
.cursor_pointer()
.child(
block.child(
h_flex()
.size_full()
.rounded(cx.theme().radius)
.when(cx.theme().shadow, |this| this.shadow_sm())
.overflow_hidden()
.border_1()
.border_color(preview.border)
.bg(preview.window)
.child(
v_flex()
.w_1_3()
.h_full()
.p_1()
.justify_between()
.bg(preview.sidebar)
.child(
v_flex()
.gap_1()
.child(h_flex().gap_0p5().children((0..3).map(|_| {
div().size_1().rounded_full().bg(preview.accent)
})))
.child(Self::demo_bar(preview.bar))
.child(Self::demo_bar(preview.bar)),
)
.child(
h_flex()
.gap_0p5()
.items_center()
.child(
div()
.size_2()
.rounded_full()
.bg(preview.bar.opacity(0.55)),
)
.child(
div()
.flex_1()
.h_1()
.rounded_full()
.bg(preview.bar.opacity(0.55)),
),
),
)
.child(
v_flex()
.flex_1()
.h_full()
.p_1()
.gap_1()
.child(Self::demo_bar(preview.bar))
.child(
h_flex()
.gap_1()
.child(div().flex_1().h_4().rounded_sm().bg(preview.block))
.child(div().flex_1().h_4().rounded_sm().bg(preview.block)),
),
),
),
)
.child(
div()
.mt_1()
.text_xs()
.truncate()
.text_color(if selected {
cx.theme().foreground
} else {
cx.theme().muted_foreground
})
.child(name),
)
.on_click({
let name = config.name.clone();
move |_, _window, cx| AppSettings::select_theme(&name, cx)
})
}
/// A skeleton text bar for the demo block
fn demo_bar(color: Hsla) -> Div {
div().w_full().h_1().rounded_full().bg(color.opacity(0.55))
}
/// The demo block colors resolved from a theme config.
fn theme_preview(config: &ThemeConfig) -> ThemePreview {
let defaults = if config.mode.is_dark() {
ThemeColor::dark()
} else {
ThemeColor::light()
};
let colors = &config.colors;
let resolve = |value: Option<&SharedString>, fallback: Hsla| {
value
.and_then(|value| try_parse_color(value).ok())
.unwrap_or(fallback)
};
ThemePreview {
background: resolve(colors.background.as_ref(), defaults.background),
border: resolve(colors.border.as_ref(), defaults.border),
window: resolve(colors.popover.as_ref(), defaults.popover),
sidebar: resolve(colors.secondary.as_ref(), defaults.secondary),
bar: resolve(colors.muted_foreground.as_ref(), defaults.muted_foreground),
block: resolve(colors.secondary.as_ref(), defaults.secondary),
accent: resolve(colors.primary.as_ref(), defaults.primary),
}
}
}
/// The colors of a theme's demo block
struct ThemePreview {
background: Hsla,
border: Hsla,
window: Hsla,
sidebar: Hsla,
bar: Hsla,
block: Hsla,
accent: Hsla,
}
impl Render for Onboarding {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const INTRO: &str =
"Pick how Coop works and looks. You can change all of this later in Preferences.";
const WOT: &str = "Treat people followed by your contacts as known senders.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
#[cfg(not(target_arch = "wasm32"))]
const WALLET: &str = "Show the wallet and allow ecash payments.";
const FONT_SIZE: &str = "The base font size for the interface.";
const RADIUS: &str =
"Corner radius for UI elements, including dialogs. Empty follows the theme.";
const FOCUS_RING: &str = "Draw a ring around focused controls.";
const SHADOW: &str = "Draw soft shadows on buttons, inputs and dialogs.";
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
let nip4e = AppSettings::get_nip4e(cx);
let theme = AppSettings::get_theme(cx);
let features = v_flex()
.gap_3()
.child(settings_row(
"Web of Trust",
WOT,
Switch::new("onboarding-wot")
.checked(wot_auto_trust)
.on_click(move |checked, _window, cx| {
AppSettings::update_wot_auto_trust(*checked, cx);
}),
cx,
))
.child(settings_row(
"Decoupling Encryption Key",
NIP4E,
Switch::new("onboarding-nip4e").checked(nip4e).on_click(
move |checked, _window, cx| {
AppSettings::update_nip4e(*checked, cx);
},
),
cx,
));
#[cfg(not(target_arch = "wasm32"))]
let features = features.child(settings_row(
"Wallet",
WALLET,
Switch::new("onboarding-wallet")
.checked(AppSettings::get_wallet(cx))
.on_click(move |checked, _window, cx| {
AppSettings::update_wallet(*checked, cx);
}),
cx,
));
v_flex()
.gap_4()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(INTRO),
)
.child(features)
.child(Separator::horizontal())
.child(self.themes_view(cx))
.child(Separator::horizontal())
.child(
v_flex()
.gap_3()
.child(settings_row(
"Font Size",
FONT_SIZE,
NumberInput::new(&self.font_size_input).w_24(),
cx,
))
.child(settings_row(
"Corner Radius",
RADIUS,
NumberInput::new(&self.radius_input).w_24(),
cx,
))
.child(settings_row(
"Focus Ring",
FOCUS_RING,
Switch::new("onboarding-focus-ring")
.checked(theme.focus_ring)
.on_click(move |checked, _window, cx| {
AppSettings::update_theme(|theme| theme.focus_ring = *checked, cx);
AppSettings::apply_theme_style(cx);
}),
cx,
))
.child(settings_row(
"Shadows",
SHADOW,
Switch::new("onboarding-shadow")
.checked(theme.shadow)
.on_click(move |checked, _window, cx| {
AppSettings::update_theme(|theme| theme.shadow = *checked, cx);
AppSettings::apply_theme_style(cx);
}),
cx,
)),
)
}
}
+87 -7
View File
@@ -5,16 +5,17 @@ use chat::{ChatRegistry, Room, RoomKind};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString, App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Task, Window, div, retain_all, Styled, Subscription, Task, Window, div, retain_all,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::clipboard::Clipboard; use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dock::DockPlacement; use gpui_kit::component::dock::DockPlacement;
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState}; use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use gpui_kit::component::notification::Notification; use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use person::{Directory, Person, PersonRegistry, shorten_pubkey};
use settings::AppSettings; use settings::AppSettings;
@@ -51,13 +52,19 @@ struct ProfileDialog {
uploading: bool, uploading: bool,
/// Updating state /// Updating state
updating: bool, updating: bool,
/// Error message
error: Option<SharedString>,
/// Tasks /// Tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Keeps the avatar preview live while the URL is typed
_subscription: Option<Subscription>,
} }
impl ProfileDialog { impl ProfileDialog {
fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let directory = Directory::new(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let is_current_user = nostr let is_current_user = nostr
.read(cx) .read(cx)
.current_user() .current_user()
@@ -75,7 +82,17 @@ impl ProfileDialog {
.placeholder("A short introduce about you.") .placeholder("A short introduce about you.")
}); });
let directory = Directory::new(cx); let subscription = cx.subscribe_in(
&avatar_input,
window,
|_this, _avatar_input, event, _window, cx| {
if matches!(event, InputEvent::Change) {
// The avatar preview only re-renders when the dialog does
cx.notify();
}
},
);
directory.update(cx, |directory, cx| { directory.update(cx, |directory, cx| {
directory.track([public_key], cx); directory.track([public_key], cx);
}); });
@@ -91,7 +108,9 @@ impl ProfileDialog {
website_input, website_input,
uploading: false, uploading: false,
updating: false, updating: false,
error: None,
tasks: vec![], tasks: vec![],
_subscription: Some(subscription),
} }
} }
@@ -190,6 +209,26 @@ impl ProfileDialog {
cx.notify(); cx.notify();
} }
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
where
E: Into<SharedString>,
{
self.error = Some(error.into());
cx.notify();
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
// Clear the error message after a delay
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})?;
Ok(())
}));
}
/// Set the metadata for the current user /// Set the metadata for the current user
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> { fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -218,19 +257,51 @@ impl ProfileDialog {
let bio = self.bio_input.read(cx).value(); let bio = self.bio_input.read(cx).value();
let website = self.website_input.read(cx).value(); let website = self.website_input.read(cx).value();
// Validate both URLs before publishing anything so a partial profile is never sent
let avatar_url = if avatar.is_empty() {
None
} else {
match Url::from_str(&avatar) {
Ok(url) => Some(url),
Err(_) => {
self.set_error(
"Avatar URL is invalid. Include the full address, e.g. https://example.com/avatar.jpg",
window,
cx,
);
return;
}
}
};
let website_url = if website.is_empty() {
None
} else {
match Url::from_str(&website) {
Ok(url) => Some(url),
Err(_) => {
self.set_error(
"Website URL is invalid. Include the full address, e.g. https://example.com",
window,
cx,
);
return;
}
}
};
// Construct the new metadata // Construct the new metadata
let mut new_metadata = old_metadata let mut new_metadata = old_metadata
.display_name(name.as_ref()) .display_name(name.as_ref())
.name(name.as_ref()) .name(name.as_ref())
.about(bio.as_ref()); .about(bio.as_ref());
// Verify the avatar URL before adding it // An empty input keeps the saved value, a valid URL replaces it
if let Ok(url) = Url::from_str(&avatar) { if let Some(url) = avatar_url {
new_metadata = new_metadata.picture(url); new_metadata = new_metadata.picture(url);
} }
// Verify the website URL before adding it if let Some(url) = website_url {
if let Ok(url) = Url::from_str(&website) {
new_metadata = new_metadata.website(url); new_metadata = new_metadata.website(url);
} }
@@ -429,6 +500,15 @@ impl Render for ProfileDialog {
this.child(field("Web of Trust", score, "Not loaded yet", cx)) this.child(field("Web of Trust", score, "Not loaded yet", cx))
}) })
}) })
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().danger)
.child(error.clone()),
)
})
.child( .child(
h_flex() h_flex()
.gap_1() .gap_1()
+59 -21
View File
@@ -10,6 +10,7 @@ use gpui::{
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::spinner::Spinner; use gpui_kit::component::spinner::Spinner;
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
use instant::Duration; use instant::Duration;
@@ -23,11 +24,23 @@ pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<
cx.new(|cx| Screening::new(public_key, window, cx)) cx.new(|cx| Screening::new(public_key, window, cx))
} }
/// Outcome of verifying the person's NIP-05 address
enum IdentifierStatus {
/// The check is still running
Pending,
/// The address matches the person's public key
Verified,
/// The address does not match the person's public key
Mismatched,
/// The check could not run, e.g. the network is unreachable
Failed,
}
pub struct Screening { pub struct Screening {
/// Public Key of the person being screened. /// Public Key of the person being screened.
public_key: PublicKey, public_key: PublicKey,
/// Whether the person's address is verified. /// Outcome of the NIP-05 address verification
verified: bool, identifier_status: IdentifierStatus,
/// Whether the person is followed by current user. /// Whether the person is followed by current user.
followed: bool, followed: bool,
/// Last time the person was active. /// Last time the person was active.
@@ -49,9 +62,8 @@ impl Screening {
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push(cx.on_release_in(window, move |this, window, cx| { subscriptions.push(cx.on_release_in(window, move |this, _window, _cx| {
this.tasks.clear(); this.tasks.clear();
window.close_all_dialogs(cx);
})); }));
subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| { subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| {
@@ -71,7 +83,7 @@ impl Screening {
Self { Self {
public_key, public_key,
verified: false, identifier_status: IdentifierStatus::Pending,
followed: false, followed: false,
last_active: None, last_active: None,
mutual_contacts: vec![], mutual_contacts: vec![],
@@ -221,10 +233,17 @@ impl Screening {
cx.background_spawn(async move { address.verify(&http_client, &public_key).await }); cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await.unwrap_or(false); let result = task.await;
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.verified = result; this.identifier_status = match result {
Ok(true) => IdentifierStatus::Verified,
Ok(false) => IdentifierStatus::Mismatched,
Err(error) => {
log::error!("failed to verify NIP-05 address: {error:#}");
IdentifierStatus::Failed
}
};
cx.notify(); cx.notify();
}) })
.ok(); .ok();
@@ -270,14 +289,26 @@ impl Screening {
Ok(()) Ok(())
}); });
self.tasks.push(cx.spawn_in(window, async move |_, cx| { self.tasks
if task.await.is_ok() { .push(cx.spawn_in(window, async move |_, cx| match task.await {
Ok(_) => {
cx.update(|window, cx| { cx.update(|window, cx| {
window.close_dialog(cx); window.close_dialog(cx);
window.push_notification("Report submitted successfully", cx); window.push_notification("Report submitted successfully", cx);
}) })
.ok(); .ok();
} }
Err(error) => {
log::error!("failed to submit report: {error:#}");
cx.update(|window, cx| {
window.push_notification(
Notification::error(error.to_string()).autohide(false),
cx,
);
})
.ok();
}
})); }));
} }
@@ -341,6 +372,9 @@ impl Render for Screening {
const NO_MUTUAL: &str = "You don't have any mutual contacts."; const NO_MUTUAL: &str = "You don't have any mutual contacts.";
const NIP05_MATCH: &str = "The address matches the user's public key."; const NIP05_MATCH: &str = "The address matches the user's public key.";
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key."; const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
const NIP05_PENDING: &str = "Checking the address…";
const NIP05_FAILED: &str =
"The address could not be verified, the network may be unavailable.";
const NO_NIP05: &str = "This person has not set up their friendly address"; const NO_NIP05: &str = "This person has not set up their friendly address";
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
@@ -349,6 +383,20 @@ impl Render for Screening {
let last_active = self.last_active.map(|_| true); let last_active = self.last_active.map(|_| true);
let (identifier_badge, identifier_message) = match self.identifier_status {
IdentifierStatus::Pending => (None, NIP05_PENDING),
IdentifierStatus::Verified => (Some(true), NIP05_MATCH),
IdentifierStatus::Mismatched => (Some(false), NIP05_NOT_MATCH),
IdentifierStatus::Failed => (Some(false), NIP05_FAILED),
};
// Without an address the check never runs
let (identifier_badge, identifier_message) = if self.address(cx).is_some() {
(identifier_badge, identifier_message)
} else {
(Some(false), NO_NIP05)
};
// Pull from the web of trust once loaded, else use the database fallback // Pull from the web of trust once loaded, else use the database fallback
let mutual_contacts = { let mutual_contacts = {
let wot = persons.read(cx).wot().read(cx); let wot = persons.read(cx).wot().read(cx);
@@ -491,7 +539,7 @@ impl Render for Screening {
h_flex() h_flex()
.items_start() .items_start()
.gap_2() .gap_2()
.child(status_badge(Some(self.verified), cx)) .child(status_badge(identifier_badge, cx))
.child( .child(
v_flex() v_flex()
.text_xs() .text_xs()
@@ -508,17 +556,7 @@ impl Render for Screening {
div() div()
.line_clamp(1) .line_clamp(1)
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child({ .child(SharedString::from(identifier_message)),
if self.address(cx).is_some() {
if self.verified {
SharedString::from(NIP05_MATCH)
} else {
SharedString::from(NIP05_NOT_MATCH)
}
} else {
SharedString::from(NO_NIP05)
}
}),
), ),
), ),
) )
+43 -10
View File
@@ -7,6 +7,8 @@ use gpui::PathPromptOptions;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use gpui::Task; use gpui::Task;
use gpui::http_client::Url; use gpui::http_client::Url;
#[cfg(not(target_arch = "wasm32"))]
use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled,
Subscription, Window, div, Subscription, Window, div,
@@ -28,8 +30,8 @@ use speech::model::{self as speech_model};
use ui::{settings_block, settings_row}; use ui::{settings_block, settings_row};
/// Bounds for the theme number inputs /// Bounds for the theme number inputs
const FONT_SIZE_RANGE: (f32, f32) = (10., 32.); pub(super) const FONT_SIZE_RANGE: (f32, f32) = (10., 32.);
const RADIUS_RANGE: (f32, f32) = (0., 24.); pub(super) const RADIUS_RANGE: (f32, f32) = (0., 24.);
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
cx.new(|cx| Preferences::new(window, cx)) cx.new(|cx| Preferences::new(window, cx))
@@ -95,7 +97,7 @@ impl Preferences {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…")); let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…"));
let subscriptions = vec![ let mut subscriptions = vec![
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| { Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
if let Some(font_size) = value { if let Some(font_size) = value {
AppSettings::update_theme(|theme| theme.font_size = font_size, cx); AppSettings::update_theme(|theme| theme.font_size = font_size, cx);
@@ -112,6 +114,12 @@ impl Preferences {
}), }),
]; ];
// Keep the switches in sync with the settings they edit
subscriptions.push({
let settings = AppSettings::global(cx);
cx.observe(&settings, |_, _, cx| cx.notify())
});
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let _speech_poll = cx.spawn_in(window, async move |this, cx| { let _speech_poll = cx.spawn_in(window, async move |this, cx| {
let timer = Duration::from_millis(500); let timer = Duration::from_millis(500);
@@ -219,10 +227,25 @@ impl Preferences {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
const WALLET: &str = "Cashu mint for your NIP-60 wallet. Set up creates the wallet, Add extends it with another mint."; const WALLET: &str = "Cashu mint for your NIP-60 wallet. Set up creates the wallet, Add extends it with another mint.";
let enabled = AppSettings::get_wallet(cx);
let has_wallet = nip60::WalletRegistry::global(cx).read(cx).has_wallet(); let has_wallet = nip60::WalletRegistry::global(cx).read(cx).has_wallet();
settings_block( v_flex()
.w_full()
.gap_3()
.child(settings_row(
"Wallet", "Wallet",
"Show the wallet and allow ecash payments.",
Switch::new("wallet")
.checked(enabled)
.on_click(move |checked, _window, cx| {
AppSettings::update_wallet(*checked, cx);
}),
cx,
))
.when(enabled, |this| {
this.child(settings_block(
"Mint",
WALLET, WALLET,
h_flex() h_flex()
.gap_1() .gap_1()
@@ -237,7 +260,8 @@ impl Preferences {
})), })),
), ),
cx, cx,
) ))
})
.into_any_element() .into_any_element()
} }
} }
@@ -458,6 +482,7 @@ impl Render for Preferences {
const RADIUS_LG: &str = const RADIUS_LG: &str =
"Corner radius for large elements like dialogs. Empty follows the OS theme."; "Corner radius for large elements like dialogs. Empty follows the OS theme.";
const FOCUS_RING: &str = "Draw a ring around focused controls."; const FOCUS_RING: &str = "Draw a ring around focused controls.";
const SHADOW: &str = "Draw soft shadows on buttons, inputs and dialogs.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages."; const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it."; const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
const BACKGROUND: &str = "Show an image behind chat and group panels."; const BACKGROUND: &str = "Show an image behind chat and group panels.";
@@ -566,14 +591,22 @@ impl Render for Preferences {
FOCUS_RING, FOCUS_RING,
Switch::new("focus-ring") Switch::new("focus-ring")
.checked(theme.focus_ring) .checked(theme.focus_ring)
.on_click(move |_, _window, cx| { .on_click(move |checked, _window, cx| {
AppSettings::update_theme( AppSettings::update_theme(|theme| theme.focus_ring = *checked, cx);
|theme| theme.focus_ring = !theme.focus_ring,
cx,
);
AppSettings::apply_theme_style(cx); AppSettings::apply_theme_style(cx);
}), }),
cx, cx,
))
.child(settings_row(
"Shadows",
SHADOW,
Switch::new("shadow").checked(theme.shadow).on_click(
move |checked, _window, cx| {
AppSettings::update_theme(|theme| theme.shadow = *checked, cx);
AppSettings::apply_theme_style(cx);
},
),
cx,
)), )),
) )
.child(Separator::horizontal()) .child(Separator::horizontal())
+118 -30
View File
@@ -1,9 +1,11 @@
use std::sync::Arc; use std::sync::Arc;
use gpui::{App, Pixels, WeakEntity, Window, px}; use chat_ui::ChatPanel;
use gpui_kit::component::dock::{ use gpui::{App, Entity, Pixels, SharedString, WeakEntity, Window, px};
DockArea, DockPlacement, InsertTarget, NodeId, PaneRef, PanelHandle, PanelId, use gpui_base::dock::PanelView;
}; use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle, PanelId};
use nip29::GroupKey;
use nip29_ui::GroupPanel;
mod invalid_panel; mod invalid_panel;
mod skin; mod skin;
@@ -14,39 +16,83 @@ pub use skin::CoopDockSkin;
/// The fixed height of the tab bar, which doubles as the window title bar. /// The fixed height of the tab bar, which doubles as the window title bar.
pub const TAB_BAR_HEIGHT: Pixels = px(44.); pub const TAB_BAR_HEIGHT: Pixels = px(44.);
/// Dock placements.
/// The panel in any region of `area whose id is `key. const DOCK_PLACEMENTS: [DockPlacement; 4] = [
fn find_panel(area: &DockArea, key: PanelId, _cx: &App) -> Option<(PanelId, NodeId, usize)> {
let placements = [
DockPlacement::Center, DockPlacement::Center,
DockPlacement::Left, DockPlacement::Left,
DockPlacement::Right, DockPlacement::Right,
DockPlacement::Bottom, DockPlacement::Bottom,
]; ];
for placement in placements { /// What makes a panel the same panel across opens.
#[derive(Clone, PartialEq, Eq)]
enum PanelKey {
/// A panel of which there should be one, keyed by its `panel_name`.
Named(&'static str),
/// A chat panel, keyed by the id of the room it displays.
Room(SharedString),
}
/// The key a panel handle carries, or `None` when it cannot be read off the panel.
fn panel_key(panel: &PanelHandle, cx: &App) -> Option<PanelKey> {
let view = panel.panel();
if let Some(chat) = view.as_any().downcast_ref::<Entity<ChatPanel>>() {
return Some(PanelKey::Room(chat.read(cx).room_id().clone()));
}
if view.as_any().downcast_ref::<Entity<GroupPanel>>().is_some() {
return None;
}
Some(PanelKey::Named(view.panel_name(cx)))
}
/// The key of a panel docked in `area`, recovered through the handle base holds.
fn docked_panel_key(view: &Arc<dyn PanelView>, cx: &App) -> Option<PanelKey> {
let handle = PanelHandle::of(view)?;
panel_key(handle, cx)
}
/// The docked panel whose key is `key`, and the dock it sits in.
fn find_panel(area: &DockArea, key: &PanelKey, cx: &App) -> Option<(PanelId, DockPlacement)> {
for placement in DOCK_PLACEMENTS {
let Some(tree) = area.layout(placement) else { let Some(tree) = area.layout(placement) else {
continue; continue;
}; };
for id in tree.panels() { for id in tree.panels() {
if id != key { let Some(view) = area.panel(id) else {
continue;
}
let node = tree.find_panel_node(id)?;
let PaneRef::Tabs { panels, .. } = tree.find_node(node)?.kind() else {
continue; continue;
}; };
let ix = panels.iter().position(|panel| *panel == key)?; if docked_panel_key(view, cx).is_some_and(|found| &found == key) {
return Some((id, node, ix)); return Some((id, placement));
}
} }
} }
None None
} }
/// Add `panel to the area, or move it to the end of its existing tab group. /// Bring the docked panel `id` on screen.
pub fn focus_panel(
area: &mut DockArea,
id: PanelId,
window: &mut Window,
cx: &mut gpui::Context<DockArea>,
) {
let Some(placement) = DOCK_PLACEMENTS.into_iter().find(|placement| {
area.layout(*placement)
.is_some_and(|tree| tree.panels().any(|panel| panel == id))
}) else {
return;
};
// The center region has no open state, and `toggle_dock` would ignore it.
if placement != DockPlacement::Center && !area.is_dock_open(placement) {
area.toggle_dock(placement, window, cx);
}
area.select_panel(id, window, cx);
}
/// Add `panel` to the area, or focus the panel already showing the same thing.
pub fn add_panel( pub fn add_panel(
area: &mut DockArea, area: &mut DockArea,
panel: PanelHandle, panel: PanelHandle,
@@ -54,24 +100,44 @@ pub fn add_panel(
window: &mut Window, window: &mut Window,
cx: &mut gpui::Context<DockArea>, cx: &mut gpui::Context<DockArea>,
) { ) {
let key = panel.panel_id(cx); let Some(key) = panel_key(&panel, cx) else {
if let Some((id, node, ix)) = find_panel(area, key, cx) { area.add_panel_view(Arc::new(panel), placement, None, window, cx);
area.move_panel( return;
id, };
InsertTarget::Tabs {
node, if let Some((id, _)) = find_panel(area, &key, cx) {
ix: Some(ix), focus_panel(area, id, window, cx);
activate: true,
},
window,
cx,
);
return; return;
} }
area.add_panel_view(Arc::new(panel), placement, None, window, cx); area.add_panel_view(Arc::new(panel), placement, None, window, cx);
} }
/// Close the docked chat panel showing `room_id`, if there is one.
pub fn close_room_panel(
area: &mut DockArea,
room_id: &SharedString,
window: &mut Window,
cx: &mut gpui::Context<DockArea>,
) {
let key = PanelKey::Room(room_id.clone());
let Some((id, _)) = find_panel(area, &key, cx) else {
return;
};
let Some(view) = area.panel(id) else {
return;
};
let Some(chat) = view.as_any().downcast_ref::<Entity<ChatPanel>>() else {
return;
};
let chat = chat.clone();
area.remove_panel(chat, window, cx);
}
/// Add a panel to a dock reached through a weak handle. /// Add a panel to a dock reached through a weak handle.
pub fn add_panel_to( pub fn add_panel_to(
dock: &WeakEntity<DockArea>, dock: &WeakEntity<DockArea>,
@@ -86,3 +152,25 @@ pub fn add_panel_to(
log::warn!("Failed to add panel to dock: {error}"); log::warn!("Failed to add panel to dock: {error}");
} }
} }
/// Add a group panel to a dock, or focus the panel already showing that group.
///
/// Group panels hide their group, so the workspace records which panel it
/// docked per key; without that record, reopening a group stacks another tab.
pub fn add_group_panel_to(
dock: &WeakEntity<DockArea>,
group_key: GroupKey,
panel: PanelHandle,
placement: DockPlacement,
window: &mut Window,
cx: &mut App,
) {
let Some(root) = window.root::<crate::Workspace>().flatten() else {
add_panel_to(dock, panel, placement, window, cx);
return;
};
root.update(cx, |this, cx| {
this.add_group_panel_to_dock(group_key, panel, placement, window, cx);
});
}
-6
View File
@@ -22,7 +22,6 @@ use super::tab_panel::CoopTabGroupSkin;
/// State the skin shares with its per-container renderers. /// State the skin shares with its per-container renderers.
pub(crate) struct SkinShared { pub(crate) struct SkinShared {
area: WeakEntity<DockArea>, area: WeakEntity<DockArea>,
toggle_button_visible: Cell<bool>,
/// The dock whose resize handle is being dragged, if any. Only one can be. /// The dock whose resize handle is being dragged, if any. Only one can be.
resizing_dock: Cell<Option<DockPlacement>>, resizing_dock: Cell<Option<DockPlacement>>,
} }
@@ -32,10 +31,6 @@ impl SkinShared {
&self.area &self.area
} }
pub(crate) fn is_toggle_button_visible(&self) -> bool {
self.toggle_button_visible.get()
}
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> { pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
&self.resizing_dock &self.resizing_dock
} }
@@ -52,7 +47,6 @@ impl CoopDockSkin {
Rc::new(Self { Rc::new(Self {
shared: Rc::new(SkinShared { shared: Rc::new(SkinShared {
area: cx.weak_entity(), area: cx.weak_entity(),
toggle_button_visible: Cell::new(true),
resizing_dock: Cell::new(None), resizing_dock: Cell::new(None),
}), }),
}) })
+1 -1
View File
@@ -209,7 +209,7 @@ impl CoopTabGroupSkin {
group: &TabGroupContext, group: &TabGroupContext,
cx: &mut App, cx: &mut App,
) -> Option<Button> { ) -> Option<Button> {
if group.is_zoomed() || !self.shared.is_toggle_button_visible() { if group.is_zoomed() {
return None; return None;
} }
+64 -6
View File
@@ -1,8 +1,9 @@
use std::cell::Cell; use std::cell::Cell;
use std::collections::HashMap;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use ::settings::{AppSettings, AppearanceMode}; use ::settings::{AppSettings, AppearanceMode, persisted_onboarded};
use anyhow::Error; use anyhow::Error;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater; use auto_update::AutoUpdater;
@@ -17,7 +18,7 @@ use gpui_kit::component::button::Button;
use gpui_kit::component::command::{ use gpui_kit::component::command::{
Command as CommandPalette, CommandGroup, CommandItem, CommandState, Command as CommandPalette, CommandGroup, CommandItem, CommandState,
}; };
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle}; use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle, PanelId};
use gpui_kit::component::notification::{Notification, NotificationType}; use gpui_kit::component::notification::{Notification, NotificationType};
use gpui_kit::component::{ use gpui_kit::component::{
ActiveTheme, IndexPath, Theme, ThemeConfig, ThemeRegistry, WindowExt, v_flex, ActiveTheme, IndexPath, Theme, ThemeConfig, ThemeRegistry, WindowExt, v_flex,
@@ -30,7 +31,9 @@ use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent}; use state::{NostrRegistry, StateEvent};
use crate::dialogs::restore::RestoreEncryption; use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::{import, join_group, new_chat, new_group, profile, relays, settings}; use crate::dialogs::{
import, join_group, new_chat, new_group, onboarding, profile, relays, settings,
};
use crate::panels::{backup, browse, contact_list, greeter, inbox}; use crate::panels::{backup, browse, contact_list, greeter, inbox};
use crate::sidebar::Sidebar; use crate::sidebar::Sidebar;
@@ -78,6 +81,9 @@ enum Command {
pub struct Workspace { pub struct Workspace {
dock: Entity<DockArea>, dock: Entity<DockArea>,
sidebar: Entity<Sidebar>, sidebar: Entity<Sidebar>,
/// Group panels docked by group key, so reopening a group focuses its panel.
/// Group panels hide their group, so the dock cannot tell them apart.
group_panels: HashMap<GroupKey, PanelId>,
/// Async tasks /// Async tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions /// Event subscriptions
@@ -94,6 +100,7 @@ impl Workspace {
let skin = dock::CoopDockSkin::new(cx); let skin = dock::CoopDockSkin::new(cx);
gpui_kit::component::dock::DockArea::new("coop", None, window, cx).with_renderer(skin) gpui_kit::component::dock::DockArea::new("coop", None, window, cx).with_renderer(skin)
}); });
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx)); let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
let left_sidebar = sidebar.clone(); let left_sidebar = sidebar.clone();
@@ -107,10 +114,18 @@ impl Workspace {
} }
})); }));
let login_nostr = nostr.clone();
subscriptions.push( subscriptions.push(
// Subscribe to the nostr events // Subscribe to the nostr events
cx.subscribe_in(&nostr, window, move |_, _, event, window, cx| match event { cx.subscribe_in(&nostr, window, move |_, _, event, window, cx| match event {
StateEvent::SignerChanged => window.close_all_dialogs(cx), StateEvent::SignerChanged => {
window.close_all_dialogs(cx);
// Show the first-run onboarding once the user is signed in
if !persisted_onboarded() && login_nostr.read(cx).current_user().is_some() {
onboarding::open(window, cx);
}
}
StateEvent::PassphraseRequired => import::open_unlock(window, cx), StateEvent::PassphraseRequired => import::open_unlock(window, cx),
_ => {} _ => {}
}), }),
@@ -265,11 +280,16 @@ impl Workspace {
this.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx); this.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx);
this.set_center(center, window, cx); this.set_center(center, window, cx);
}); });
if !persisted_onboarded() && nostr.read(cx).current_user().is_some() {
onboarding::open(window, cx);
}
}); });
Self { Self {
dock, dock,
sidebar, sidebar,
group_panels: HashMap::new(),
tasks: vec![], tasks: vec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
@@ -304,6 +324,43 @@ impl Workspace {
}); });
} }
fn add_group_panel_to_dock(
&mut self,
group_key: GroupKey,
panel: PanelHandle,
placement: DockPlacement,
window: &mut Window,
cx: &mut Context<Self>,
) {
let panel_id = panel.panel_id(cx);
let docked = self.group_panels.get(&group_key).copied();
let mut added = false;
let mut focused = false;
self.dock.update(cx, |area, cx| {
if let Some(id) = docked
&& area.panel(id).is_some()
{
crate::dock::focus_panel(area, id, window, cx);
focused = true;
return;
}
crate::dock::add_panel(area, panel, placement, window, cx);
added = area.panel(panel_id).is_some();
});
if focused {
return;
}
if added {
self.group_panels.insert(group_key, panel_id);
} else {
self.group_panels.remove(&group_key);
}
}
/// Handle command events /// Handle command events
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) { fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command { match command {
@@ -343,9 +400,10 @@ impl Workspace {
let panel = browse::init(self.dock.downgrade(), window, cx); let panel = browse::init(self.dock.downgrade(), window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx); self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
} }
Command::ShowWallet => { Command::ShowWallet =>
#[cfg(not(target_arch = "wasm32"))]
{ {
#[cfg(not(target_arch = "wasm32"))]
if AppSettings::get_wallet(cx) {
let panel = nip60_ui::wallet::init(window, cx); let panel = nip60_ui::wallet::init(window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx); self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
} }
+57 -9
View File
@@ -1,4 +1,5 @@
use anyhow::Error; use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, SharedString, Styled, Task, Window, div, ParentElement, Render, SharedString, Styled, Task, Window, div,
@@ -9,6 +10,7 @@ use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputState}; use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::separator::Separator; use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ActiveTheme, v_flex}; use gpui_kit::component::{ActiveTheme, v_flex};
use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::USER_KEYRING; use state::USER_KEYRING;
@@ -23,14 +25,12 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<BackupPanel> {
pub struct BackupPanel { pub struct BackupPanel {
name: SharedString, name: SharedString,
focus_handle: FocusHandle, focus_handle: FocusHandle,
/// Public key input /// Public key input
npub_input: Entity<InputState>, npub_input: Entity<InputState>,
/// Secret key input /// Secret key input
nsec_input: Entity<InputState>, nsec_input: Entity<InputState>,
/// Error message
/// Background tasks error: Option<SharedString>,
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
} }
@@ -49,6 +49,7 @@ impl BackupPanel {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
npub_input, npub_input,
nsec_input, nsec_input,
error: None,
tasks: vec![], tasks: vec![],
} }
} }
@@ -57,20 +58,58 @@ impl BackupPanel {
let keyring = cx.read_credentials(USER_KEYRING); let keyring = cx.read_credentials(USER_KEYRING);
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
if let Some((_, secret)) = keyring.await? { let result: Result<(), Error> = async {
let secret = SecretKey::from_slice(&secret)?; let Some((_, secret)) = keyring.await? else {
let keys = Keys::new(secret); return Ok(());
};
let keys = Keys::new(SecretKey::from_slice(&secret)?);
// Bech32 encoding of a valid key cannot fail today, but handle it instead of panicking
let npub = keys.public_key().to_bech32()?;
let nsec = keys.secret_key().to_bech32()?;
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.npub_input.update(cx, |this, cx| { this.npub_input.update(cx, |this, cx| {
this.set_value(keys.public_key().to_bech32().unwrap(), window, cx); this.set_value(npub.as_str(), window, cx);
}); });
this.nsec_input.update(cx, |this, cx| { this.nsec_input.update(cx, |this, cx| {
this.set_value(keys.secret_key().to_bech32().unwrap(), window, cx); this.set_value(nsec.as_str(), window, cx);
}); });
})?; })?;
Ok(())
} }
.await;
if let Err(error) = result {
log::error!("failed to load account keys: {error:#}");
this.update_in(cx, |this, window, cx| {
this.set_error(format!("Failed to load your keys: {error}"), window, cx);
})?;
}
Ok(())
}));
}
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
where
E: Into<SharedString>,
{
self.error = Some(error.into());
cx.notify();
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Clear the error message after a delay
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})?;
Ok(()) Ok(())
})); }));
@@ -110,6 +149,15 @@ impl Render for BackupPanel {
.child(SharedString::from(MSG)), .child(SharedString::from(MSG)),
) )
.child(Separator::horizontal()) .child(Separator::horizontal())
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().danger)
.child(error.clone()),
)
})
.child( .child(
v_flex() v_flex()
.gap_2() .gap_2()
+3 -2
View File
@@ -227,10 +227,11 @@ impl BrowsePanel {
/// Open the group's panel to preview (or revisit) it. /// Open the group's panel to preview (or revisit) it.
fn open_panel(dock: &WeakEntity<DockArea>, key: GroupKey, window: &mut Window, cx: &mut App) { fn open_panel(dock: &WeakEntity<DockArea>, key: GroupKey, window: &mut Window, cx: &mut App) {
let registry = GroupsRegistry::global(cx); let registry = GroupsRegistry::global(cx);
let group = registry.update(cx, |registry, cx| registry.open(key, cx)); let group = registry.update(cx, |registry, cx| registry.open(key.clone(), cx));
crate::dock::add_panel_to( crate::dock::add_group_panel_to(
dock, dock,
key,
PanelHandle::new(nip29_ui::init(group, window, cx)), PanelHandle::new(nip29_ui::init(group, window, cx)),
DockPlacement::Center, DockPlacement::Center,
window, window,
+33 -30
View File
@@ -29,26 +29,17 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
pub struct ContactListPanel { pub struct ContactListPanel {
name: SharedString, name: SharedString,
focus_handle: FocusHandle, focus_handle: FocusHandle,
/// Npub input /// Npub input
input: Entity<InputState>, input: Entity<InputState>,
/// Whether the panel is updating /// Whether the panel is updating
updating: bool, updating: bool,
/// Error message /// Error message
error: Option<SharedString>, error: Option<SharedString>,
/// All contacts /// All contacts
contacts: HashSet<PublicKey>, contacts: HashSet<Contact>,
/// Person directory for the contacts /// Person directory for the contacts
directory: Entity<Directory>, directory: Entity<Directory>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
} }
@@ -92,33 +83,50 @@ impl ContactListPanel {
return; return;
}; };
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move { let task: Task<Result<HashSet<Contact>, Error>> = cx.background_spawn(async move {
let filter = Filter::new() let filter = Filter::new()
.author(public_key) .author(public_key)
.kind(Kind::ContactList) .kind(Kind::ContactList)
.limit(1); .limit(1);
let contact_list: HashSet<PublicKey> = client let contact_list: HashSet<Contact> = client
.database() .database()
.query(filter) .query(filter)
.await? .await?
.into_iter() .into_iter()
.next() .next()
.map(|event| event.tags.public_keys().collect()) .map(|event| {
event
.tags
.iter()
.filter_map(|tag| match Nip02Tag::try_from(tag) {
Ok(Nip02Tag::PublicKey {
public_key,
relay_hint,
alias,
}) => Some(Contact {
public_key,
relay_url: relay_hint,
alias,
}),
Err(_) => None,
})
.collect()
})
.unwrap_or_default(); .unwrap_or_default();
Ok(contact_list) Ok(contact_list)
}); });
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let public_keys = task.await?; let contacts = task.await?;
// Update state // Update state
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.directory.update(cx, |directory, cx| { this.directory.update(cx, |directory, cx| {
directory.track(public_keys.iter().copied(), cx) directory.track(contacts.iter().map(|contact| contact.public_key), cx)
}); });
this.contacts.extend(public_keys); this.contacts.extend(contacts);
cx.notify(); cx.notify();
})?; })?;
@@ -130,7 +138,7 @@ impl ContactListPanel {
let value = self.input.read(cx).value().to_string(); let value = self.input.read(cx).value().to_string();
if let Ok(public_key) = PublicKey::parse(&value) { if let Ok(public_key) = PublicKey::parse(&value) {
if self.contacts.insert(public_key) { if self.contacts.insert(Contact::new(public_key)) {
self.directory self.directory
.update(cx, |directory, cx| directory.track([public_key], cx)); .update(cx, |directory, cx| directory.track([public_key], cx));
self.input.update(cx, |this, cx| { self.input.update(cx, |this, cx| {
@@ -144,7 +152,8 @@ impl ContactListPanel {
} }
fn remove(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) { fn remove(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
self.contacts.remove(public_key); self.contacts
.retain(|contact| contact.public_key != *public_key);
cx.notify(); cx.notify();
} }
@@ -184,11 +193,7 @@ impl ContactListPanel {
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
// Get contacts // Get contacts
let contacts: Vec<Contact> = self let contacts: Vec<Contact> = self.contacts.iter().cloned().collect();
.contacts
.iter()
.map(|public_key| Contact::new(*public_key))
.collect();
// Set updating state // Set updating state
self.set_updating(true, cx); self.set_updating(true, cx);
@@ -230,8 +235,9 @@ impl ContactListPanel {
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> { fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
let mut items = Vec::new(); let mut items = Vec::new();
for (ix, public_key) in self.contacts.iter().enumerate() { for (ix, contact) in self.contacts.iter().enumerate() {
let profile = self.directory.read(cx).person(public_key, cx); let public_key = contact.public_key;
let profile = self.directory.read(cx).person(&public_key, cx);
items.push( items.push(
h_flex() h_flex()
@@ -262,12 +268,9 @@ impl ContactListPanel {
.ghost() .ghost()
.invisible() .invisible()
.group_hover("", |this| this.visible()) .group_hover("", |this| this.visible())
.on_click({ .on_click(cx.listener(move |this, _ev, _window, cx| {
let public_key = public_key.to_owned();
cx.listener(move |this, _ev, _window, cx| {
this.remove(&public_key, cx); this.remove(&public_key, cx);
}) })),
}),
), ),
) )
} }
+15 -11
View File
@@ -1,12 +1,12 @@
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater; use auto_update::{AutoUpdater, UpdateDisplay};
#[cfg(not(target_arch = "wasm32"))]
use gpui::AnyElement;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString, Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString,
Stateful, Styled, Window, div, px, Stateful, Styled, Window, div, px,
}; };
#[cfg(not(target_arch = "wasm32"))]
use gpui::{AnyElement, Entity};
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use gpui_kit::component::Disableable; use gpui_kit::component::Disableable;
@@ -35,7 +35,7 @@ impl Sidebar {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Stateful<Div> { ) -> Stateful<Div> {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let updater = AutoUpdater::try_global(cx); let updater_display = self.updater_display.read(cx).clone();
let profile = self.directory.read(cx).person(current_user, cx); let profile = self.directory.read(cx).person(current_user, cx);
let avatar = profile.avatar(); let avatar = profile.avatar();
let encryption_key = profile let encryption_key = profile
@@ -78,8 +78,8 @@ impl Sidebar {
let actions = h_flex().gap_1(); let actions = h_flex().gap_1();
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let actions = actions.when_some(updater, |this, updater| { let actions = actions.when_some(updater_display, |this, display| {
this.child(self.render_updater(updater, cx)) this.child(self.render_updater(display))
}); });
actions actions
@@ -233,10 +233,12 @@ impl Sidebar {
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement { fn render_updater(&self, display: UpdateDisplay) -> AnyElement {
let status = updater.read(cx).status(); let UpdateDisplay {
let up_to_date = updater.read(cx).up_to_date(); up_to_date,
let staged = updater.read(cx).staged(); staged,
status,
} = display;
h_flex() h_flex()
.gap_2() .gap_2()
@@ -257,10 +259,12 @@ impl Sidebar {
.tooltip("Quit and relaunch into the installed update") .tooltip("Quit and relaunch into the installed update")
.small() .small()
.ghost() .ghost()
.on_click(move |_, _window, cx| { .on_click(|_, _window, cx| {
if let Some(updater) = AutoUpdater::try_global(cx) {
updater.update(cx, |this, cx| { updater.update(cx, |this, cx| {
this.restart(cx); this.restart(cx);
}); });
}
}), }),
) )
}) })
+9 -3
View File
@@ -67,17 +67,23 @@ impl Sidebar {
.gap_1() .gap_1()
.items_start() .items_start()
.justify_start() .justify_start()
.when(cfg!(not(target_arch = "wasm32")), |this| { .when(
cfg!(not(target_arch = "wasm32")) && AppSettings::get_wallet(cx),
|this| {
this.child( this.child(
Nav::new("wallet") Nav::new("wallet")
.prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small()) .prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small())
.label("Wallet") .label("Wallet")
.text_sm() .text_sm()
.on_click(|_, window, cx| { .on_click(|_, window, cx| {
window.dispatch_action(Box::new(crate::Command::ShowWallet), cx) window.dispatch_action(
Box::new(crate::Command::ShowWallet),
cx,
)
}), }),
) )
}) },
)
.child( .child(
Nav::new("browse") Nav::new("browse")
.prefix(Icon::new(IconName::Compass).small()) .prefix(Icon::new(IconName::Compass).small())
+24 -13
View File
@@ -1,6 +1,8 @@
use std::collections::HashSet; use std::collections::HashSet;
use anyhow::Error; use anyhow::Error;
#[cfg(not(target_arch = "wasm32"))]
use auto_update::{AutoUpdater, UpdateDisplay};
use chat::{ChatEvent, ChatRegistry}; use chat::{ChatEvent, ChatRegistry};
use common::DebouncedDelay; use common::DebouncedDelay;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
@@ -21,7 +23,6 @@ use nostr_sdk::prelude::*;
use person::{Directory, PersonRegistry}; use person::{Directory, PersonRegistry};
pub(crate) use rows::open_room; pub(crate) use rows::open_room;
use search::SearchResult; use search::SearchResult;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent}; use state::{FIND_DELAY, NostrRegistry, StateEvent};
use ui::title_bar_drag_handlers; use ui::title_bar_drag_handlers;
@@ -44,7 +45,6 @@ pub struct Sidebar {
dock: WeakEntity<DockArea>, dock: WeakEntity<DockArea>,
/// Random background shown behind the signed-out screen. /// Random background shown behind the signed-out screen.
banner: SharedString, banner: SharedString,
/// The group whose information the sidebar is browsing, if any. /// The group whose information the sidebar is browsing, if any.
group: Option<WeakEntity<Group>>, group: Option<WeakEntity<Group>>,
/// Whether the sidebar is showing the group's information instead of its list. /// Whether the sidebar is showing the group's information instead of its list.
@@ -53,14 +53,12 @@ pub struct Sidebar {
admins_open: bool, admins_open: bool,
/// Whether the group view's members section is expanded. /// Whether the group view's members section is expanded.
members_open: bool, members_open: bool,
/// Whether the main list's pinned section is expanded. /// Whether the main list's pinned section is expanded.
pins_open: bool, pins_open: bool,
/// Whether the user expanded or collapsed the main list's groups section. /// Whether the user expanded or collapsed the main list's groups section.
groups_open: Option<bool>, groups_open: Option<bool>,
/// Same as `groups_open` for the chats section. /// Same as `groups_open` for the chats section.
chats_open: Option<bool>, chats_open: Option<bool>,
/// Whether the main list is showing contacts to start a chat with. /// Whether the main list is showing contacts to start a chat with.
contacts_open: bool, contacts_open: bool,
/// The signed-in user's contacts, loaded when the contact picker opens. /// The signed-in user's contacts, loaded when the contact picker opens.
@@ -69,11 +67,9 @@ pub struct Sidebar {
directory: Entity<Directory>, directory: Entity<Directory>,
/// Contacts selected in the contact picker. /// Contacts selected in the contact picker.
selected_contacts: HashSet<PublicKey>, selected_contacts: HashSet<PublicKey>,
/// Whether the main list is showing chat requests instead of chats and groups. /// Whether the main list is showing chat requests instead of chats and groups.
requests_open: bool, requests_open: bool,
new_requests: bool, new_requests: bool,
/// Whether the search bar is shown below the header. /// Whether the search bar is shown below the header.
search_open: bool, search_open: bool,
/// Whether searches also cover global (relay) profiles. /// Whether searches also cover global (relay) profiles.
@@ -90,12 +86,13 @@ pub struct Sidebar {
global_results: Option<Vec<PublicKey>>, global_results: Option<Vec<PublicKey>>,
/// The async global profile search. /// The async global profile search.
global_task: Option<Task<()>>, global_task: Option<Task<()>>,
/// Simulated gift wrap processing progress, shown while the backlog loads /// Simulated gift wrap processing progress, shown while the backlog loads
chat_progress: f32, chat_progress: f32,
/// Simulated web of trust sync progress /// Simulated web of trust sync progress
sync_progress: f32, sync_progress: f32,
/// The updater facts the header renders, mirrored so updater churn cannot invalidate the cached sidebar
#[cfg(not(target_arch = "wasm32"))]
updater_display: Entity<Option<UpdateDisplay>>,
tasks: SmallVec<[Task<Result<(), Error>>; 1]>, tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
_subscriptions: SmallVec<[Subscription; 4]>, _subscriptions: SmallVec<[Subscription; 4]>,
} }
@@ -105,7 +102,6 @@ impl Sidebar {
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let groups = GroupsRegistry::global(cx); let groups = GroupsRegistry::global(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx);
let directory = Directory::new(cx); let directory = Directory::new(cx);
let find_input = cx.new(|cx| { let find_input = cx.new(|cx| {
@@ -209,10 +205,23 @@ impl Sidebar {
}, },
)); ));
// The user header reads settings and the tracked profiles on render, so #[cfg(not(target_arch = "wasm32"))]
// the encryption button reacts to toggles and announcement arrivals let updater_display =
subscriptions.push(cx.observe(&settings, |_, _, cx| cx.notify())); cx.new(|cx| AutoUpdater::try_global(cx).map(|updater| updater.read(cx).display()));
subscriptions.push(cx.observe(&directory, |_, _, cx| cx.notify()));
// Mirror only the header's rendered facts; updater churn must not invalidate the whole cached sidebar
#[cfg(not(target_arch = "wasm32"))]
if let Some(updater) = AutoUpdater::try_global(cx) {
subscriptions.push(cx.observe(&updater, |this, updater, cx| {
let display = updater.read(cx).display();
this.updater_display.update(cx, |state, cx| {
if state.as_ref() != Some(&display) {
*state = Some(display);
cx.notify();
}
});
}));
}
tasks.push(cx.spawn(async move |this, cx| { tasks.push(cx.spawn(async move |this, cx| {
let timer = Duration::from_millis(PROGRESS_TICK_MS); let timer = Duration::from_millis(PROGRESS_TICK_MS);
@@ -251,6 +260,8 @@ impl Sidebar {
global_task: None, global_task: None,
chat_progress: 0.0, chat_progress: 0.0,
sync_progress: 0.0, sync_progress: 0.0,
#[cfg(not(target_arch = "wasm32"))]
updater_display,
tasks, tasks,
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
+17 -4
View File
@@ -9,7 +9,7 @@ use gpui::{
}; };
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle}; use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem}; use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex}; use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex};
use nip29::Group; use nip29::Group;
@@ -30,9 +30,12 @@ pub(crate) fn open_room(
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) { ) {
let panel = chat_ui::init(room.downgrade(), window, cx);
let room_id = panel.read(cx).room_id().clone();
crate::dock::add_panel_to( crate::dock::add_panel_to(
dock, dock,
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), PanelHandle::new(panel),
DockPlacement::Center, DockPlacement::Center,
window, window,
cx, cx,
@@ -41,8 +44,11 @@ pub(crate) fn open_room(
if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
let peer = room.read(cx).display_member(cx).public_key(); let peer = room.read(cx).display_member(cx).public_key();
let view = screening::init(peer, window, cx); let view = screening::init(peer, window, cx);
let dock = dock.clone();
window.open_alert_dialog(cx, move |alert, _window, _cx| { window.open_alert_dialog(cx, move |alert, _window, _cx| {
let dock = dock.clone();
let room_id = room_id.clone();
alert alert
.confirm() .confirm()
.child(view.clone()) .child(view.clone())
@@ -52,7 +58,11 @@ pub(crate) fn open_room(
.ok_text("Response"), .ok_text("Response"),
) )
.on_cancel(move |_event, window, cx| { .on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx); if let Err(error) = dock.update(cx, |area, cx| {
crate::dock::close_room_panel(area, &room_id, window, cx);
}) {
log::warn!("Failed to close the request room's tab: {error}");
}
true true
}) })
}); });
@@ -318,8 +328,11 @@ impl Sidebar {
) )
}) })
.on_click(move |_event, window, cx| { .on_click(move |_event, window, cx| {
crate::dock::add_panel_to( let group_key = group.read(cx).key().clone();
crate::dock::add_group_panel_to(
&dock, &dock,
group_key,
PanelHandle::new(nip29_ui::init(group.clone(), window, cx)), PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
DockPlacement::Center, DockPlacement::Center,
window, window,
-3
View File
@@ -48,9 +48,6 @@ person = { path = "../crates/person" }
gpui.workspace = true gpui.workspace = true
gpui_platform.workspace = true gpui_platform.workspace = true
gpui-kit.workspace = true gpui-kit.workspace = true
gpui_linux.workspace = true
gpui_windows.workspace = true
gpui_macos.workspace = true
reqwest_client.workspace = true reqwest_client.workspace = true
log.workspace = true log.workspace = true
+996
View File
@@ -0,0 +1,996 @@
# Coop Stability Audit — Pre-Release
Date: 2026-11-12 · Revision audited: `8fa79aef` (master) · Scope: entire workspace (~35k lines, 18 crates + `desktop` + `web`)
> **Remediation status (2026-11-12, later the same day):** all ten P0 items from §8 have been
> implemented and validated (workspace clippy clean, `cargo fmt --check` clean, 35 tests passing).
> See §9 for the per-fix log. The P1 list (committed as `fd802a88` base) has also been processed —
> see §10.
## 1. Scope and Method
Every crate was audited file-by-file against the project conventions (`AGENTS.md`), Rust best
practices (ownership, error handling, async, API design, anti-patterns), and GPUI lifecycle rules
(entity updates, task/subscription ownership, `ElementId` stability, render purity). Severe claims
were verified against the vendored dependency sources (`gpui-pre 0.3.12`, `gpui-base 0.9.1`,
`gpui-component 0.9.1`, rust-nostr 0.45.6, `gpui-updater-pre 0.2.0`), and the highest-severity
findings were independently re-verified by hand.
Tooling results: `cargo clippy --workspace --all-targets` — **clean, zero warnings**.
`cargo fmt --all --check` — **clean**. `cargo check -p workspace` and `-p person` — pass.
## 2. Executive Summary
**Verdict: not ready for stable release yet.** The codebase is well-structured — clean clippy,
disciplined GPUI entity/subscription hygiene in most crates, good hostile-input handling in the
crypto-adjacent code, and no lock-across-await anywhere. But there are **3 Critical** and
**~27 High** findings that must be addressed first, concentrated in three areas:
1. **Money and key safety** (`nip60`, `state`, `browser-signer-proxy`): a one-event wallet-wipe
vector, a startup crash loop, a plaintext client key in web `localStorage`, and an
unauthenticated localhost signing API.
2. **Silent failure paths**: errors from wallet ops, message loads, relay connects, settings
persistence, and backup/keyring reads are swallowed; several failure states strand data or
wedge the UI (stuck `loading_more`, permanently-blacklisted profile fetches, dictation dying
after 30 s).
3. **Wrong results visible to users**: reaction double-counting, multi-line messages flattened to
one line, contact-list updates that erase relay hints and petnames, stale group state from
out-of-order relay delivery, duplicate tabs.
### Must fix before stable (P0)
| # | Severity | Location | Issue |
|---|----------|----------|-------|
| 1 | Critical | `nip60/src/lib.rs:420,552` | Spoofed kind:5 deletion from **any** author wipes wallet proofs (operator precedence bug + missing author check in `absorb_deletion`) |
| 2 | Critical | `state/src/lib.rs:117-122` | LMDB open `block_on` + `.expect` → unrecoverable startup crash loop on any DB error |
| 3 | Critical | `state/src/lib.rs:447-449` | Plaintext NIP-46 client secret key persisted to `localStorage` on web |
| 4 | High | `browser-signer-proxy/src/lib.rs:443-571` | Unauthenticated localhost signing API with `Access-Control-Allow-Origin: *`; plaintext theft, request suppression, response forgery |
| 5 | High | `nip60/src/ops.rs:455-488` | Reconcile marks relay proofs `Unspent` unconditionally → resurrects locally-spent proofs (double-spend risk) |
| 6 | High | `nip60/src/ops.rs:381-417` | Publish failure after store commit strands sent/received proofs; no retry path |
| 7 | High | `state/src/file.rs:418-443` | Decrypted-file cache keyed by filename only → user can be served another attachment's contents |
| 8 | High | `chat_ui/src/lib.rs:605-611` | Own reactions counted twice (backup gift-wrap re-delivery, no dedup) |
| 9 | High | `common/src/media_extractor.rs:52-58` | Whitespace collapse destroys line breaks in every message |
| 10 | High | `workspace/src/panels/contact_list.rs:187-191` | Contact-list Update silently strips NIP-02 relay hints and petnames from the published event |
| 11 | High | `nip29/src/lib.rs:541-555` + `group.rs:696-728` | No `created_at` guards: stale relay data resurrects left groups; group state applied last-write-wins |
| 12 | High | `settings/src/lib.rs:403-404,423-438` | Failed settings load silently resets and overwrites the user's file; saves discard errors and race |
| 13 | High | `speech/src/parakeet.rs:134` | Dictation worker exits after 30 s idle → longer dictations silently lost |
| 14 | High | `browser-signer-proxy/src/lib.rs:339-366` | Timed-out signing requests leak plaintext in memory forever |
| 15 | High | `dock/mod.rs:50-73` + `sidebar/rows.rs:54-57` | Tab dedup is dead code (duplicate tabs); "Ignore" screening action dispatches to a handler that can never receive it |
| 16 | High | `nip60_ui/src/wallet.rs:615-726` + `nip60/src/lib.rs:147-185` | Lightning invoice flow broken end-to-end (quotes never rendered or claimable) |
| 17 | High | `state/src/lib.rs:553-554` | Proxy credential write silently discarded (`save.await.ok()`) |
| 18 | High | `person/src/lib.rs:310-333` | One 2 s metadata timeout permanently blacklists those profiles until restart |
| 19 | High | `chat/src/lib.rs:398-403` | Failed-to-decrypt messages are trashed with no UI surface anywhere |
| 20 | High | `chat_ui/src/lib.rs:325-334`, `workspace/src/dialogs/profile.rs:227-235`, `workspace/src/panels/backup.rs:59-76` | Silent failure cluster: room loads, profile URL saves, keyring reads |
The full finding list follows, crate by crate.
## 3. Systemic Patterns (cross-cutting)
**S1 — Task handles accumulate unboundedly (~20 containers, ~48 declaration sites).**
`tasks: Vec<Task<_>>` / `SmallVec<[Task<_>; N]>` fields are pushed on every operation and never
drained; finished `Task` handles held in a container are not reaped by GPUI. Worst offenders:
`nip60/src/lib.rs:79` (a handle per relay event, whole session), `nip4e/src/lib.rs:90` (runs on
every settings change), `chat_ui/src/lib.rs:110`, `nip29/src/lib.rs:90`, `nip29_ui/src/lib.rs:337`,
`person/src/directory.rs:110`, `state/src/lib.rs:95`, plus every workspace dialog. The codebase
already contains the correct patterns: single `Option<Task<_>>` replacement
(`screening.rs`, `greeter.rs`, `debounced_delay.rs`) and reset-on-teardown (`nip29::reset`).
Fix convention-wide: replace-per-spawn for one-shots, `detach_and_log_err` for true fire-and-forget.
**S2 — Silent error discards (~156 `let _ =` / `.ok()` sites outside tests).**
Project rule forbids `let _ =` on fallible ops; `AGENTS.md` requires async errors to reach the UI.
Beyond the P0s above, the recurring case is `_ = this.update(cx, …)` — mostly benign
(entity dropped at shutdown) but should be `.log_err()` for visibility. Files with the most sites:
`dock/tab_panel.rs` (6), `nip60/src/lib.rs` (10), `nip4e/src/lib.rs` (8).
**S3 — `chat_ui` ↔ `nip29_ui` message-panel duplication.**
`ListState` sync + splice bookkeeping (including divergent `ix + 1` vs `ix + 2` offsets),
`rendered_texts_by_id` cache (both share the stale-mention bug), speech reveal + `RevealText`
fallback timer + `end_events` loop, `Speaker` wiring, reaction grouping, `MessageRow` assembly are
near-verbatim copies — and they have already diverged: `nip29_ui` splices at the prepend position
while `chat_ui` always appends (bug 5.7.4). Extract a shared message-panel controller in `ui`.
**S4 — Dialog error-banner + auto-clear timer duplicated four times** with drifted timings
(`restore.rs` 3 s, `import.rs` 3 s, `relays.rs` 2 s, `contact_list.rs` 2 s). One shared
`error: Entity<Option<SharedString>>` helper removes the drift.
**S5 — `DefaultHasher` used for network-persisted IDs.**
`common::EventExt::uniq_id` (`event.rs:12-18`) is hashed into published `r` tags by
`chat/src/lib.rs:1031-1041`; `DefaultHasher`'s algorithm is not stable across Rust versions, so a
toolchain bump silently changes every published room id. Also hand-rolled in three places
(`nip29/protocol.rs:282-286`, `nip29/utils.rs:27-32`). Replace with a stable hash (SHA-256 of
sorted hex pubkeys) in one shared helper.
**S6 — Index-based element ids.** Rows keyed by list index (`sidebar/rows.rs:174+`,
`contact_list.rs:238`, `greeter.rs:226+`, `nip29_ui` reply previews `message.rs:359`) reassign to
different data after mutations; benign today because handlers capture data, but any future
stateful element (hover/focus) in a row will mis-attach. Prefer domain ids (pubkey hex, event id,
room id) as `group_view.rs` already does.
## 4. Findings by Crate
Severity: **[C]** Critical · **[H]** High · **[M]** Medium · **[L]** Low
### 4.1 `state` — central Nostr registry, signers, persistence, files
- **[C] `lib.rs:117-122` — Startup LMDB open: `block_on` + `.expect` = crash loop.** The
`block_on` itself is acceptable (runs once before the window opens; `NostrLmdb::open` schedules
nothing back onto the GPUI executor — add a comment saying so). The real bug: a corrupted or
unwritable data dir makes **every** launch panic with no dialog and no recovery except manually
deleting data. The web path already does it right (`web_database.rs:30-33` falls back to memory
with a logged error). Fix: mirror that on desktop — memory DB fallback + `StateEvent::Error` so
the UI can tell the user local history is unavailable.
- **[C] `lib.rs:447-449` (+ `credentials.rs:29-38`, `common/web_store.rs:21-25`) — Plaintext
bunker client key in web `localStorage`.** `import_bunker` embeds a fresh `Keys::generate()` into
the bunker URI and stores the credential unencrypted on wasm. XSS/malicious extension/shared
machine = signature capability through the bunker (bunkers commonly auto-approve registered
clients). Desktop stores it in the OS keyring, which is fine. Fix: on wasm, passphrase-encrypt
(NIP-49) like `import_nsec` does, or keep the session memory-only.
- **[H] `lib.rs:553-554` — Proxy credential write silently discarded.**
`cx.background_spawn(async move { save.await.ok() }).detach();` — keyring failure = user silently
dropped to login next launch. Log + emit `StateEvent::error`.
- **[H] `file.rs:418-443` — Decrypted-file cache collides by filename.** Cache key is
`sha256(name)` and the `metadata().is_err()` guard skips the write, so two attachments named
`invoice.pdf` from different chats resolve to the **first** file's path. Fix: include the
attachment sha256/URL in the key, or always overwrite via the atomic-rename path.
- **[M] `lib.rs:373-386` — Web `ncryptsec` stored with `KeySecurity::Medium` (2¹⁶ PBKDF2).**
Encrypted, but offline guessing at 2¹⁶ defeats human passphrases. Raise to `Strong` and/or move
to IndexedDB + session-only keys. Acceptable if documented as a tradeoff.
- **[M] `lib.rs:564-589` — Stale proxy monitor can log out a newer session.** The monitor's guard
is only `current_user.is_some()`; if the user disconnects the proxy and signs in with nsec/bunker
before the next 5 s tick, the monitor swaps the **active** signer for `Keys::generate()`. Fix:
capture the proxy pubkey at spawn and only act if it's still the active signer.
- **[M] `lib.rs:95` — `tasks` vec grows forever** (see S1; `connect_proxy` alone pushes four).
- **[L] `lib.rs:714` `to_bech32().expect("infallible")`; `signer.rs` ×7
`.expect("RwLock poisoned")`.** Practically unreachable, but a poisoned signer lock is the worst
failure mode for a signer — prefer `unwrap_or_else(|e| e.into_inner())`.
- **[L] `lib.rs:93` — `passphrase_required` written, never read** (UI learns via
`StateEvent::PassphraseRequired`). Delete or expose.
- **[L] `file.rs:381-384` — `DownloadedFile::save` (wasm) has no callers.** Remove or wire up.
- **[L] `lib.rs:647-656` — Dead search-relay guard.** Vendored nostr-sdk: `Client::relay()` returns
`Result<Option<_>>`, so `is_ok()` is always true and the "Failed to add search relay" branch is
unreachable. Test `is_ok_and(|r| r.is_some())` or drop the check.
- **[L] `file.rs:221-258` — No size limit on pick/upload** (`MAX_FILE_SIZE` enforced only on
download); a multi-GB pick is read fully into memory. Check size before reading.
- **[L] `file.rs:427-441` — Plaintext decrypted files in `temp_dir()/coop-files`, default
permissions, never purged.** Consider 0600 + best-effort purge on exit. Also `file.rs:150`
builds `file://{path}` by string concat (breaks on `#`/`?`/`%`).
- **[L] `file.rs:291` — Blossom rejection classified by `message.contains("415")`** — match the
HTTP status instead. `file.rs:172` — 16-byte GCM nonce; 12 bytes is the norm.
- **[L] `lib.rs:280-289` — `restore_session` cfg branches compile to empty bodies on wrong
target**, silently emitting no event. `debug_assert!` or fall through to `NoSigner`.
- **[L] `file.rs:446-451` + `web_file.rs:164-174` — Web blob URLs never revoked** when replaced;
every decrypted file pins its bytes for the page lifetime.
- **[L] `lib.rs:683-687` — Search results can contain duplicate pubkeys.** Dedupe.
Positives: device key stored only NIP-44-encrypted to self in the local DB; bunker credentials
verified before persisting; `UniversalSigner` clones the inner `Arc` out of the lock before
awaiting (no locks across `.await`); downloads size-capped and hash-verified; `Keys`' vendored
`Debug` hides secrets.
### 4.2 `nip60` — NIP-60 Cashu wallet engine
- **[C] `lib.rs:420` + `lib.rs:552-572` — Spoofed kind:5 deletion can wipe the wallet.**
`let wallet_event = ours && is_cashu || is_token_deletion(&event);` parses as
`(ours && is_cashu) || is_token_deletion(&event)` — deletion events from **any** author are
forwarded, and `absorb_deletion` never checks `event.pubkey == me`. Kind 7375 event ids are
public on relays; one attacker event removes our tokens, marks them stale, and the Reconcile
deletes the real proofs from the store (restore-only recovery). Fix:
`wallet_event = ours && (is_cashu || is_token_deletion(&event))` + author check inside
`absorb_deletion` for defense in depth.
- **[H] `ops.rs:455-488` (with `proof_info` `ops.rs:601-617`) — Reconcile resurrects spent
proofs.** Every relay-token proof is added to the store hardcoded `State::Unspent`, ignoring the
proof's existing store state. After any failed-publish op, reconcile re-adds proofs this device
itself marked spent/reserved (incl. offline sends the recipient also holds) → bogus balance,
real double-spend race. Fix: skip `y`s already in the store with non-Unspent state; verify
unknown proofs against the mint (NUT-08) before adding.
- **[H] `ops.rs:381-417` + `lib.rs:836-843` — Publish failure after store commit strands funds.**
If `send_event` fails, the confirmed `TokenUpdate` is dropped: for `SendToken` the token string
is lost (proofs spent locally, recipient never gets it); for `Receive` proofs sit unpublished and
a retry errors as duplicate. Reconcile only republishes when `mint_unissued_quotes() != 0`
(`ops.rs:429-432`), so it doesn't heal this. Fix: retain the confirmed update on error; force
republish whenever the store's unspent set differs from relay state.
- **[H] `ops.rs:420-453` — Quote-minting publish can `del` events whose proofs aren't converged
yet.** `previous_ids` is the schedule-time relay snapshot; if another device's token was absorbed
but not yet converged, the published event `del`s the id while omitting its proofs — those proofs
get pruned everywhere while remaining spendable at the mint. Fix: converge before publish, or
only `del` when the published proof set fully covers the id.
- **[H] `lib.rs:616,651` + `lib.rs:902-912` — Failed nutzap redemption stuck until restart.**
`redeeming.insert` happens before scheduling; the `Err` branch never removes the id and
`absorb_nutzap` skips anything in `redeeming`, and nothing requeues to `pending_nutzaps`. Fix:
remove + requeue with bounded retries on error.
- **[H] `lib.rs:79` — `tasks` grows by a handle per relay event** (see S1).
- **[H] `lib.rs:147-149,172-185` — `mint_quotes()`/`claim_mint_quote()` dead → created invoices
are never displayable or claimable.** `Outcome::QuoteCreated` pushes into `self.quotes` and
nothing ever reads it (workspace-wide grep). Quotes also accumulate forever. Fix with the invoice finding in 4.3 below.
- **[M] `lib.rs:188-215` — `melt()` and `send_token()` unreachable; `WalletEvent::TokenCreated`
emitted only from the dead path** while `workspace/src/lib.rs:226-236` promises "copy it from
the wallet panel". Wire into UI or remove; fix the notification text regardless.
- **[M] `lib.rs:285-370` — Concurrent `setup()` can fork the wallet key** (only guarded by
`loading`); absorption tie-breaks with `created_at >=` on second-granularity timestamps, and
stores are keyed by wallet pubkey — the losing key orphans its funds. Fix: `setup_in_progress`
guard; deterministic tie-break (event id).
- **[M] `lib.rs:430-432` — `processed_events.clear()` at 1000 entries** re-delivers a burst;
evict oldest instead.
- **[M] `lib.rs:615-661` — Nutzap `unit` tag never validated**; a `usd` nutzap passes local
validation. Require `unit == "sat"` in `Nutzap::from_event`.
- **[L] `lib.rs:301-303,699-701` — Silent no-ops**: `setup()` when mint already listed;
`ensure_wallets` on unparseable mint URLs forever. Surface feedback.
- **[L] `ops.rs:107` — ClaimQuote history records `quote.amount.unwrap_or_default()`** (0-sat
inflow when mint omits amount); use the summed proof amount.
- **[L] `Cargo.toml:23` — `tokio` dep unused** (only `gpui_tokio::Tokio` is referenced). Remove.
Positives: store-before-publish enforced via cdk prepare/confirm sagas; all relay-derived content
author-checked before decryption; `keys.rs` secret handling clean (no Debug on `WalletData`, no
secrets in logs, HKDF salt pinned by test); single-flight job queue; `WalletEvent::Error` reaches
notifications even with the panel closed.
### 4.3 `nip60_ui` — wallet panel + dialogs
- **[H] `wallet.rs:615-726` — Invoice button creates an invoice the user can never see or pay.**
After `create_mint_quote` the bolt11 string lands in `WalletRegistry::quotes`, which nothing
renders. Receive-via-lightning is broken end-to-end, silently. Fix: render pending quotes
(request + copy + check/claim) in the dialog/panel, refreshed on `WalletEvent::Updated`.
- **[M] `wallet.rs:765` — `render()` clones the whole history `Vec` every frame** (history is
unbounded). Snapshot into `Rc` only on `WalletEvent::Updated`. Same for `filtered_contacts`
(`wallet.rs:542`) — cache on input change.
- **[L] `wallet.rs:206,284,356` — `SendDialog::tasks` accumulates** (see S1).
- **[L] `wallet.rs:262-264` — `load_contacts` gives up silently pre-login**; retry on
`StateEvent::SignerChanged` (sidebar already does this).
- **[L] `wallet.rs:728-733` vs `lib.rs:28-34` — amount parsing duplicated.** Share `parse_amount`.
Positives: `cx.notify()` on all mutating paths; per-key hex ElementIds; stored subscriptions;
history list windowed via `uniform_list`.
### 4.4 `browser-signer-proxy` — NIP-46 localhost bridge
- **[H] `lib.rs:443-571` — Unauthenticated API + `ACAO: *`.** Every response sends
`Access-Control-Allow-Origin: *` (verified at :556, :588, :607, :631); no `Host`/`Origin` checks.
Consequences: (1) `GET /api/pending` returns full `sign_event`/`nip44_encrypt` params (message
plaintext) readable by any web page in Firefox-family browsers and by DNS rebinding anywhere;
(2) `:516-517` clears the queue on read → third party can steal requests so the real extension
page never sees them; (3) `POST /api/response` accepts any `{id, result}` → forged
`get_public_key` substitutes an attacker pubkey as "the user". Fix: drop CORS entirely (the
served page is same-origin), reject non-`127.0.0.1:7400` Host, gate `/api/*` on a per-session
random token embedded in the served page.
- **[H] `lib.rs:339-366` — Timed-out requests leak forever.** The `RequestData` and oneshot sender
stay in the maps after the 30 s timeout — plaintext retained for the session, re-served to
whoever polls next, map grows unboundedly. Remove entries on timeout (self-removing guard or
id-returning `request`).
- **[M] `lib.rs:374-380` — `_sign_event` verifies the signature but not the event.** Nothing checks
the returned event matches the requested `UnsignedEvent` (pubkey/content/tags/kind/created_at);
a compromised signer page can substitute content. Compare before accepting.
- **[M] `lib.rs:538` — Decrypted plaintext debug-logged.** `tracing::debug!("Received response …
{message:?}")` leaks `nip44_decrypt` output and event content the moment anyone runs with
`RUST_LOG=debug`. Log id + method only.
- **[M] `lib.rs:447,465-480,523` — Unbounded reads/allocations**: header line grows unbounded;
`vec![0u8; content_length]` trusts `Content-Length` (4 GiB reservation from one POST); no
connection timeout. Cap header block (16 KiB), cap body (1 MiB), add idle timeout.
- **[L] `lib.rs:650-678` — `race_timeout` reports channel-closed as timeout.** Distinguish.
- **[L] `lib.rs:574-638` — Three near-identical response writers**; one function with a header
options struct (also makes the CORS removal enforceable in one place).
- **[L] `lib.rs:546,563,593,612,636` — bare `let _ =` on writes**; centralize the
best-effort-write rationale in the helper.
Positives: loopback-only bind, graceful shutdown tied to handle lifetime, per-request timeouts,
UUID ids, bounded channels.
### 4.5 `nip4e` — per-device keys
- **[M] `lib.rs:1036,774-860` — Approval button wedged + double-clickable.** `loading.set(true)`
without `cx.notify()` keeps the old button enabled (double-click sends the secret in two 4455
events); on failure `loading` is never reset and the notification isn't removed. Notify after
setting, reset on error, rebuild the notification on both outcomes.
- **[M] `lib.rs:403-405` — Rotation skips archiving silently, then overwrites the key.** If the DB
read/decrypt fails transiently, `archive_key` is skipped but `set_keys` still overwrites → old
chats permanently undecryptable. Make archive failure abort rotation (or log loudly).
- **[M] `lib.rs:175-206` — Relay listener can die silently.** `tx.send_async(...).await?` aborts on
full bounded(100) channel or dead consumer; the task's `Err` is never inspected. All 4e event
processing stops until the next signer change. Log and handle backpressure.
- **[M] `lib.rs:90` — `tasks` grows on every settings change** (`get_announcement` runs per
settings mutation) — see S1.
- **[M] `lib.rs:177,184` — `processed_events` inserts **every** relay event** (insert precedes the
kind filter) and grows unbounded. Filter first; bound the set.
- **[L] `lib.rs:303-309` — Desktop key backup file written 0644** (subject to umask); use 0600.
- **[L] `lib.rs:178` — `current_user` snapshotted per listener**; events between signer change and
`get_public_key_async()` resolving are dropped, and a stale snapshot can briefly forward one
account's events to the next handler. Re-check per event.
- **[L] `storage.rs:29-32` — Malformed stored `created_at` silently resets the rotation
interval.** Log the fallback.
- **[L] Zeroization gaps** (`app_keys`, decrypted secrets in `decrypt_approval`/`approve`): Low —
the nostr stack itself doesn't zeroize; note as defense-in-depth.
- **[L] `lib.rs:141,246,426,458,845,854,894,1041` — `.ok()` discards** (see S2); `lib.rs:479`
single-letter `n`.
- **[L] `lib.rs:136,241,888,914` — `cx.windows().first()` targets an arbitrary window** (matters
only if multi-window ever lands).
Positives: secret never published/logged; transfer encrypted to a fresh one-time keypair;
approvals re-validated against the live announcement; stale requests dropped by age; bounded
archive; account switch wipes pending state.
### 4.6 `chat` — chat registry + rooms
- **[H] `lib.rs:331-338` + `message.rs:203-220` — Failed-to-decrypt messages silently trashed;
nothing renders `trash`.** The `trash: Entity<BTreeSet<FailedMessage>>` is written, cleared, and
reprocessed, but has **no reader or renderer workspace-wide**. A user whose incoming message
can't be decrypted gets zero feedback — core E2EE UX gap. Emit a `ChatEvent` or render an
indicator.
- **[M] `lib.rs:284,327,620-635,726,733,854,892` — `RwLock` `.read().unwrap()`/`.write().unwrap()`
poisoning crash loop.** `search.write()` runs on a background thread; one panic while holding it
turns every later foreground read into a panic. These maps are reconstructible heuristics — use
the tolerant `poisoned.into_inner()` pattern the codebase already has
(`person/src/directory.rs:226-233`).
- **[M] `lib.rs:779-780` — Off-by-one index stored in `room_map`** (`rooms.len()` after push).
Currently unreachable (batch rooms come from an id-keyed set) but any future reader panics at
`self.rooms[index]`. Store `len() - 1`, or drop the never-usefully-read index bookkeeping.
- **[M] `lib.rs:440-443,454-461,825-833` — Discarded subscribe result + `unwrap_or_default()`.**
`_ = client.subscribe(...)` (rule violation); a DB error becomes "not found" → false
"Messaging Relays not found" retry prompt, and a contacts DB error classifies peer rooms as
`Request` instead of `Ongoing` (wrong trust classification). Distinguish errors from empty.
- **[M] `lib.rs:110,199,232-358,362-414` — Stored task results never inspected** (see S1/S2); a
dead notification listener silently stops message delivery — at least `detach_and_log_err`.
- **[L] `room.rs:637-646` — `deliver<S>` generic has one implementation**; inline it.
- **[L] `lib.rs:867` — `max_by_key().unwrap()`** relies on an invariant three lines up;
`let Some(...) else { continue }` documents it.
- **[L] `lib.rs:332-338` — Empty-tags rumor is both trashed and delivered** (appears as failure
entry *and* rendered message until reprocess). Pick one path.
- **[L] `room.rs:350` — `self.members[0]` safe only via a cross-crate invariant**
(`extract_public_keys` always appends the author). Add `debug_assert!` or a comment.
- **[L] `Cargo.toml:28-29` — `smol` unused.** Remove.
- **[L] `lib.rs:182-186` — `let _ = entity.update(...)`** in deferred init; `.log_err()`.
Positives: signal batching bounds per-update work; wasm yield loop; gift-wrap dedup; seal
verification; `WeakEntity` room handles; `ChatEvent::Error`/`Loaded`/`Ping` genuinely surfaced.
### 4.7 `chat_ui` — chat panel
- **[H] `lib.rs:605-611` — Your own reactions count twice.** `insert_reaction` pushes
unconditionally (keyed only by target id); with `backup: true` (default) the reaction comes back
as your own backup gift wrap → inserted again → "👍 2". Text messages are dedup'd by
`binary_search`; reactions aren't. The dormant `RoomEvent::Reload` path double-counts too.
Track seen reaction event ids.
- **[H] `lib.rs:325-334` (+ `272-277`) — Room message-load and connect failures swallowed.** The
stored task's `Result` is never inspected: user sees an empty room with no error; relay
subscription failures invisible. Push a notification on error.
- **[M] `lib.rs:557-581` — Bulk load is O(n²) on the foreground thread.** Per-event
binary-search + index rebuild + splice inside one `update`; multi-second stall on big rooms.
Add a bulk variant (sort once, rebuild index once, splice once).
- **[M] `lib.rs:569-570` — Insert position and `ListState::splice` disagree.** Messages are
binary-inserted by `created_at` but the list always appends at `len..len`; mid-`Vec` arrivals
shift rows while the list state learns "appended one" → stale height caches, scroll jumps.
`nip29_ui/src/lib.rs:516` does it right: `splice(pos + 1..pos + 1, 1)`.
- **[M] `lib.rs:1183-1198` — `rendered_texts_by_id` caches mention names forever** (same defect as
`nip29_ui` 4.8). A message rendered before the profile loaded shows `@npub1…` all session.
Key the cache on directory generation or drop the resolved-text cache.
- **[M] `lib.rs:50-51,395-398` — `\p{Emoji}` matches plain digits: typing "2" while replying to
exactly one message sends a "2" reaction instead of a message.** Require
`[\p{Emoji_Presentation}\p{Extended_Pictographic}\u{200D}\u{FE0F}\u{20E3}]`.
- **[M] `lib.rs:110` — `tasks: Vec<Task>` grows without bound** (see S1).
- **[M] `lib.rs:547-549,618,623` — `reports_by_id: Arc<RwLock<...>>` nothing else shares.** Plain
`BTreeMap` field; removes the poisoning risk for zero benefit.
- **[L] `lib.rs:499` — `rumor.id.expect("rumor must have an id")`** — invariant holds today, but
degrade + notify instead of panic per house style (also `to_bech32().unwrap()` at 652, 1072).
- **[L] `lib.rs:362-370,797-802` — `.ok()` discards `room.update`** (subject change, retry action).
- **[L] `lib.rs:722-727` — `encrypted` snapshotted before the file-picker await**; typing while the
picker is open classifies the file by stale input state. Re-read after the await.
- **[L] `lib.rs:1170-1178` — `load_file` invoked from the render path** (guarded, but render should
be pure — move to a subscription).
- **[L] `lib.rs:147-221` — Summary comments** ("Define input state", …) violate the comment rule.
### 4.8 `nip29` — NIP-29 registry + protocol
- **[H] `lib.rs:541-555` (merge), `582-607` (absorb_list) — Stale group-list events resurrect left
groups and get republished.** The kind 10004 list is absorbed without comparing `created_at` and
`merge` never removes entries absent from older snapshots; a relay still holding the pre-leave
version re-adds the left group, `ensure()` re-creates subscription, and the next
`refresh_entry`/`publish_list` republishes the left group to all write relays (privacy angle).
Track the last accepted list event's `created_at`; ignore older.
- **[H] `group.rs:696-728` (absorb_state) — Group state applied last-write-wins, no ordering
guard.** Out-of-order relay delivery (newest-first is common) leaves the *oldest*
metadata/members/admins/roles/pins displayed indefinitely. Cache hydration is safe (sorted
ascending at `group.rs:507`); the live path is not. Keep `created_at` per state kind (the
`forks` map at `lib.rs:669-675` shows the pattern).
- **[M] `lib.rs:173-174,189` — Join published optimistically; refusal classified by relay error
prose** (`reason.starts_with("duplicate")`); a refused join stays in the public list. Drop the
entry on refusal; detect duplicates via a stable marker.
- **[M] `lib.rs:173,825-834` — Rejoin flips a private list entry to public** (`join_key` hardcodes
`private: false`, `upsert` replaces the whole entry). Preserve the existing flag.
- **[M] `group.rs:418-448` + `lib.rs:383-397,1146-1159` — Failed page load leaves `loading_more`
stuck true forever** (subscribe error path emits no signal; guard then permanently blocks
pagination for the session). Route a synthetic settle on error.
- **[M] `lib.rs:308-330` + `discovery.rs:253,267,274,281` — Network failures silently produce
"empty" results** (`and_connect().await.ok()`, discarded sync errors): browse shows zero
candidates, discovery silently stale. Surface `GroupsEvent::Error`.
- **[M] `discovery.rs:240-252,264-267` — Empty contact list → `Filter::authors(∅)` serializes as
absent → the sync reconciles **every user's** list, and the pool-wide sweep (kinds
`[GroupMetadata, GroupMembers]`, no ids/limit) pulls all NIP-29 state from all relays.**
Early-return when `authors.is_empty()`; constrain the sweep.
- **[M] `group.rs:457-511` + `lib.rs:906-941,1042-1071` — Every live event triggers a full
unlimited `load_envelopes` reload** (re-query + JSON-decode + sort of entire group history per
message) and every event is cached forever. Use the single-event absorb path live; cap/prune the
cache.
- **[M] `lib.rs:90` — `tasks: SmallVec<[Task; 2]>` accumulates per incoming event** (see S1).
- **[L] `protocol.rs:389-399,534,650,964` — One malformed optional tag fails the whole parse**
(one bad `supported_kinds` value blanks a group's metadata display). Skip + log instead.
- **[L] `protocol.rs:24` — Custom kind 9010 not in rust-nostr 0.45.6's NIP-29 range**; confirm
against target relay implementations before stable.
- **[L] `group.rs:602-611` — `previous` mismatch only logs a warning**; an empty `previous` tag
counts as fully verified (`protocol.rs:1046-1056`). Align comment with behavior or escalate.
- **[L] `protocol.rs:995-1016` — `TimelineWindow.seen` grows unbounded** (u32/event, never pruned).
- **[L] `lib.rs:582-607,661-677` — No client-side author re-check on list/fork subscriptions.**
Mitigated by `verify_subscriptions(true)` + `ban_relay_on_mismatch(true)`; note the residual
NIP-29 trust model rests on relay admission policy.
- **[L] `lib.rs:860-904` — Concurrent `publish_list` can complete out of order** (each snapshots at
call time; older snapshot can win). Serialize publishes.
- **[L] `lib.rs:1225-1230` — Negentropy/foreign-EOSE cross-talk** fires spurious `Synced` signals
from unrelated subscriptions.
- **[L] `utils.rs:24,31,35` — Subscription ids truncate the digest to 32 bits**; collision would
cross-wire routes. Negligible at this cardinality; use the full digest anyway.
- **[L] `lib.rs:1198-1200` — `processed` dedup wholesale-cleared at 10k**; evict oldest instead.
- **[L] `lib.rs:723-736` — `previews` grow unbounded per session** (entity + subscription each).
- **[L] `group.rs:197-202` — `Group::message(id)` O(n) scan** called per reply preview in render
paths → O(n²) timelines. Build an id→row map.
- **[L] `Cargo.toml:14` — `person` dep unused.** Remove.
- **[L] `lib.rs:1208-1229` — `.ok()` on signal sends** (benign; log or comment per rule).
- **[L] `lib.rs:358,949,975` — Inline global-handle chains** (rule: bind to a local).
Positives: zero reachable panics; tag parsing consistently `get`/`skip`/`first`;
`saturating_sub` timestamp math; tasks/subscriptions stored; `WeakEntity` spawns; membership
derivation order-independent; strong publish-error propagation.
### 4.9 `nip29_ui` — group panel + dialogs
- **[H] `lib.rs:1053-1067` — `rendered_texts_by_id` bakes mention names in permanently.**
Computed once per `EventId`; when the profile arrives later the body keeps `@npub1ab…` (the
header recovers via re-render, the body doesn't). Key on directory generation or don't cache the
resolved text (only the markdown source needs caching).
- **[M] `lib.rs:337` — `tasks` accumulates** from every send/upload/react/delete/pin (see S1).
- **[M] `lib.rs:1068` — Content `TextView` keyed by row index.** Prepending shifts every index →
every visible `TextViewState` re-parses markdown in one frame ("Load earlier messages" jank).
Use the message id as element id.
- **[M] `message.rs:359` + `lib.rs:1068` — Sibling ElementId collision: reply previews use bare
reply index as sibling of the row-index TextView**; when `replies.count > row_index` both sides
share `…/[N]/[N]`. Latent today, breaks silently when element types coincide. Namespaced ids.
- **[L] `message.rs:192,227,238` — `let _ = panel.update_in(...)`** where sibling call sites log
(`log::warn!` at 110-160). Mirror the logged pattern.
- **[L] `lib.rs:630` — File-picker failure silently swallowed** (task result never inspected).
- **[L] `lib.rs:436-445` — Utterance-end loop clears the reveal unconditionally**; interrupted
utterances still fire `on_utterance_end` on many backends, cancelling B's reveal while B's audio
plays. Guard with the session id like `message.rs:144` does.
- **[L] `message.rs:97,130,168-180` — Per-frame per-row allocations** (`preview()` computed twice
per row per frame, custom-emoji `Vec` rebuilt + cloned into the submenu closure every render —
and gpui-pre redraws the window on every keystroke). Compute once per render.
- **[L] `lib.rs:677,956` — Inline global chains** (rule).
- **[L] `lib.rs:407-419` — Subscribe + observe both run `sync` + `track_participants`** (events
and notifies fire together) → double O(rows) scan per change.
- **[L] `message.rs:322` — Empty role list renders "is now "** (trailing space, no roles). Emit
"now a regular member" for the empty case.
### 4.10 `common` — shared utilities
- **[H] `media_extractor.rs:52-58` — Every message body has all whitespace collapsed, destroying
line breaks.** `cleanup_text`'s `\s+ → " "` runs on the entire remaining text unconditionally,
so multi-line messages render as one line in both chat and groups (via `chat/message.rs:99-103`).
Normalize only around removed URLs; never touch `\n` in untouched regions.
- **[M] `media_extractor.rs:15-18,56,62-65` — Regexes compiled per message** (the cache struct's
own entry point defeats it). `static LazyLock<Regex>`.
- **[M] `parser.rs:240,254,260` — `NostrParserIter::next` recurses per token; depth is
attacker-controlled** (~9 bytes/token of crafted `nostr:` URIs). Desktop's 8 MB stack survives;
the wasm32 web build can crash the tab. Rewrite as a loop.
- **[M] `event.rs:12-18` — `uniq_id` uses `DefaultHasher` and is persisted to published events**
(see S5).
- **[M] `paths.rs:7,14,23,31` — `expect()` on `dirs::download_dir()` etc.** — legitimately `None`
on Linux without XDG user-dirs → panic on export (`workspace/src/lib.rs:405`). Return `Option`
and handle.
- **[L] `display.rs:18,20,39` — `as i64` on relay-controlled timestamps** (contained by
`LocalResult`); fallback magic strings `"9999"`/`"1m"`.
- **[L] `event.rs:12-53` — `Event`/`UnsignedEvent` impls duplicate the same logic with drift**
(one sorts, one doesn't). Factor into one helper — same helper fixes S5.
- **[L] `debounced_delay.rs:33` — `_ = channel.send(())`** explicit silent discard (harmless;
satisfy the rule with a comment + `if let Err`).
Positives: `web_store.rs` error handling exemplary; parser char-boundary-safe throughout;
`DebouncedDelay` cancellation correct; time formatting degrades gracefully.
### 4.11 `person` — profiles + web of trust
- **[H] `lib.rs:310-333` + `204-245` — One failed metadata batch permanently blacklists those
pubkeys.** `in_flight` removal happens only on metadata arrival; `get_metadata` uses
`TIMEOUT = 2 s` (tight for bootstrap relays + EOSE) and on failure keys stay in `in_flight`
forever → those people show truncated-pubkey placeholders until restart. Return failed keys on
error and release them; raise the request timeout independently of the batching window.
- **[M] `lib.rs:175` — Malformed metadata wipes a good profile.**
`Metadata::from_json(...).unwrap_or_default()` replaces previously-loaded data with a default
`Person`; content is attacker-influenceable (anyone can publish metadata for any key). Skip on
parse failure.
- **[M] `directory.rs:110` (+ `lib.rs:255-262`) — Task handles accumulate per `track`** (see S1).
- **[M] `directory.rs:161-172` — `person()` performs side effects (lock write + network fetch) from
a render-time getter.** Deduped via `pending`, but order-dependent render behavior; consider an
explicit `ensure_tracked` from update paths.
- **[L] `wot.rs:100,343` — DB errors silently become empty results**; add `log::warn`.
- **[L] `wot.rs:150-153` — `let _ = this.update(...)`** (commented, periodic; noted for the rule).
- **[L] `person.rs:23-47` — `PartialEq/Eq/PartialOrd/Ord/Hash` for `Person` unused
workspace-wide**; the `Ord` impl clones `Metadata` twice per comparison. Delete.
- **[L] `person.rs:66-68,124-138` — `metadata()` clones per call; `name()` calls it twice** per
invocation → 2+ clones per avatar/name row per frame. Borrow or cache.
- **[L] `person.rs:166-177` — `shorten_pubkey` doc example doesn't match output; panics if
`len ≥ 63`** (callers use 4/8 today). Note: the `let Ok(...) =` pattern compiles only because
`ToBech32::Err = Infallible` — worth a comment since it looks like a compile error.
Positives: WoT builder caps hostile publishers; poisoning-tolerant lock helpers; bounded dispatch
channel; `limit(1)` newest-event lookups verified correct against nostr-lmdb ordering.
### 4.12 `speech` — dictation + TTS
- **[H] `parakeet.rs:134` — Inference worker exits after 30 s idle, permanently disabling
dictation.** `recv_timeout(30s)` kills the thread; the `OnceLock` sender survives, so after
that every `send` fails — and `send_or_log` (`:200-204`) logs at **debug** and drops. A
`MAX_SECONDS = 120` dictation records fine, then `finish()`'s `Transcribe` goes into the void;
gpui-kit's 180 s `stop_timeout` then force-ends with an **empty** transcript. Net: dictations
longer than 30 s silently lost after up to a 3-minute hang. Fix: block on `recv()` (an `Unload`
command already exists for explicit retirement), and make `finish()` surface send failure via
`sink.error`.
- **[M] `parakeet.rs:63-68` — Audio past 120 s silently discarded** mid-sentence with no
indication. Surface once or stop capture at the cap.
- **[M] `parakeet.rs:119-128` — Worker-spawn failure leaves a permanently dead channel** (receiver
dropped, sender cached in the `OnceLock` forever). Don't cache on failure.
- **[M] `speaker.rs:36-53` — `Tts::default()` constructed lazily inside `speak()` invoked from
render paths**; first-use engine init can take tens-to-hundreds of ms on the UI thread
mid-render. Initialize eagerly at panel creation or on the background executor.
- **[L] `model.rs:82-97` — `status()` does filesystem I/O per call** while the settings dialog
polls every 500 ms on the foreground. Cache status in the state mutex.
- **[L] `model.rs:183` — `let _unused = fs::remove_file(...)`**; log per rule.
- **[L] `web.rs:23-25` — `is_available` hardcodes `true`**; mic button renders enabled in browsers
without `SpeechRecognition`. Reflect actual support.
- **[L] `web.rs:200,218` — `let _ignored` abort/stop** — commented, best-effort; fine.
Positives: `catch_unwind` isolation around model load/transcribe; staged download with SHA-256 +
size cap + atomic rename; dedicated threads for inference/download; all other fallible paths
reach `sink.error`.
### 4.13 `settings` — persistence + themes
- **[H] `lib.rs:403-404` + `311-314` — Failed load silently resets settings and destroys the saved
file.** `unwrap_or(Settings::default())` swallows I/O and JSON errors; the observer then
immediately writes defaults over the user's file (losing `trusted_relays`, pins, wallet prefs)
with no log. Log, keep defaults in memory, but don't persist until a real user change.
- **[H] `lib.rs:423-438` — Saves discard errors and race each other.** `.await.ok()` hides
disk-full/permission failures; every change detaches a new concurrent write to the same path —
last-completion-wins can overwrite newer snapshots and interleaved truncate/write can corrupt
the file (font-size input ticks each trigger a save). Single writer slot + temp-file + rename +
`log_err`.
- **[L] `lib.rs:425` — Serialization failure silently skips saving**; log.
- **[L] `lib.rs:596-639,671-683` — `apply_persisted_theme` re-reads and re-parses the settings
file four times.** Parse once.
- **[L] `lib.rs:211,408` — `Url::parse(DEFAULT_FILE_SERVER).unwrap()`**; `OnceLock` or a reasoned
`expect`.
Positives: accessor macros keep 20 settings consistent; serde default/alias migration fields with
tests covering old formats; wasm path handled.
### 4.14 `workspace` — core + dock + sidebar
- **[H] `dock/mod.rs:50-73` + `lib.rs:309-320` — Tab dedup is unreachable; repeated opens stack
duplicate tabs.** `find_panel` matches on `PanelId`, which is always `PanelId::from(entity_id())`
of a panel created in the same statement — so it can never match (verified against gpui-base
0.9.1 `add_panel_inner`, which inserts without dedup). Clicking the same chat row twice opens
two live panels for one room; ShowInbox/Browse/Contacts/Backup multiply tabs. Key panels by
domain identity (singleton entities per panel name, or room/group key). The ~55 lines of
`find_panel`/`move_panel` are dead until then.
- **[H] `sidebar/rows.rs:54-57` — "Ignore" dispatches `ClosePanel` where it can never be handled.**
The only handler is on the tab-group frame; the alert dialog holds focus at that moment, and
dispatch resolves along the focus node's ancestor path — the dock tree isn't on it. Clicking
"Ignore" is a silent no-op for a destructive-intent action. Close the room via the dock handle
already in scope, or restore focus first.
- **[H] `sidebar/header.rs:38,236-268` — Updater buttons render stale status.** Nothing observes
the `AutoUpdater` entity (gpui-pre has no read-tracking); a staged update can sit invisible on
an idle window indefinitely. Add `cx.observe(&updater, …)`.
- **[M] `sidebar/mod.rs:192-206` + `search.rs:70-117` — Full message-index scan on the foreground
thread for every keystroke.** Only the *global* search is debounced; local search synchronously
scans every indexed message, sorts the entire match set, then truncates. Debounce + background
spawn + partial top-`limit` select.
- **[M] `sidebar/list.rs:41,178-248` + `group_view.rs:88-136` — Row model rebuilt on every sidebar
render** (clone + partition + pin-tag scans ×visible rows; sidebar re-renders on 500 ms ticks,
keystrokes, group updates). Cache the row model in state, refresh on registry events.
- **[M] `sidebar/search.rs:152-155` — Global search failure renders as "No profiles found".**
Surface the error state.
- **[M] `lib.rs:410-421` — Save-dialog failure silently cancels the backup** (`Err(_) => return`):
indistinguishable from user cancel; user believes the key was backed up. Split arms + notify.
- **[M] `sidebar/mod.rs:139-142` — Whole-sidebar re-render on every `GroupsEvent::Updated`**
(acknowledged TODO; compounds the row-rebuild cost). Gate on visibility.
- **[L] `dock/skin.rs:25,35-37,55` — `toggle_button_visible: Cell<bool>` is write-once** (set true
in ctor, never elsewhere) → dead state + dead branch at `tab_panel.rs:212`. Delete.
- **[L] `sidebar/rows.rs:174-374` — Index-based row ids** (see S6).
- **[L] `dock/tab_panel.rs` ×6, `skin.rs:262`, `sidebar/search.rs:164,178`, `lib.rs:641` — `let _ =
`/`.ok()` on fallible updates** (see S2); `lib.rs:641` is pre-destructive-reset — make it visible.
- **[L] `rows.rs:220-222,346-351`, `list.rs:53-55` — Inline global chains** (rule).
- **[L] `lib.rs:84`, `sidebar/mod.rs:99`, `sidebar/inbox.rs:68,118` — `tasks` accumulate** (see S1).
- **[L] `lib.rs:54` — Typo: `DeviceNotifcation`.**
Positives: careful `WeakEntity` hygiene; virtualized lists via `uniform_list` + `Rc` snapshots;
`InvalidPanel` preserves unknown panels across load/save; `theme_name_at` maps the palette's
original `IndexPath` correctly; `cargo check -p workspace` passes.
### 4.15 `workspace` — dialogs + panels
- **[H] `panels/contact_list.rs:187-191` — "Update" republishes the contact list with relay hints
and petnames stripped.** `load()` keeps only `tags.public_keys()`; `update()` rebuilds every
entry as `Contact::new(*public_key)` (`relay_url: None, alias: None`) while the UI reports
success. Silent NIP-02 data loss on relays. Carry the original `Contact`s through.
- **[H] `dialogs/new_chat.rs:60-64` — Contact list doesn't re-filter while typing.** No
`InputEvent::Change` handling; the input renders its own state entity so typing doesn't notify
`NewChat`; results stay stale until an unrelated click/Enter. `cx.notify()` on Change. Same
pattern, cosmetic: `profile.rs:320-326`.
- **[H] `panels/backup.rs:59-76` — Keyring read failures silently swallowed**; backup panel just
shows empty inputs. Log + surface.
- **[H] `dialogs/profile.rs:227-235` — Invalid avatar/website URLs silently dropped on save** while
"Profile updated successfully" shows. Validate both up front; inline error.
- **[M] `dialogs/screening.rs:52-55` — `on_release_in` calls `window.close_all_dialogs(cx)`**,
nuking unrelated stacked dialogs (including the mutual-contacts dialog this view opens). Close
just the hosting dialog / cancel tasks only.
- **[M] `dialogs/screening.rs:126-168` — DB-fallback "mutual contacts" has different semantics than
the WoT list** (followers vs. your-contacts-who-follow). Intersect or relabel.
- **[M] `dialogs/screening.rs:211-232` — NIP-05 network errors render as "address does not
match"** (`unwrap_or(false)` asserts infrastructure failure as fact about the person). Tri-state.
- **[M] `dialogs/screening.rs:250-282` — Report submit gives no failure feedback.**
- **[M] `dialogs/import.rs:129-185` — Enter bypasses the loading guard** (no in-method guard; all
three `PressEnter` subscriptions call `login()` directly) → concurrent double import. Same class:
`contact_list.rs:176-228`, `relays.rs:377,521`.
- **[M] `dialogs/import.rs:31-40` — Unlock dialog is inescapable** (no cancel/logout path for a
forgotten passphrase). Add a "start over" escape hatch for stable.
- **[M] `panels/browse.rs:484-493,316-348` — Card list recomputed per render *and* per visible
row** (O(n) clone + registry reads ×(rows+1) per frame during scroll). Compute once per render
(`new_chat.rs`'s `Rc<Vec>` pattern).
- **[M] `panels/greeter.rs:127-137` — Every refresh fetches the full message history of every room
to take `.last()`.** Query latest-per-room instead.
- **[M] `dialogs/new_chat.rs:138-160,388` — Filter allocates per contact per keystroke-scale
render** (`to_hex().to_lowercase()` per contact). Cache lowercase names / filter once per change.
- **[M] `dialogs/settings.rs:124-148` — Speech-model errors can be consumed before the status
change check** and dropped. Check `error` before the equality early-return.
- **[M] `new_chat.rs:282-315` vs `profile.rs:274-313` — Duplicated room-open logic with divergent
dedup.** Unify on find-or-create.
- **[L] `panels/contact_list.rs:238,259` + `greeter.rs:226-402` — HashSet/index-based element ids**
(see S6).
- **[L] `panels/contact_list.rs:288` — Copy bug: contacts panel says "Please add some relays."**
- **[L] `dialogs/new_group.rs:392` — `let _ = this.update(...)`** (see S2; `relays.rs:623-629` is
the correct logged pattern).
- **[L] `panels/backup.rs:66,70` — `.unwrap()` on bech32** (infallible today; still banned pattern).
- **[L] `dialogs/restore.rs:52-62`, `contact_list.rs:130`, `relays.rs:352-355,497-500` — Untrimmed
paste input fails parse** ("trailing newline = invalid key"); `relays` has a weak
`starts_with("ws")` check.
- **[L] `dialogs/relays.rs:124-140,360-366` — Relay metadata can never be reset; duplicate add is a
silent no-op** that can't update metadata.
- **[L] `dialogs/settings.rs:517-543` — Toggles compute `!rendered_value` instead of the switch's
`checked`**; two fast clicks collapse into one. Match `onboarding.rs:398-399`.
- **[L] Dialog `tasks` fields never pruned** (`import.rs:63`, `restore.rs:24`, `relays.rs:45,57`,
`contact_list.rs:52`, `profile.rs:55`, `new_chat.rs:50`, `new_group.rs:94`) — see S1;
`screening.rs`/`greeter.rs` show the right patterns.
- **[L] `new_chat.rs:93-95`, `profile.rs:278-280`, `screening.rs:90-92` — Signed-out paths return
silently**; no retry when login completes later.
- **[L] Dialogs can be stacked repeatedly** — no `has_active_dialog` checks in any `open()`.
- **[L] `new_group.rs:137-140` — `GroupsEvent::Created { warning, .. }` discards the non-fatal
warning.**
Positives: `limit(1)` newest-event pattern verified correct on both backends; entities created
before `open_dialog` per gpui-kit contract; `new_group.rs` has a solid error lifecycle; heavy work
consistently on background; no unused deps in the crate.
### 4.16 `ui` — shared components
- **[M] `avatar.rs:128-186` — `PixelAvatar` regenerates the full pattern every frame** (FNV-1a +
xorshift 64-cell grid + ~50 child divs per avatar; ~30 visible avatars, redrawn on every
keystroke) — the biggest hot-path cost in the main view. Memoize the deterministic pattern
(seed → `[u8; 64]` cache); hoist `FALLBACK_SEED` to a `&'static str` path.
- **[L] `text.rs:11-38` — `InlineReplacement` is `pub` but unreachable externally**; same for
`RUN_WINDOW_SECS` (`message.rs:28`). `pub(crate)` or document as extension points.
- **[L] `message.rs:766` — `RevealText::new` is the only public constructor without a doc line.**
- **[L] `message.rs:775-796` — `RevealText` re-splits text into a `Vec<SharedString>` every
animation frame**; split once in `new`.
- **[L] `text.rs:89-95` — Non-web, non-nevent Nostr links are silently dead** (`npub`/`nprofile`
clicks do nothing). Route to a profile view or drop the link markup.
- **[L] `Cargo.toml:15,19` — `web-time` and `uuid` unused.** Remove.
Positives: consistent `RenderOnce` + `Styled` builder pattern; `#[must_use]` builders; documented
public items; char-boundary-safe slicing; truncating casts provably bounded.
### 4.17 `auto_update`
- **[M] `lib.rs:92` — Checksum-only verification; no signing key configured.** SHA256SUMS comes
from the same host as the artifact — protects against corruption, not a compromised release
host/account. The engine already supports minisign/Ed25519; publish a sidecar signature and pin
the public key before stable.
- **Verified non-issue:** the `futures::executor::block_on` in `source.rs:152,170` runs on the GPUI
**background** executor (verified through `gpui-updater-pre 0.2.0`'s `Updater::check` →
`engine.check()` → `fetch_latest` call chain) — no UI freeze. Severity Low: it parks one
background thread up to ~60 s during a check. Optional tidy-up only.
- **[L] `source.rs:156-163,180-183` — Asset URLs not scheme-checked**; assert `https://`.
- **[L] `lib.rs:119-131,145-147` — A failed check shows for 5 s then reports "Up to date"**
(`error_revert` → `Idle`, and `Idle` counts as up-to-date). Distinct "check failed" state.
- **[L] `lib.rs:103,128` — `.ok()` on entity updates** (see S2).
- **[L] `lib.rs:134-139` — Auto-download/stage ~2 min after startup without a prompt**; fine on
unmetered, worth a setting.
Positives: strict `latest > current` gate (no downgrades); drafts/prereleases skipped; 30 s
timeouts; defensive checksum parsing; good tests.
### 4.18 `gpui_tokio`
- **[L] `lib.rs:14` — `.expect("Failed to initialize Tokio")`**; per the no-panic rule, `init`
returning `Result` would be more in keeping (realistically unreachable).
- **[L] `lib.rs:38` — `pub struct Tokio {}`** namespace struct; `pub struct Tokio;` or free
functions.
Positives: the `defer`-guard cancellation pattern is correct in both completion and drop cases;
`shutdown_background()` avoids blocking exit; `spawn_result` maps `JoinError` into `anyhow`.
No deadlock risk across the two runtimes (verified: nothing waits on GPUI from tokio or holds a
lock across the boundary).
### 4.19 `desktop` / `web` / `assets` entries
- **[M] `desktop/src/main.rs:20-26,153-170` — Secret key accepted as a CLI argument.** `--sec
nsec1…` puts the master key in the process list (`ps`, `/proc`, shell history); the parse-error
path echoes the raw (near-)key to stderr. Read from stdin/env; print error kind only, never the
input.
- **[L] `desktop/src/main.rs:131-150` — Seven startup unwraps on embedded assets.** Deterministic,
but one corrupt font aborts launch; skip bad fonts with a warning and fail only if zero loaded.
`open_window().expect` is fair (genuinely unrecoverable). The startup `block_on` for fonts
(main.rs:135) is intentional and acceptable — noted so it isn't confused with the anti-pattern.
- **[L] `web/src/lib.rs:122` — `expect("Failed to open window…")` aborts the wasm module** (blank
page + console error; `panic = "abort"`). A DOM-level error would be kinder.
- **[L] `web/src/lib.rs:43` — `.ok()` on console-log init** (justified; noted for completeness).
- **[L] `desktop/Cargo.toml:37` + `web/Cargo.toml:13` — `ui` dep unused in both entries.** Remove
unless it exists to force feature unification (then comment why).
- **[L] `assets/Cargo.toml:9` — `anyhow` unused** (code uses `gpui::Result`). Remove.
Positives: `web` opens IndexedDB before `run()`, panic hook + both logging stacks via `OnceLock`;
`assets` ships the theme-schema test.
## 5. Tooling Validation
| Check | Result |
|---|---|
| `cargo clippy --workspace --all-targets` | clean — zero warnings |
| `cargo fmt --all --check` | clean |
| `cargo check -p workspace`, `-p person` | pass |
| `let _ =` / `.ok()` sites (non-test) | ~156 — mostly entity-update-expected-error cases; see S2 |
| `tasks:` container declarations | ~48 sites across 20 owners — see S1 |
| Startup `block_on` sites | 3 (`state` DB open, `desktop` fonts — both acceptable-at-startup; `auto_update` — background thread, non-issue) |
## 6. Verified Non-Issues (checked, do not re-flag)
- `auto_update/source.rs` `block_on`: background executor only (verified through the
`gpui-updater-pre 0.2.0` call chain) — no UI freeze.
- `nip29/src/protocol.rs:1072` `block_on`: test-only.
- No locks held across `.await` anywhere in the workspace (all audited crates).
- `person.rs:170` `let Ok(pubkey) = to_bech32()` — irrefutable (`Err = Infallible`).
- `stored_contact_list`'s `limit(1)` + `.next()` — correct: nostr-lmdb orders by `created_at`
descending.
- `nip60`/`nip60_ui` have zero non-test `unwrap`/`expect`/`let _ =`; indexing is guarded; no
truncating casts.
- `nip29_ui`'s `sync()` splice math traced against `ListState` semantics — correct (it's `chat_ui`
' that has the mismatch).
- gpui-pre's entity read-tracking re-renders panels on `Directory`/`SpeechState` notifications —
the missing-`cx.notify()` suspicion in speech handlers is fine; the stale-cache bugs above are
real, the observation isn't.
- `nip60::init` startup ordering is sound for normal flows; the CLI-key path fires `SignerChanged`
before wallet subscription exists (app-wide init-order caveat, affects all registries equally).
- `nip60` melt double-spend paths are latent (dead API), not live.
## 7. What's Done Well
- **Clippy-clean, fmt-clean workspace** with no reachable panics in `nip29`, `nip60`, `ui`,
`nip29_ui`, `person` production paths; hostile-input discipline in the WoT builder, speech
downloads (SHA-256 + size cap + atomic rename), and auto-update (fail-closed checksums, no
downgrades).
- **`verify_subscriptions(true)` + `ban_relay_on_mismatch(true)`** in `state` is the single most
valuable client-side trust boundary, and it's on.
- **Entity/subscription hygiene is strong overall**: subscriptions stored in `_subscriptions`,
`WeakEntity` used for room/group/dock back-references, no strong entity cycles found anywhere,
no dropped-subscription bugs.
- **Secrets discipline**: no secret is ever logged or published in `nip4e`/`nip60`/`state` desktop
paths (verified against every log call); `Keys`' vendored `Debug` hides secrets.
- **Errors generally do reach the UI** where it matters most (group writes via `util::report`,
wallet events, speech errors, import/restore flows) — the gaps are enumerated above.
## 8. Recommended Remediation Order
**P0 — before any release candidate (blocks release):**
1. nip60 deletion spoofing (4.2 #1) — one-line precedence fix + author check.
2. browser-signer-proxy auth/CORS/token + timeout cleanup (4.4 #1-2).
3. state startup DB fallback (4.1 #1) and web localStorage encryption (4.1 #2).
4. nip60 reconcile/republish money-safety trio (4.2 #2-4) + stuck `redeeming` (4.2 #5).
5. state file-cache collision (4.1 #4) — wrong-file disclosure.
6. chat_ui reaction double-count (4.7 #1) and common whitespace collapse (4.10 #1).
7. contact-list Update data loss (4.15 #1).
8. speech parakeet worker lifetime (4.12 #1).
9. settings load/save durability (4.13 #1-2).
10. nip29 `created_at` guards (4.8 #1-2).
**P1 — fix during the release cycle:**
Dock panel identity + "Ignore" dispatch (4.14 #1-2); updater observation (4.14 #3); invoice flow
or removal (4.3 #1); person `in_flight` release (4.11 #1); chat trash UI (4.6 #1); chat_ui load
errors + splice + quadratic insert (4.7 #2-4); profile URL validation, backup keyring surfacing,
new_chat re-filter, screening dialog cluster (4.15); parser recursion for wasm (4.10 #3);
`DefaultHasher` → stable hash before more room ids ship (S5); nip4e approval button + rotation
archive + listener death (4.5 #1-3); proxy plaintext leak + event-substitution check (4.4 #3-4).
**P2 — hygiene, in one mechanical sweep:**
S1 (`tasks` containers → replace-per-spawn / `detach_and_log_err`), S2 (`let _ =`/`.ok()` →
`.log_err()`), S6 (domain-keyed element ids), unused deps (`chat`: smol; `nip29`: person;
`nip60`: tokio; `ui`: web-time, uuid; `assets`: anyhow; entries: ui), dead code
(`nip60` melt/send/quotes decision, `state::passphrase_required`, `DownloadedFile::save`,
`dock::find_panel` fix-or-delete, `skin.rs` write-once flag, `Person` trait impls), avatar
memoization, sidebar row-model caching, rename `DeviceNotifcation`, summary-comment cleanup.
---
*Method note: every crate's sources were read in full by a dedicated audit pass; GPUI/fork API
claims were verified against the vendored `gpui-pre 0.3.12`, `gpui-base 0.9.1`,
`gpui-component 0.9.1`, and rust-nostr sources; the Critical and top-High findings were
independently re-verified by hand against the working tree at `8fa79aef`. No files were modified
during the audit.*
## 9. P0 Remediation Log (2026-11-12)
All ten P0 items fixed. Validation after the full batch: `cargo clippy --workspace --all-targets`
zero warnings, `cargo fmt --all --check` clean, `cargo test --workspace` 35 passed / 0 failed.
Diff: 15 files, +816/−273.
1. **nip60 deletion spoofing** — `nip60/src/lib.rs`: precedence fixed to
`ours && (is_cashu || is_token_deletion(..))`; `absorb`/`absorb_deletion` now take `me` and
reject foreign-authored kind 5 events (defense in depth; the live notification gate was the
only protection before, and `absorb_deletion` had no author check). The DB load path was
already author-filtered and is unchanged.
2. **browser-signer-proxy hardening** — per-session UUID token generated at startup, embedded in
the served page (`inject_session_token`), required on `/api/*` (403 otherwise); exact `Host`
match (DNS-rebinding defense); all CORS headers and the OPTIONS preflight removed;
`cancel_request` removes timed-out requests from both maps so plaintext is dropped;
centralized best-effort `write_response`. `proxy.js` authenticates all three fetches. Token
never appears in the URL the app opens.
3. **state startup crash loop** — `NostrLmdb::open` failure now logs and falls back to
`MemoryDatabase::unbounded()` (nostr-memory added to the desktop deps), emitting
`StateEvent::error` via the existing deferred path; the `block_on` stays (safe: the LMDB open
schedules nothing back onto the GPUI executor — now commented).
4. **state web localStorage key** — `import_bunker` on wasm32 no longer persists the bunker
credential (session is memory-only; desktop keyring unchanged); `restore_session` degrades to
`NoSigner` when nothing is stored (verified). **Known behavior change: web users re-enter their
bunker URI after each reload.**
5. **state file-cache collision** — cache filename now derived from the decrypted content's
sha256 instead of the filename; the metadata guard is gone and every store goes through
temp-write + atomic rename.
6. **nip60 reconcile money-safety** — `ops.rs::reconcile` rewritten: relay tokens are converged
into the store BEFORE any publish (closes the `del`-before-converge race); relay proofs already
tracked locally (unspent or in-flight Pending/Reserved/PendingSpent) are never re-added, and
unseen proofs are checked against the mint (NUT-08 `check_proofs_spent`, best-effort) before
being accepted as Unspent; when a prior job errored after mutating the store
(`publish_failed` flag), reconcile republishes the store's unspent set instead of trusting
relay state — healing stranded sends/receives; without the flag the relay-trust removal path
(cross-device spend convergence) is preserved. A failing Reconcile does not reschedule itself
(no infinite loop).
7. **nip60 stuck nutzap redemptions** — `apply_outcome` now receives the failed job; on
`RedeemNutzap` error the id is removed from `redeeming` and requeued up to
`MAX_REDEEM_ATTEMPTS = 3` (cleared on success, reset on signer change), so transient mint
failures no longer wedge redemptions until restart.
8. **Reactions double-count** — `chat_ui::insert_reaction` replaces per (author, target) per
NIP-25, making the backup gift-wrap echo idempotent (verified: optimistic rumor and the
re-delivered seal carry the same event id) and history reloads non-duplicating.
9. **Whitespace collapse** — `common/media_extractor.rs` rewritten: whitespace is tidied only at
URL-removal boundaries; line breaks in untouched regions survive; regex hoisted to
`LazyLock`; 9 unit tests added (multi-line preservation, URL-only messages, digit/emoji edges).
10. **Contact-list data loss** — `contact_list.rs` now parses the stored event into full
`nostr::nips::nip02::Contact` values (relay hint + alias preserved through load → add/remove
→ republish); new contacts still get `Contact::new`.
11. **Speech worker lifetime** — `parakeet.rs`: the `OnceLock<Sender>` became a resettable
`Mutex<Option<Sender>>`; a dead worker (30 s idle exit) is detected via `is_disconnected`, the
slot cleared, and a replacement thread spawned lazily on the next command; spawn failure no
longer caches a dead sender; failures surface through `sink.error` / returned `Err` instead
of a debug-level swallow (one retry covers the liveness-check/send race).
12. **Settings durability** — a failed load logs and keeps defaults in memory without
persisting (`suppress_next_save`, consumed by the next real change); fresh-install NotFound is
no longer conflated with read/parse failures; saves are serialized through a single
`save_task` slot (chained, FIFO order) with temp-file + atomic rename; serialization and
write failures are logged.
13. **nip29 ordering guards** — shared `is_newer_event` (strict `(created_at, id)` total order):
`Group::absorb_state` keeps a per-kind stamp (written only after a successful apply, so a
malformed newer event can't poison the guard) and rejects stale replays while ascending cache
hydration still applies; the registry's `list_stamp` seeds from the cached list event at
startup, is checked in `merge` before applying, updated by `publish_list` (closing the
leave→resurrect window), and reset on signer change. 3 new tests across protocol/group.
14. **nip60_ui invoice flow** — *not* in this batch: it is P1 (the audit lists it as High but the
P0 list ranks it 16th via `nip60_ui/wallet.rs`); `mint_quotes()`/`claim_mint_quote()` remain
dead until the P1 UI work lands. No other P0 code paths depend on it.
Behavior changes to be aware of when testing:
- Web: bunker credentials are no longer persisted (re-connect after each reload).
- Desktop: a corrupted/unreadable local database now starts with an empty in-memory history and
shows an error event instead of crash-looping; relays re-sync as before.
- nip60: reconcile now performs one NUT-08 mint round-trip when unseen relay proofs appear.
- Reactions render per NIP-25 replace semantics (one reaction per author per target).
- Messages keep their line breaks; only URL-adjacent whitespace is tidied.
## 10. P1 Remediation Log (2026-11-12)
All eleven P1 items from §8 fixed on top of the P0 commit (`fd802a88`). Validation after the
full batch: `cargo clippy --workspace --all-targets` zero warnings, `cargo fmt --all --check`
clean, `cargo test --workspace` 38 passed / 0 failed. Diff: 25 files, +1072/−247.
1. **Dock panel dedup made real** (`workspace/src/dock/mod.rs`, `lib.rs`, `skin.rs`):
`PanelKey` — `Named(panel_name)` for singletons, `Room(room_id)` for chat panels — recovered
from docked panels via `PanelHandle::of` + downcasts; `add_panel` now focuses the existing
tab (reopening a collapsed dock first) instead of stacking a duplicate; group panels dedupe
through `Workspace.group_panels: HashMap<GroupKey, PanelId>` (group identity is not readable
off a `GroupPanel`), with stale ids pruned via `area.panel(id)` validation. The dead
`find_panel`/`move_panel`/`InsertTarget` machinery is deleted, along with `skin.rs`'s
write-once `toggle_button_visible`. Known residual: the panel opened by the new-group dialog
(`dialogs/new_group.rs`, still plain `add_panel_to`) isn't registered — P2.
2. **"Ignore" screening action** (`sidebar/rows.rs`, `dock/mod.rs`): closes the request room's
tab via `dock::close_room_panel` (find by room key → `DockArea::remove_panel`) instead of
dispatching `ClosePanel` into a focus path that can never reach its handler.
3. **Updater observation** (`sidebar/mod.rs`, `sidebar/header.rs`, `auto_update/src/lib.rs`):
`AutoUpdater` now exposes `UpdateDisplay` (`up_to_date`, `staged`, `status` text) via
`display()`; the Sidebar mirrors it into a small `Entity<Option<UpdateDisplay>>`, updating it
only when a rendered fact actually changes, and the header renders from that snapshot (the
restart click fetches the global entity at click time). Observing the whole updater and
notifying the entire cached sidebar was rejected as too blunt: download-progress ticks would
have invalidated the panel on every byte.
4. **Wallet invoice flow** (`nip60_ui/wallet.rs`, `nip60/src/lib.rs`): pending mint quotes render
in the panel (amount, mint, bolt11 with middle-ellipsis, copy via the house `Clipboard`
component, check/claim wired to the existing `Job::ClaimQuote` path, per-quote dismiss via the
new `dismiss_mint_quote`); claim results refresh through the existing `WalletEvent::Updated`;
an unpaid check emits "Invoice is not paid yet" (gated on `ClaimQuote` — flagged behavior
change). Receive-via-lightning is functional end-to-end.
5. **Person fetch blacklisting** (`person/src/lib.rs`): `in_flight` is now shared
(`Arc<RwLock<HashSet<_>>>`) with the batching task; all three duplicated `get_metadata` error
arms factored into `flush_batch`, which releases the whole batch for retry on failure and
logs. A 2 s timeout now costs a retry, not a session-long placeholder.
6. **Chat trash surfaced** (`chat/src/lib.rs`): the signal consumer counts *freshly* trashed
messages (BTreeSet insert dedupes reprocess attempts) and emits one batched
`ChatEvent::Error` ("N messages could not be decrypted"), which the workspace already renders
as an error notification.
7. **chat_ui list fixes** (`chat_ui/src/lib.rs`): room connect/load failures now push persistent
error notifications (`notify_failure`, `.autohide(false)`) instead of silently empty rooms;
message inserts splice at the real row (`pos + 1`, welcome row accounted for — append case is
byte-identical to the old behavior); bulk loads are O(n log n) sort-merge with a single splice
and one index rebuild, preserving the old first-arrival-wins timestamp dedup exactly.
8. **Dialog fixes** (`workspace/src/dialogs/{profile,new_chat,screening}.rs`, `panels/backup.rs`):
profile validates avatar/website URLs before publishing (inline error banner; empty still
means "keep saved value"); backup surfaces keyring/decode failures in-panel and its bech32
`unwrap`s are gone; new_chat re-filters on `InputEvent::Change` (and profile's avatar preview
tracks typing); screening no longer closes all stacked dialogs on release, NIP-05 verification
is tri-state (pending/verified/mismatched/failed — network failure no longer asserts "does not
match"), and report submission shows failures.
9. **Parser recursion** (`common/src/parser.rs`): `NostrParserIter::next` is a loop; the
pending-match fields are gone (provably passthrough); 50,000 consecutive malformed `nostr:`
URIs now cost loop iterations, not stack frames (regression tests added) — wasm tab-crash
vector closed.
10. **Stable room ids** (`common/src/event.rs`, `nip29`): `DefaultHasher` replaced by SHA-256
(`common::stable_id` 128-bit hex, `stable_id_u64` for u64-keyed consumers); `Event` and
`UnsignedEvent` `uniq_id` impls factored into one `pubkey_set_uniq_id` helper; nip29's
`GroupKey::uniq_id` and `browse_subscription` unified (verified session-local). **Room id
values change** (new hash domain) — existing cached groupings regroup per session as rumors
re-arrive; release notes should mention it. Follow-up if 128-bit ids are wanted: chat's id
fields are u64-typed; migrating to `String` ids is mechanical.
11. **nip4e lifecycle** (`nip4e/src/lib.rs`): approval has an `approving` re-entry guard (the
device secret can no longer be double-sent), toast lifecycle is correct on both outcomes
(success clears only the matching request; failure rebuilds the toast with a fresh enabled
button); rotation aborts before overwrite when load/archive fails (surfaces via
`DeviceEvent::error`); the relay listener logs dropped events and its own death instead of
stopping silently.
12. **Proxy hardening round 2** (`browser-signer-proxy/src/lib.rs`): signed events are checked
for correspondence (`event.compute_id()` vs returned id — proves field equality, covers
stale ids), closing the event-substitution hole; the response log carries id + ok/error only,
never values; request lines/headers are capped (16 KiB → 400), `Content-Length` capped
(1 MiB → 413), invalid UTF-8 → 400, and connections are dropped after 30 s (slowloris
bound).
### P1 behavior changes to be aware of when testing
- Opening an already-open room/group/panel focuses the existing tab; the "Ignore" action closes
the request room's tab.
- Wallet panel shows a "Pending invoices" section; unpaid checks report "Invoice is not paid
yet"; quotes are dismissable.
- Room groupings regenerate under a new id scheme — first launch after this change rebuilds
rooms from cached rumors (history is kept; grouping self-heals).
- Profile saves with an invalid URL now stop with an inline error instead of silently dropping
the field; NIP-05 shows "could not be verified" on network failure instead of "does not
match"; undecryptable messages and room load/connect failures produce visible notifications.
- First message-send/receive after the room-id change republishes `r` tags under the new ids.
-1
View File
@@ -21,7 +21,6 @@ person = { path = "../crates/person" }
gpui.workspace = true gpui.workspace = true
gpui_platform.workspace = true gpui_platform.workspace = true
gpui_web.workspace = true
gpui-kit.workspace = true gpui-kit.workspace = true
log.workspace = true log.workspace = true