app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit bd0ced242d453eacb44a352f09e9fc0bb5b5e121
parent 1dad49751d9ea58bae97c6f33dc783f5ee6078ea
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 03:10:37 +0000

app: add host-vault accounts bootstrap

Diffstat:
MCargo.lock | 714+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCargo.toml | 2++
Mcrates/launchers/desktop/Cargo.toml | 2++
Acrates/launchers/desktop/src/accounts.rs | 470+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/launchers/desktop/src/lib.rs | 1+
Mcrates/launchers/desktop/src/runtime.rs | 19+++++++++++++++++++
Mcrates/shared/models/src/lib.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
7 files changed, 1263 insertions(+), 27 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -9,6 +9,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] name = "aes" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -61,6 +71,12 @@ dependencies = [ ] [[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -111,6 +127,12 @@ dependencies = [ ] [[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] name = "arrayref" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -496,6 +518,12 @@ checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" [[package]] name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" @@ -507,6 +535,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] name = "bindgen" version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -527,6 +561,17 @@ dependencies = [ ] [[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -548,6 +593,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", + "serde", +] + +[[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -558,6 +620,9 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] [[package]] name = "bitstream-io" @@ -842,6 +907,30 @@ dependencies = [ ] [[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] name = "chrono" version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -888,7 +977,7 @@ dependencies = [ "cocoa-foundation 0.1.2", "core-foundation 0.9.4", "core-graphics 0.23.2", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -904,7 +993,7 @@ dependencies = [ "cocoa-foundation 0.2.0", "core-foundation 0.10.0", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -992,6 +1081,25 @@ dependencies = [ ] [[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "async-trait", + "convert_case 0.6.0", + "json5", + "nom 7.1.3", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml 0.8.23", + "yaml-rust2", +] + +[[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1030,6 +1138,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" [[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1064,7 +1181,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1077,7 +1194,7 @@ dependencies = [ "bitflags 2.11.1", "core-foundation 0.10.0", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1090,7 +1207,7 @@ dependencies = [ "bitflags 2.11.1", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1137,7 +1254,7 @@ checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" dependencies = [ "core-foundation 0.10.0", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1324,6 +1441,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "openssl", + "zeroize", +] + +[[package]] name = "deflate64" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1354,7 +1493,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", @@ -1450,6 +1589,15 @@ dependencies = [ ] [[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1901,12 +2049,21 @@ dependencies = [ [[package]] name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1922,6 +2079,12 @@ dependencies = [ [[package]] name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" @@ -2268,7 +2431,7 @@ dependencies = [ "etagere", "filedescriptor", "flume", - "foreign-types", + "foreign-types 0.5.0", "futures", "gpui-macros", "gpui_collections", @@ -2463,7 +2626,7 @@ dependencies = [ "core-foundation 0.10.0", "core-video", "ctor", - "foreign-types", + "foreign-types 0.5.0", "metal", "objc", ] @@ -2600,6 +2763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", + "allocator-api2", ] [[package]] @@ -2619,6 +2783,15 @@ checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" @@ -2651,6 +2824,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] name = "hexf-parse" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3048,6 +3230,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -3172,6 +3357,34 @@ dependencies = [ ] [[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "openssl", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] name = "khronos-egl" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3264,6 +3477,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] name = "libfuzzer-sys" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3313,6 +3536,16 @@ dependencies = [ ] [[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + +[[package]] name = "linux-raw-sys" version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3541,7 +3774,7 @@ dependencies = [ "bitflags 2.11.1", "block", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", @@ -3784,6 +4017,31 @@ dependencies = [ ] [[package]] +name = "nostr" +version = "0.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" +dependencies = [ + "aes", + "base64 0.22.1", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] name = "notify" version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4135,6 +4393,12 @@ dependencies = [ ] [[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] name = "open" version = "5.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4146,18 +4410,76 @@ dependencies = [ ] [[package]] +name = "openssl" +version = "0.10.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] name = "ordered-stream" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4197,6 +4519,17 @@ dependencies = [ ] [[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4250,6 +4583,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] name = "phf" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4405,6 +4781,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" [[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] name = "postage" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4665,6 +5052,8 @@ dependencies = [ "radroots_app_state", "radroots_app_sync", "radroots_app_ui", + "radroots_nostr_accounts", + "radroots_secret_vault", "thiserror 2.0.18", "tracing", "tracing-subscriber", @@ -4741,6 +5130,153 @@ dependencies = [ ] [[package]] +name = "radroots_core" +version = "0.1.0-alpha.2" +dependencies = [ + "rust_decimal", + "rust_decimal_macros", + "serde", +] + +[[package]] +name = "radroots_events" +version = "0.1.0-alpha.2" +dependencies = [ + "radroots_core", + "serde", +] + +[[package]] +name = "radroots_identity" +version = "0.1.0-alpha.2" +dependencies = [ + "nostr", + "radroots_events", + "radroots_protected_store", + "radroots_runtime", + "radroots_runtime_paths", + "radroots_secret_vault", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "radroots_log" +version = "0.1.0-alpha.2" +dependencies = [ + "chrono", + "thiserror 1.0.69", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "radroots_nostr" +version = "0.1.0-alpha.2" +dependencies = [ + "nostr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "radroots_nostr_accounts" +version = "0.1.0-alpha.2" +dependencies = [ + "radroots_identity", + "radroots_nostr_signer", + "radroots_protected_store", + "radroots_runtime", + "radroots_secret_vault", + "serde", + "serde_json", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "radroots_nostr_connect" +version = "0.1.0-alpha.2" +dependencies = [ + "nostr", + "serde", + "serde_json", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "radroots_nostr_signer" +version = "0.1.0-alpha.2" +dependencies = [ + "hex", + "nostr", + "radroots_identity", + "radroots_nostr", + "radroots_nostr_connect", + "radroots_runtime", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "url", + "uuid", +] + +[[package]] +name = "radroots_protected_store" +version = "0.1.0-alpha.2" +dependencies = [ + "chacha20poly1305", + "getrandom 0.2.17", + "radroots_secret_vault", + "serde", + "serde_json", + "zeroize", +] + +[[package]] +name = "radroots_runtime" +version = "0.1.0-alpha.2" +dependencies = [ + "anyhow", + "chacha20poly1305", + "config", + "getrandom 0.2.17", + "radroots_log", + "radroots_protected_store", + "radroots_runtime_paths", + "radroots_secret_vault", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "toml 0.8.23", + "tracing", + "zeroize", +] + +[[package]] +name = "radroots_runtime_paths" +version = "0.1.0-alpha.2" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "radroots_secret_vault" +version = "0.1.0-alpha.2" +dependencies = [ + "keyring", +] + +[[package]] name = "rand" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5028,6 +5564,18 @@ dependencies = [ ] [[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags 2.11.1", + "serde", + "serde_derive", +] + +[[package]] name = "ropey" version = "2.0.0-beta.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5051,7 +5599,7 @@ dependencies = [ "bitflags 2.11.1", "fallible-iterator", "fallible-streaming-iterator", - "hashlink", + "hashlink 0.9.1", "libsqlite3-sys", "smallvec", ] @@ -5147,6 +5695,38 @@ dependencies = [ ] [[package]] +name = "rust-ini" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust_decimal" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" +dependencies = [ + "arrayvec", + "num-traits", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "rust_decimal_macros" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5216,7 +5796,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -5297,6 +5877,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5376,12 +5965,57 @@ dependencies = [ ] [[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] name = "seahash" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.6", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6295,11 +6929,24 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] [[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6617,6 +7264,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] name = "uds_windows" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6700,6 +7353,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] name = "unicode-properties" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6736,6 +7398,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6766,7 +7438,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb 0.23.0", @@ -6955,6 +7627,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -7929,6 +8602,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" [[package]] +name = "yaml-rust2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink 0.8.4", +] + +[[package]] name = "yazi" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -8074,7 +8758,7 @@ version = "0.12.15-zed" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac2d05756ff48539950c3282ad7acf3817ad3f08797c205ad1c34a2ce03b9970" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", diff --git a/Cargo.toml b/Cargo.toml @@ -30,6 +30,8 @@ hex = "0.4" mf2-i18n-build = { git = "https://github.com/triesap/mf2-i18n.git", rev = "0c3ba2729b309f27aed3e27ae4e753b0147a75ec" } mf2-i18n-core = { git = "https://github.com/triesap/mf2-i18n.git", rev = "0c3ba2729b309f27aed3e27ae4e753b0147a75ec" } mf2-i18n-native = { git = "https://github.com/triesap/mf2-i18n.git", rev = "0c3ba2729b309f27aed3e27ae4e753b0147a75ec" } +radroots_nostr_accounts = { path = "../lib/crates/nostr_accounts", features = ["os-keyring"] } +radroots_secret_vault = { path = "../lib/crates/secret_vault", default-features = false, features = ["std", "os-keyring"] } radroots_app_core = { path = "crates/shared/core", version = "0.1.0" } radroots_app_i18n = { path = "crates/shared/i18n", version = "0.1.0" } radroots_app_models = { path = "crates/shared/models", version = "0.1.0" } diff --git a/crates/launchers/desktop/Cargo.toml b/crates/launchers/desktop/Cargo.toml @@ -11,6 +11,8 @@ publish = false gpui.workspace = true gpui-component.workspace = true gpui-component-assets.workspace = true +radroots_nostr_accounts.workspace = true +radroots_secret_vault.workspace = true radroots_app_core.workspace = true radroots_app_i18n.workspace = true radroots_app_models.workspace = true diff --git a/crates/launchers/desktop/src/accounts.rs b/crates/launchers/desktop/src/accounts.rs @@ -0,0 +1,470 @@ +use std::{env, fs, path::PathBuf}; + +use radroots_app_core::AppSharedAccountsPaths; +use radroots_app_models::{ + AccountSummary, AppIdentityProjection, FarmerActivationProjection, IdentityBlockedReason, + SelectedAccountProjection, SelectedSurfaceProjection, +}; +use radroots_app_sqlite::{AppSqliteError, AppSqliteStore}; +use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountRecord, RadrootsNostrAccountStore, RadrootsNostrAccountStoreState, + RadrootsNostrAccountsError, RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, + RadrootsNostrSelectedAccountStatus, +}; +use radroots_secret_vault::{ + RadrootsHostVaultCapabilities, RadrootsHostVaultPolicy, RadrootsSecretBackend, + RadrootsSecretBackendAvailability, RadrootsSecretBackendSelection, RadrootsSecretVault, + RadrootsSecretVaultError, RadrootsSecretVaultOsKeyring, +}; +use thiserror::Error; + +const HOST_VAULT_AVAILABILITY_OVERRIDE_ENV: &str = "RADROOTS_APP_HOST_VAULT_AVAILABLE"; +const HOST_VAULT_SERVICE_NAME: &str = "org.radroots.app.local-account"; +const HOST_VAULT_PROBE_SLOT: &str = "__radroots_app_host_vault_probe__"; + +pub struct DesktopAccountsBootstrap { + pub accounts_manager: Option<RadrootsNostrAccountsManager>, + pub identity_projection: AppIdentityProjection, +} + +pub fn bootstrap_desktop_accounts( + paths: &AppSharedAccountsPaths, + sqlite_store: &AppSqliteStore, +) -> Result<DesktopAccountsBootstrap, DesktopAccountsBootstrapError> { + bootstrap_desktop_accounts_with_availability( + paths, + sqlite_store, + secret_backend_availability()?, + ) +} + +fn bootstrap_desktop_accounts_with_availability( + paths: &AppSharedAccountsPaths, + sqlite_store: &AppSqliteStore, + availability: RadrootsSecretBackendAvailability, +) -> Result<DesktopAccountsBootstrap, DesktopAccountsBootstrapError> { + ensure_directory(paths.data_root.as_path())?; + ensure_directory(paths.secrets_root.as_path())?; + + let selection = local_account_secret_backend_selection(); + let store = RadrootsNostrFileAccountStore::new(paths.store_path.as_path()); + + match RadrootsNostrAccountsManager::resolve_local_backend(selection, availability) { + Ok(_) => { + let (accounts_manager, _) = RadrootsNostrAccountsManager::new_local_file_backed( + paths.store_path.as_path(), + paths.secrets_root.as_path(), + selection, + availability, + HOST_VAULT_SERVICE_NAME, + )?; + let identity_projection = + identity_projection_from_manager(&accounts_manager, sqlite_store)?; + + Ok(DesktopAccountsBootstrap { + accounts_manager: Some(accounts_manager), + identity_projection, + }) + } + Err(RadrootsSecretVaultError::BackendUnavailable { .. }) + | Err(RadrootsSecretVaultError::FallbackUnavailable { .. }) => { + let state = store.load()?; + let identity_projection = + blocked_identity_projection_from_store_state(state, sqlite_store)?; + + Ok(DesktopAccountsBootstrap { + accounts_manager: None, + identity_projection, + }) + } + Err(error) => Err(error.into()), + } +} + +fn ensure_directory(path: &std::path::Path) -> Result<(), DesktopAccountsBootstrapError> { + fs::create_dir_all(path).map_err(|source| DesktopAccountsBootstrapError::CreateDirectory { + path: path.to_path_buf(), + source, + }) +} + +fn local_account_secret_backend_selection() -> RadrootsSecretBackendSelection { + RadrootsSecretBackendSelection { + primary: RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop()), + fallback: None, + } +} + +fn secret_backend_availability() +-> Result<RadrootsSecretBackendAvailability, DesktopAccountsBootstrapError> { + Ok(RadrootsSecretBackendAvailability { + host_vault: host_vault_capabilities()?, + encrypted_file: false, + external_command: false, + memory: false, + }) +} + +fn host_vault_capabilities() -> Result<RadrootsHostVaultCapabilities, DesktopAccountsBootstrapError> +{ + if let Some(available) = host_vault_availability_override()? { + return Ok(match available { + true => RadrootsHostVaultCapabilities::desktop_keyring(), + false => RadrootsHostVaultCapabilities::unavailable(), + }); + } + + let keyring = RadrootsSecretVaultOsKeyring::new(HOST_VAULT_SERVICE_NAME); + match keyring.load_secret(HOST_VAULT_PROBE_SLOT) { + Ok(_) => Ok(RadrootsHostVaultCapabilities::desktop_keyring()), + Err(_) => Ok(RadrootsHostVaultCapabilities::unavailable()), + } +} + +fn host_vault_availability_override() -> Result<Option<bool>, DesktopAccountsBootstrapError> { + let Ok(value) = env::var(HOST_VAULT_AVAILABILITY_OVERRIDE_ENV) else { + return Ok(None); + }; + + parse_bool_value(HOST_VAULT_AVAILABILITY_OVERRIDE_ENV, value.trim()).map(Some) +} + +fn parse_bool_value(key: &str, value: &str) -> Result<bool, DesktopAccountsBootstrapError> { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + other => Err(DesktopAccountsBootstrapError::Configuration(format!( + "{key} must be a boolean value, got `{other}`" + ))), + } +} + +fn blocked_identity_projection_from_store_state( + state: RadrootsNostrAccountStoreState, + sqlite_store: &AppSqliteStore, +) -> Result<AppIdentityProjection, DesktopAccountsBootstrapError> { + let selected_account = selected_account_from_store_state(&state, sqlite_store)?; + + Ok(AppIdentityProjection::blocked_with_selection( + IdentityBlockedReason::HostVaultUnavailable, + account_roster_from_records(state.accounts.as_slice()), + selected_account, + )) +} + +fn identity_projection_from_manager( + manager: &RadrootsNostrAccountsManager, + sqlite_store: &AppSqliteStore, +) -> Result<AppIdentityProjection, DesktopAccountsBootstrapError> { + let roster_records = manager.list_accounts()?; + let roster = account_roster_from_records(roster_records.as_slice()); + + match manager.selected_account_status()? { + RadrootsNostrSelectedAccountStatus::NotConfigured => { + Ok(AppIdentityProjection::missing_with_roster(roster)) + } + RadrootsNostrSelectedAccountStatus::PublicOnly { account } + | RadrootsNostrSelectedAccountStatus::Ready { account } => { + Ok(AppIdentityProjection::ready( + roster, + selected_account_projection_from_record(&account, sqlite_store)?, + )) + } + } +} + +fn selected_account_from_store_state( + state: &RadrootsNostrAccountStoreState, + sqlite_store: &AppSqliteStore, +) -> Result<Option<SelectedAccountProjection>, DesktopAccountsBootstrapError> { + let Some(selected_account_id) = state.selected_account_id.as_ref() else { + return Ok(None); + }; + let Some(record) = state + .accounts + .iter() + .find(|record| &record.account_id == selected_account_id) + else { + return Ok(None); + }; + + selected_account_projection_from_record(record, sqlite_store).map(Some) +} + +fn selected_account_projection_from_record( + record: &RadrootsNostrAccountRecord, + sqlite_store: &AppSqliteStore, +) -> Result<SelectedAccountProjection, DesktopAccountsBootstrapError> { + let account = account_summary_from_record(record); + + Ok( + match sqlite_store.load_surface_activation(account.account_id.as_str())? { + Some(activation) => { + SelectedAccountProjection::from_surface_activation(account, activation) + } + None => SelectedAccountProjection::new( + account, + SelectedSurfaceProjection::default(), + FarmerActivationProjection::inactive(), + ), + }, + ) +} + +fn account_roster_from_records(records: &[RadrootsNostrAccountRecord]) -> Vec<AccountSummary> { + records.iter().map(account_summary_from_record).collect() +} + +fn account_summary_from_record(record: &RadrootsNostrAccountRecord) -> AccountSummary { + AccountSummary { + account_id: record.account_id.to_string(), + npub: record.public_identity.public_key_npub.clone(), + label: record.label.clone(), + custody: radroots_app_models::AccountCustody::LocalManaged, + } +} + +#[derive(Debug, Error)] +pub enum DesktopAccountsBootstrapError { + #[error("failed to create runtime directory {path}: {source}")] + CreateDirectory { + path: PathBuf, + source: std::io::Error, + }, + #[error(transparent)] + Sqlite(#[from] AppSqliteError), + #[error(transparent)] + Accounts(#[from] RadrootsNostrAccountsError), + #[error(transparent)] + SecretVault(#[from] RadrootsSecretVaultError), + #[error("{0}")] + Configuration(String), +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::PathBuf, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, + }; + + use radroots_app_core::AppSharedAccountsPaths; + use radroots_app_models::{ + ActiveSurface, AppStartupGate, IdentityBlockedReason, IdentityReadiness, + SelectedSurfaceProjection, + }; + use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget}; + use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, + RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, + }; + use radroots_secret_vault::RadrootsHostVaultCapabilities; + + use super::{ + account_summary_from_record, blocked_identity_projection_from_store_state, + bootstrap_desktop_accounts_with_availability, identity_projection_from_manager, + selected_account_projection_from_record, + }; + + fn temp_shared_accounts_paths(label: &str) -> AppSharedAccountsPaths { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let base = std::env::temp_dir().join(format!("radroots_app_accounts_{label}_{suffix}")); + + AppSharedAccountsPaths { + data_root: base.join("data/shared/accounts"), + secrets_root: base.join("secrets/shared/accounts"), + store_path: base.join("data/shared/accounts/store.json"), + } + } + + fn unavailable_secret_backend_availability() + -> radroots_secret_vault::RadrootsSecretBackendAvailability { + radroots_secret_vault::RadrootsSecretBackendAvailability { + host_vault: RadrootsHostVaultCapabilities::unavailable(), + encrypted_file: false, + external_command: false, + memory: false, + } + } + + #[test] + fn blocked_bootstrap_keeps_roster_and_selected_account_when_host_vault_is_unavailable() { + let paths = temp_shared_accounts_paths("blocked"); + fs::create_dir_all(paths.data_root.as_path()).expect("data root should create"); + fs::create_dir_all(paths.secrets_root.as_path()).expect("secrets root should create"); + let store = Arc::new(RadrootsNostrFileAccountStore::new( + paths.store_path.as_path(), + )); + let manager = RadrootsNostrAccountsManager::new( + store, + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("file-backed memory manager should build"); + let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store"); + + let first_account_id = manager + .generate_identity(Some("North field".to_owned()), true) + .expect("first account should generate"); + let second_account_id = manager + .generate_identity(Some("South field".to_owned()), false) + .expect("second account should generate"); + manager + .select_account(&first_account_id) + .expect("first account should remain selected"); + + let bootstrap = bootstrap_desktop_accounts_with_availability( + &paths, + &sqlite_store, + unavailable_secret_backend_availability(), + ) + .expect("blocked bootstrap should succeed"); + + assert!(bootstrap.accounts_manager.is_none()); + assert_eq!( + bootstrap.identity_projection.readiness, + IdentityReadiness::Blocked(IdentityBlockedReason::HostVaultUnavailable) + ); + assert_eq!( + bootstrap.identity_projection.startup_gate(), + AppStartupGate::Blocked + ); + assert_eq!(bootstrap.identity_projection.roster.len(), 2); + assert_eq!( + bootstrap + .identity_projection + .selected_account + .as_ref() + .map(|account| account.account.account_id.as_str()), + Some(first_account_id.as_str()) + ); + assert!( + bootstrap + .identity_projection + .roster + .iter() + .any(|account| account.account_id == second_account_id.as_str()) + ); + + cleanup_paths(&paths); + } + + #[test] + fn manager_projection_uses_selected_account_and_activation_state() { + let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store"); + let manager = RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrMemoryAccountStore::new()), + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("memory manager should build"); + let account_id = manager + .generate_identity(Some("North field".to_owned()), true) + .expect("account should generate"); + let selected_account = manager + .selected_account() + .expect("selected account should load") + .expect("selected account should exist"); + let selected_account_summary = account_summary_from_record(&selected_account); + let selected_account_projection = + selected_account_projection_from_record(&selected_account, &sqlite_store) + .expect("selected account projection"); + + assert_eq!( + selected_account_projection.account, + selected_account_summary + ); + assert_eq!( + selected_account_projection.selected_surface, + SelectedSurfaceProjection::default() + ); + + let activation = radroots_app_models::AccountSurfaceActivationProjection::new( + account_id.as_str(), + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + radroots_app_models::FarmerActivationProjection::active( + radroots_app_models::FarmId::new(), + ), + ); + sqlite_store + .save_surface_activation(&activation) + .expect("surface activation should save"); + + let projection = + identity_projection_from_manager(&manager, &sqlite_store).expect("projection"); + + assert_eq!(projection.readiness, IdentityReadiness::Ready); + assert_eq!(projection.startup_gate(), AppStartupGate::Farmer); + assert_eq!(projection.roster.len(), 1); + assert_eq!( + projection + .selected_account + .as_ref() + .map(|account| account.account.account_id.as_str()), + Some(account_id.as_str()) + ); + assert_eq!( + projection + .selected_account + .as_ref() + .map(|account| account.active_surface()), + Some(ActiveSurface::Farmer) + ); + assert!( + projection + .selected_account + .as_ref() + .is_some_and(|account| account.farmer_activation.is_active()) + ); + } + + #[test] + fn blocked_projection_from_store_state_ignores_stale_selected_account_ids() { + let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store"); + let manager = RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrMemoryAccountStore::new()), + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("memory manager should build"); + let account_id = manager + .generate_identity(Some("North field".to_owned()), true) + .expect("account should generate"); + let stale_selected_account_id = RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrMemoryAccountStore::new()), + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("secondary memory manager should build") + .generate_identity(Some("South field".to_owned()), true) + .expect("secondary account should generate"); + let record = manager + .selected_account() + .expect("selected account should load") + .expect("selected account should exist"); + let state = radroots_nostr_accounts::prelude::RadrootsNostrAccountStoreState { + version: radroots_nostr_accounts::prelude::RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION, + selected_account_id: Some(stale_selected_account_id), + accounts: vec![record], + }; + + let projection = + blocked_identity_projection_from_store_state(state, &sqlite_store).expect("projection"); + + assert_eq!( + projection.readiness, + IdentityReadiness::Blocked(IdentityBlockedReason::HostVaultUnavailable) + ); + assert!(projection.selected_account.is_none()); + assert_eq!(projection.roster.len(), 1); + assert_eq!(projection.roster[0].account_id, account_id.as_str()); + } + + fn cleanup_paths(paths: &AppSharedAccountsPaths) { + let Some(base) = paths.data_root.ancestors().nth(3).map(PathBuf::from) else { + return; + }; + let _ = fs::remove_dir_all(base); + } +} diff --git a/crates/launchers/desktop/src/lib.rs b/crates/launchers/desktop/src/lib.rs @@ -1,5 +1,6 @@ #![forbid(unsafe_code)] +mod accounts; mod app; mod menus; mod runtime; diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -13,9 +13,12 @@ use radroots_app_state::{ AppShellProjection, AppStateCommand, AppStateStore, AppStateStoreError, InMemoryAppStateRepository, }; +use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager; use thiserror::Error; use tracing::error; +use crate::accounts::{DesktopAccountsBootstrapError, bootstrap_desktop_accounts}; + const APP_DATABASE_FILE_NAME: &str = "app.sqlite3"; #[derive(Clone, Debug)] @@ -150,6 +153,7 @@ pub struct DesktopAppRuntimeSummary { struct DesktopAppRuntimeState { state_store: AppStateStore<InMemoryAppStateRepository>, + accounts_manager: Option<RadrootsNostrAccountsManager>, sqlite_store: Option<AppSqliteStore>, startup_issue: Option<String>, } @@ -160,6 +164,10 @@ impl fmt::Debug for DesktopAppRuntimeState { .debug_struct("DesktopAppRuntimeState") .field("state_store", &self.state_store) .field( + "accounts_manager", + &self.accounts_manager.as_ref().map(|_| "available"), + ) + .field( "sqlite_store", &self.sqlite_store.as_ref().map(|_| "available"), ) @@ -174,12 +182,17 @@ impl DesktopAppRuntimeState { let database_path = paths.app.data.join(APP_DATABASE_FILE_NAME); let sqlite_store = AppSqliteStore::open(DatabaseTarget::Path(database_path.clone()))?; let mut state_store = AppStateStore::load(InMemoryAppStateRepository::default())?; + let accounts_bootstrap = bootstrap_desktop_accounts(&paths.shared_accounts, &sqlite_store)?; let today_projection = sqlite_store.load_today_agenda(None)?; let _ = state_store.apply_in_memory(AppStateCommand::replace_today_agenda(today_projection)); + let _ = state_store.apply_in_memory(AppStateCommand::replace_identity_projection( + accounts_bootstrap.identity_projection, + )); Ok(Self { state_store, + accounts_manager: accounts_bootstrap.accounts_manager, sqlite_store: Some(sqlite_store), startup_issue: None, }) @@ -188,6 +201,7 @@ impl DesktopAppRuntimeState { fn degraded(error: DesktopAppRuntimeBootstrapError) -> Self { Self { state_store: AppStateStore::in_memory(AppShellProjection::default()), + accounts_manager: None, sqlite_store: None, startup_issue: Some(error.to_string()), } @@ -206,6 +220,8 @@ enum DesktopAppRuntimeBootstrapError { #[error(transparent)] RuntimePaths(#[from] AppRuntimePathsError), #[error(transparent)] + Accounts(#[from] DesktopAccountsBootstrapError), + #[error(transparent)] Sqlite(#[from] AppSqliteError), #[error(transparent)] State(#[from] AppStateStoreError), @@ -279,6 +295,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) .expect("in-memory sqlite store should open"), @@ -320,6 +337,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) .expect("in-memory sqlite store should open"), @@ -403,6 +421,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) .expect("in-memory sqlite store should open"), diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -410,31 +410,46 @@ pub struct AppIdentityProjection { impl AppIdentityProjection { pub fn missing() -> Self { - Self::default() + Self::with_readiness(IdentityReadiness::MissingAccount, Vec::new(), None) + } + + pub fn missing_with_roster(roster: Vec<AccountSummary>) -> Self { + Self::with_readiness(IdentityReadiness::MissingAccount, roster, None) } pub fn blocked(reason: IdentityBlockedReason) -> Self { - Self { - readiness: IdentityReadiness::Blocked(reason), - ..Self::default() - } + Self::with_readiness(IdentityReadiness::Blocked(reason), Vec::new(), None) + } + + pub fn blocked_with_selection( + reason: IdentityBlockedReason, + roster: Vec<AccountSummary>, + selected_account: Option<SelectedAccountProjection>, + ) -> Self { + Self::with_readiness(IdentityReadiness::Blocked(reason), roster, selected_account) } - pub fn ready( + pub fn ready(roster: Vec<AccountSummary>, selected_account: SelectedAccountProjection) -> Self { + Self::with_readiness(IdentityReadiness::Ready, roster, Some(selected_account)) + } + + pub fn with_readiness( + readiness: IdentityReadiness, mut roster: Vec<AccountSummary>, - selected_account: SelectedAccountProjection, + selected_account: Option<SelectedAccountProjection>, ) -> Self { - if !roster - .iter() - .any(|account| account.account_id == selected_account.account.account_id) + if let Some(selected_account) = selected_account.as_ref() + && !roster + .iter() + .any(|account| account.account_id == selected_account.account.account_id) { roster.insert(0, selected_account.account.clone()); } Self { - readiness: IdentityReadiness::Ready, + readiness, roster, - selected_account: Some(selected_account), + selected_account, } } @@ -823,6 +838,49 @@ mod tests { } #[test] + fn blocked_identity_keeps_selected_account_visible_in_roster() { + let selected_account = SelectedAccountProjection::new( + AccountSummary { + account_id: "acct_blocked".to_owned(), + npub: "npub1blocked".to_owned(), + label: Some("Blocked account".to_owned()), + custody: AccountCustody::LocalManaged, + }, + SelectedSurfaceProjection::new(ActiveSurface::Personal), + FarmerActivationProjection::inactive(), + ); + let projection = AppIdentityProjection::blocked_with_selection( + IdentityBlockedReason::HostVaultUnavailable, + Vec::new(), + Some(selected_account.clone()), + ); + + assert_eq!( + projection.readiness, + IdentityReadiness::Blocked(IdentityBlockedReason::HostVaultUnavailable) + ); + assert_eq!(projection.roster, vec![selected_account.account.clone()]); + assert_eq!(projection.selected_account, Some(selected_account)); + assert_eq!(projection.startup_gate(), AppStartupGate::Blocked); + } + + #[test] + fn missing_identity_can_keep_roster_visible_without_selected_account() { + let roster = vec![AccountSummary { + account_id: "acct_waiting".to_owned(), + npub: "npub1waiting".to_owned(), + label: Some("Waiting".to_owned()), + custody: AccountCustody::LocalManaged, + }]; + let projection = AppIdentityProjection::missing_with_roster(roster.clone()); + + assert_eq!(projection.readiness, IdentityReadiness::MissingAccount); + assert_eq!(projection.roster, roster); + assert!(projection.selected_account.is_none()); + assert_eq!(projection.startup_gate(), AppStartupGate::SetupRequired); + } + + #[test] fn typed_ids_round_trip_through_strings() { let uuid = Uuid::parse_str("018f4d61-19b0-7cc4-9d4e-6d0df7c0aa11") .expect("test uuid should parse");