Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add a basic startup flow
Draft did:key:z6MkkfM3...sVz5 opened 1 year ago
50 files changed +2715 -528 0efb4e09 e80d92a1
modified Cargo.lock
@@ -216,6 +216,12 @@ dependencies = [
]

[[package]]
+
name = "arc-swap"
+
version = "1.7.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
+

+
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -508,6 +514,15 @@ dependencies = [
]

[[package]]
+
name = "bloomy"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "489d2af57852b78a86478273ac6a1ef912061b6af3a439694c49f309f6ea3bdd"
+
dependencies = [
+
 "siphasher 0.3.11",
+
]
+

+
[[package]]
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -562,6 +577,17 @@ dependencies = [
]

[[package]]
+
name = "bstr"
+
version = "1.11.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0"
+
dependencies = [
+
 "memchr",
+
 "regex-automata",
+
 "serde",
+
]
+

+
[[package]]
name = "bumpalo"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -768,6 +794,19 @@ dependencies = [
]

[[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.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -788,6 +827,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
 "crypto-common",
 "inout",
+
 "zeroize",
]

[[package]]
@@ -830,6 +870,16 @@ dependencies = [
]

[[package]]
+
name = "colored"
+
version = "2.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
+
dependencies = [
+
 "lazy_static",
+
 "windows-sys 0.59.0",
+
]
+

+
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1072,6 +1122,8 @@ checksum = "b67c16c8ef5ddcdab57aab83fd8e770540ea3682ccdae09642c63575b0da2184"
dependencies = [
 "amplify",
 "ec25519",
+
 "multibase",
+
 "sha2",
]

[[package]]
@@ -1082,6 +1134,7 @@ checksum = "ac949369884a7a1d802cc669821269c707be8cec4d65043382e253733d2e62e1"
dependencies = [
 "cypheraddr",
 "cyphergraphy",
+
 "noise-framework",
 "socks5-client",
]

@@ -1610,6 +1663,12 @@ dependencies = [
]

[[package]]
+
name = "faster-hex"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183"
+

+
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1970,6 +2029,18 @@ dependencies = [
]

[[package]]
+
name = "getrandom"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8"
+
dependencies = [
+
 "cfg-if",
+
 "libc",
+
 "wasi 0.13.3+wasi-0.2.2",
+
 "windows-targets 0.52.6",
+
]
+

+
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2033,6 +2104,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbaeb9672a55e9e32cb6d3ef781e7526b25ab97d499fae71615649340b143424"
dependencies = [
+
 "bstr",
 "serde",
 "thiserror 1.0.69",
]
@@ -2063,6 +2135,411 @@ dependencies = [
]

[[package]]
+
name = "gix-actor"
+
version = "0.31.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a0e454357e34b833cc3a00b6efbbd3dd4d18b24b9fb0c023876ec2645e8aa3f2"
+
dependencies = [
+
 "bstr",
+
 "gix-date 0.8.7",
+
 "gix-utils",
+
 "itoa 1.0.14",
+
 "thiserror 1.0.69",
+
 "winnow 0.6.20",
+
]
+

+
[[package]]
+
name = "gix-chunk"
+
version = "0.4.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0b1f1d8764958699dc764e3f727cef280ff4d1bd92c107bbf8acd85b30c1bd6f"
+
dependencies = [
+
 "thiserror 2.0.7",
+
]
+

+
[[package]]
+
name = "gix-command"
+
version = "0.3.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6d7d6b8f3a64453fd7e8191eb80b351eb7ac0839b40a1237cd2c137d5079fe53"
+
dependencies = [
+
 "bstr",
+
 "gix-path",
+
 "gix-trace",
+
 "shell-words",
+
]
+

+
[[package]]
+
name = "gix-commitgraph"
+
version = "0.24.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "133b06f67f565836ec0c473e2116a60fb74f80b6435e21d88013ac0e3c60fc78"
+
dependencies = [
+
 "bstr",
+
 "gix-chunk",
+
 "gix-features",
+
 "gix-hash",
+
 "memmap2",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
+
name = "gix-config-value"
+
version = "0.14.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "11365144ef93082f3403471dbaa94cfe4b5e72743bdb9560719a251d439f4cee"
+
dependencies = [
+
 "bitflags 2.6.0",
+
 "bstr",
+
 "gix-path",
+
 "libc",
+
 "thiserror 2.0.7",
+
]
+

+
[[package]]
+
name = "gix-credentials"
+
version = "0.24.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8ce391d305968782f1ae301c4a3d42c5701df7ff1d8bc03740300f6fd12bce78"
+
dependencies = [
+
 "bstr",
+
 "gix-command",
+
 "gix-config-value",
+
 "gix-path",
+
 "gix-prompt",
+
 "gix-sec",
+
 "gix-trace",
+
 "gix-url",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
+
name = "gix-date"
+
version = "0.8.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9eed6931f21491ee0aeb922751bd7ec97b4b2fe8fbfedcb678e2a2dce5f3b8c0"
+
dependencies = [
+
 "bstr",
+
 "itoa 1.0.14",
+
 "thiserror 1.0.69",
+
 "time",
+
]
+

+
[[package]]
+
name = "gix-date"
+
version = "0.9.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c57c477b645ee248b173bb1176b52dd528872f12c50375801a58aaf5ae91113f"
+
dependencies = [
+
 "bstr",
+
 "itoa 1.0.14",
+
 "jiff",
+
 "thiserror 2.0.7",
+
]
+

+
[[package]]
+
name = "gix-diff"
+
version = "0.44.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1996d5c8a305b59709467d80617c9fde48d9d75fd1f4179ea970912630886c9d"
+
dependencies = [
+
 "bstr",
+
 "gix-hash",
+
 "gix-object",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
+
name = "gix-features"
+
version = "0.38.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ac7045ac9fe5f9c727f38799d002a7ed3583cd777e3322a7c4b43e3cf437dc69"
+
dependencies = [
+
 "crc32fast",
+
 "flate2",
+
 "gix-hash",
+
 "gix-trace",
+
 "gix-utils",
+
 "libc",
+
 "prodash",
+
 "sha1_smol",
+
 "thiserror 1.0.69",
+
 "walkdir",
+
]
+

+
[[package]]
+
name = "gix-fs"
+
version = "0.11.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f2bfe6249cfea6d0c0e0990d5226a4cb36f030444ba9e35e0639275db8f98575"
+
dependencies = [
+
 "fastrand",
+
 "gix-features",
+
 "gix-utils",
+
]
+

+
[[package]]
+
name = "gix-hash"
+
version = "0.14.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f93d7df7366121b5018f947a04d37f034717e113dcf9ccd85c34b58e57a74d5e"
+
dependencies = [
+
 "faster-hex",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
+
name = "gix-hashtable"
+
version = "0.5.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7ddf80e16f3c19ac06ce415a38b8591993d3f73aede049cb561becb5b3a8e242"
+
dependencies = [
+
 "gix-hash",
+
 "hashbrown 0.14.5",
+
 "parking_lot",
+
]
+

+
[[package]]
+
name = "gix-object"
+
version = "0.42.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "25da2f46b4e7c2fa7b413ce4dffb87f69eaf89c2057e386491f4c55cadbfe386"
+
dependencies = [
+
 "bstr",
+
 "gix-actor",
+
 "gix-date 0.8.7",
+
 "gix-features",
+
 "gix-hash",
+
 "gix-utils",
+
 "gix-validate",
+
 "itoa 1.0.14",
+
 "smallvec",
+
 "thiserror 1.0.69",
+
 "winnow 0.6.20",
+
]
+

+
[[package]]
+
name = "gix-odb"
+
version = "0.61.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "20d384fe541d93d8a3bb7d5d5ef210780d6df4f50c4e684ccba32665a5e3bc9b"
+
dependencies = [
+
 "arc-swap",
+
 "gix-date 0.8.7",
+
 "gix-features",
+
 "gix-fs",
+
 "gix-hash",
+
 "gix-object",
+
 "gix-pack",
+
 "gix-path",
+
 "gix-quote",
+
 "parking_lot",
+
 "tempfile",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
+
name = "gix-pack"
+
version = "0.51.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3e0594491fffe55df94ba1c111a6566b7f56b3f8d2e1efc750e77d572f5f5229"
+
dependencies = [
+
 "gix-chunk",
+
 "gix-diff",
+
 "gix-features",
+
 "gix-hash",
+
 "gix-hashtable",
+
 "gix-object",
+
 "gix-path",
+
 "gix-tempfile",
+
 "gix-traverse",
+
 "memmap2",
+
 "parking_lot",
+
 "smallvec",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
+
name = "gix-packetline"
+
version = "0.17.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8c43ef4d5fe2fa222c606731c8bdbf4481413ee4ef46d61340ec39e4df4c5e49"
+
dependencies = [
+
 "bstr",
+
 "faster-hex",
+
 "gix-trace",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
+
name = "gix-path"
+
version = "0.10.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c40f12bb65a8299be0cfb90fe718e3be236b7a94b434877012980863a883a99f"
+
dependencies = [
+
 "bstr",
+
 "gix-trace",
+
 "home",
+
 "once_cell",
+
 "thiserror 2.0.7",
+
]
+

+
[[package]]
+
name = "gix-prompt"
+
version = "0.8.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7a7822afc4bc9c5fbbc6ce80b00f41c129306b7685cac3248dbfa14784960594"
+
dependencies = [
+
 "gix-command",
+
 "gix-config-value",
+
 "parking_lot",
+
 "rustix",
+
 "thiserror 2.0.7",
+
]
+

+
[[package]]
+
name = "gix-protocol"
+
version = "0.45.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cc43a1006f01b5efee22a003928c9eb83dde2f52779ded9d4c0732ad93164e3e"
+
dependencies = [
+
 "bstr",
+
 "gix-credentials",
+
 "gix-date 0.9.3",
+
 "gix-features",
+
 "gix-hash",
+
 "gix-transport",
+
 "gix-utils",
+
 "maybe-async",
+
 "thiserror 1.0.69",
+
 "winnow 0.6.20",
+
]
+

+
[[package]]
+
name = "gix-quote"
+
version = "0.4.15"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e49357fccdb0c85c0d3a3292a9f6db32d9b3535959b5471bb9624908f4a066c6"
+
dependencies = [
+
 "bstr",
+
 "gix-utils",
+
 "thiserror 2.0.7",
+
]
+

+
[[package]]
+
name = "gix-revwalk"
+
version = "0.13.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1b030ccaab71af141f537e0225f19b9e74f25fefdba0372246b844491cab43e0"
+
dependencies = [
+
 "gix-commitgraph",
+
 "gix-date 0.8.7",
+
 "gix-hash",
+
 "gix-hashtable",
+
 "gix-object",
+
 "smallvec",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
+
name = "gix-sec"
+
version = "0.10.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d84dae13271f4313f8d60a166bf27e54c968c7c33e2ffd31c48cafe5da649875"
+
dependencies = [
+
 "bitflags 2.6.0",
+
 "gix-path",
+
 "libc",
+
 "windows-sys 0.52.0",
+
]
+

+
[[package]]
+
name = "gix-tempfile"
+
version = "14.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "046b4927969fa816a150a0cda2e62c80016fe11fb3c3184e4dddf4e542f108aa"
+
dependencies = [
+
 "gix-fs",
+
 "libc",
+
 "once_cell",
+
 "parking_lot",
+
 "tempfile",
+
]
+

+
[[package]]
+
name = "gix-trace"
+
version = "0.1.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7c396a2036920c69695f760a65e7f2677267ccf483f25046977d87e4cb2665f7"
+

+
[[package]]
+
name = "gix-transport"
+
version = "0.42.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "421dcccab01b41a15d97b226ad97a8f9262295044e34fbd37b10e493b0a6481f"
+
dependencies = [
+
 "bstr",
+
 "gix-command",
+
 "gix-features",
+
 "gix-packetline",
+
 "gix-quote",
+
 "gix-sec",
+
 "gix-url",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
+
name = "gix-traverse"
+
version = "0.39.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e499a18c511e71cf4a20413b743b9f5bcf64b3d9e81e9c3c6cd399eae55a8840"
+
dependencies = [
+
 "bitflags 2.6.0",
+
 "gix-commitgraph",
+
 "gix-date 0.8.7",
+
 "gix-hash",
+
 "gix-hashtable",
+
 "gix-object",
+
 "gix-revwalk",
+
 "smallvec",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
+
name = "gix-url"
+
version = "0.27.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fd280c5e84fb22e128ed2a053a0daeacb6379469be6a85e3d518a0636e160c89"
+
dependencies = [
+
 "bstr",
+
 "gix-features",
+
 "gix-path",
+
 "home",
+
 "thiserror 1.0.69",
+
 "url",
+
]
+

+
[[package]]
+
name = "gix-utils"
+
version = "0.1.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ff08f24e03ac8916c478c8419d7d3c33393da9bb41fa4c24455d5406aeefd35f"
+
dependencies = [
+
 "fastrand",
+
 "unicode-normalization",
+
]
+

+
[[package]]
+
name = "gix-validate"
+
version = "0.8.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "82c27dd34a49b1addf193c92070bcbf3beaf6e10f16a78544de6372e146a0acf"
+
dependencies = [
+
 "bstr",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
name = "glib"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2242,6 +2719,15 @@ dependencies = [
]

[[package]]
+
name = "home"
+
version = "0.5.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf"
+
dependencies = [
+
 "windows-sys 0.59.0",
+
]
+

+
[[package]]
name = "hstr"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2611,6 +3097,18 @@ dependencies = [
]

[[package]]
+
name = "io-reactor"
+
version = "0.5.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "77d78c3e630f04a61ec86ba171c0bbd161434a7f2e8e4a67728320d4ce7c6c79"
+
dependencies = [
+
 "amplify",
+
 "crossbeam-channel",
+
 "libc",
+
 "popol",
+
]
+

+
[[package]]
name = "ipnet"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2683,6 +3181,35 @@ dependencies = [
]

[[package]]
+
name = "jiff"
+
version = "0.1.29"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c04ef77ae73f3cf50510712722f0c4e8b46f5aaa1bf5ffad2ae213e6495e78e5"
+
dependencies = [
+
 "jiff-tzdb-platform",
+
 "log",
+
 "portable-atomic",
+
 "portable-atomic-util",
+
 "serde",
+
 "windows-sys 0.59.0",
+
]
+

+
[[package]]
+
name = "jiff-tzdb"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cf2cec2f5d266af45a071ece48b1fb89f3b00b2421ac3a5fe10285a6caaa60d3"
+

+
[[package]]
+
name = "jiff-tzdb-platform"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a63c62e404e7b92979d2792352d885a7f8f83fd1d0d31eea582d77b2ceca697e"
+
dependencies = [
+
 "jiff-tzdb",
+
]
+

+
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2972,12 +3499,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"

[[package]]
+
name = "maybe-async"
+
version = "0.2.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.90",
+
]
+

+
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"

[[package]]
+
name = "memmap2"
+
version = "0.9.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3085,6 +3632,20 @@ dependencies = [
]

[[package]]
+
name = "netservices"
+
version = "0.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d583eef3f42156adfd95d87872a6006d88ed9fa7e6d74583b784d84afda4a0da"
+
dependencies = [
+
 "amplify",
+
 "cyphernet",
+
 "io-reactor",
+
 "libc",
+
 "rand 0.8.5",
+
 "socket2",
+
]
+

+
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3110,6 +3671,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"

[[package]]
+
name = "noise-framework"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b57e96e713d599dc58755d0e5bb2238908a63e13f624f70c8345fdb7d8b51bae"
+
dependencies = [
+
 "amplify",
+
 "chacha20poly1305",
+
 "cyphergraphy",
+
]
+

+
[[package]]
name = "nonempty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3603,6 +4175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
 "digest",
+
 "hmac",
]

[[package]]
@@ -3843,6 +4416,30 @@ dependencies = [
]

[[package]]
+
name = "popol"
+
version = "3.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "eaac4c1cff8bea6334f5c2646ca0f007648dc14d0471cf3e522cd34900126568"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
+
name = "portable-atomic"
+
version = "1.11.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
+

+
[[package]]
+
name = "portable-atomic-util"
+
version = "0.2.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
+
dependencies = [
+
 "portable-atomic",
+
]
+

+
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3940,6 +4537,12 @@ dependencies = [
]

[[package]]
+
name = "prodash"
+
version = "28.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "744a264d26b88a6a7e37cbad97953fa233b94d585236310bcbc88474b4092d79"
+

+
[[package]]
name = "psm"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4063,6 +4666,8 @@ source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7
dependencies = [
 "amplify",
 "base64 0.21.7",
+
 "chrono",
+
 "colored",
 "crossbeam-channel",
 "cyphernet",
 "fastrand",
@@ -4134,6 +4739,27 @@ dependencies = [
]

[[package]]
+
name = "radicle-fetch"
+
version = "0.10.0"
+
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
+
dependencies = [
+
 "bstr",
+
 "either",
+
 "gix-actor",
+
 "gix-features",
+
 "gix-hash",
+
 "gix-odb",
+
 "gix-pack",
+
 "gix-protocol",
+
 "gix-transport",
+
 "log",
+
 "nonempty",
+
 "radicle",
+
 "radicle-git-ext",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
name = "radicle-git-ext"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4148,6 +4774,51 @@ dependencies = [
]

[[package]]
+
name = "radicle-node"
+
version = "0.10.0"
+
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
+
dependencies = [
+
 "amplify",
+
 "anyhow",
+
 "bloomy",
+
 "byteorder",
+
 "chrono",
+
 "colored",
+
 "crossbeam-channel",
+
 "cyphernet",
+
 "fastrand",
+
 "io-reactor",
+
 "lexopt",
+
 "libc",
+
 "localtime",
+
 "log",
+
 "netservices",
+
 "nonempty",
+
 "once_cell",
+
 "radicle",
+
 "radicle-fetch",
+
 "radicle-git-ext",
+
 "radicle-signals",
+
 "radicle-systemd",
+
 "scrypt",
+
 "serde",
+
 "serde_json",
+
 "socket2",
+
 "sqlite",
+
 "tempfile",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
+
name = "radicle-signals"
+
version = "0.10.0"
+
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
+
dependencies = [
+
 "crossbeam-channel",
+
 "libc",
+
]
+

+
[[package]]
name = "radicle-ssh"
version = "0.9.0"
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
@@ -4185,17 +4856,26 @@ dependencies = [
]

[[package]]
+
name = "radicle-systemd"
+
version = "0.9.0"
+
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
+

+
[[package]]
name = "radicle-tauri"
version = "0.0.0"
dependencies = [
 "anyhow",
 "base64 0.22.1",
+
 "crossbeam-channel",
 "log",
 "radicle",
+
 "radicle-node",
+
 "radicle-signals",
 "radicle-surf",
 "radicle-types",
 "serde",
 "serde_json",
+
 "ssh-key",
 "tauri",
 "tauri-build",
 "tauri-plugin-clipboard-manager",
@@ -4206,6 +4886,7 @@ dependencies = [
 "thiserror 1.0.69",
 "tokio",
 "ts-rs",
+
 "zeroize",
]

[[package]]
@@ -4220,10 +4901,15 @@ dependencies = [
 "log",
 "mime-infer",
 "radicle",
+
 "radicle-node",
 "radicle-surf",
 "serde",
 "serde_json",
 "sqlite",
+
 "ssh-key",
+
 "strum",
+
 "tauri-plugin-clipboard-manager",
+
 "tauri-plugin-fs",
 "tempfile",
 "thiserror 1.0.69",
 "tree-sitter-bash",
@@ -4661,6 +5347,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"

[[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"
@@ -4709,6 +5404,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"

[[package]]
+
name = "scrypt"
+
version = "0.11.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
+
dependencies = [
+
 "pbkdf2",
+
 "salsa20",
+
 "sha2",
+
]
+

+
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4917,6 +5623,12 @@ dependencies = [
]

[[package]]
+
name = "sha1_smol"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+

+
[[package]]
name = "sha2"
version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4948,6 +5660,12 @@ dependencies = [
]

[[package]]
+
name = "shell-words"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
+

+
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5267,6 +5985,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"

[[package]]
+
name = "strum"
+
version = "0.27.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32"
+
dependencies = [
+
 "strum_macros",
+
]
+

+
[[package]]
+
name = "strum_macros"
+
version = "0.27.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8"
+
dependencies = [
+
 "heck 0.5.0",
+
 "proc-macro2",
+
 "quote",
+
 "rustversion",
+
 "syn 2.0.90",
+
]
+

+
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5517,9 +6257,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"

[[package]]
name = "tar"
-
version = "0.4.43"
+
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c65998313f8e17d0d553d28f91a0df93e4dbbbf770279c7bc21ca0f09ea1a1f6"
+
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
 "filetime",
 "libc",
@@ -5875,12 +6615,13 @@ dependencies = [

[[package]]
name = "tempfile"
-
version = "3.14.0"
+
version = "3.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c"
+
checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230"
dependencies = [
 "cfg-if",
 "fastrand",
+
 "getrandom 0.3.1",
 "once_cell",
 "rustix",
 "windows-sys 0.59.0",
@@ -6757,6 +7498,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"

[[package]]
+
name = "wasi"
+
version = "0.13.3+wasi-0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2"
+
dependencies = [
+
 "wit-bindgen-rt",
+
]
+

+
[[package]]
name = "wasm-bindgen"
version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7392,6 +8142,15 @@ dependencies = [
]

[[package]]
+
name = "wit-bindgen-rt"
+
version = "0.33.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
+
dependencies = [
+
 "bitflags 2.6.0",
+
]
+

+
[[package]]
name = "write16"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7642,6 +8401,9 @@ name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
+
dependencies = [
+
 "serde",
+
]

[[package]]
name = "zerovec"
modified crates/radicle-tauri/Cargo.toml
@@ -17,10 +17,13 @@ tauri-build = { version = "2.0.1", features = ["isolation"] }
[dependencies]
anyhow = { version = "1.0.90" }
base64 = { version = "0.22.1" }
+
crossbeam-channel = { version = "0.5.14" }
log = { version = "0.4.22" }
radicle = { git = "https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", package = "radicle", rev = "7c902b6905724345ba850eb6cca8f8becc9a9c72" }
-
radicle-types = { version = "0.1.0", path = "../radicle-types" }
+
radicle-node = { git = "https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", package = "radicle-node", rev = "7c902b6905724345ba850eb6cca8f8becc9a9c72" }
+
radicle-signals = { git = "https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", package = "radicle-signals", rev = "7c902b6905724345ba850eb6cca8f8becc9a9c72" }
radicle-surf = { version = "0.22.1", features = ["serde"] }
+
radicle-types = { version = "0.1.0", path = "../radicle-types" }
serde = { version = "1.0.210", features = ["derive"] }
serde_json = { version = "1.0.132" }
tauri = { version = "2.2.5", features = ["isolation"] }
@@ -32,6 +35,8 @@ tauri-plugin-window-state = { version = "2.2.1" }
thiserror = { version = "1.0.64" }
tokio = { version = "1.40.0", features = ["time"] }
ts-rs = { version = "10.0.0", features = ["serde-json-impl", "no-serde-warnings"] }
+
ssh-key = { version = "0.6.3" }
+
zeroize = { version = "1.8.1", features = ["serde"] }

[features]
# by default Tauri runs in production mode
modified crates/radicle-tauri/src/commands.rs
@@ -2,7 +2,8 @@ pub mod auth;
pub mod cob;
pub mod diff;
pub mod inbox;
-
pub mod init;
+
pub mod node;
pub mod profile;
pub mod repo;
+
pub mod startup;
pub mod thread;
modified crates/radicle-tauri/src/commands/auth.rs
@@ -1,9 +1,84 @@
+
use std::str::FromStr;
+

+
use radicle::crypto::ssh::{self, Passphrase};
+
use radicle::node::Alias;
+
use radicle::profile::env;
+
use radicle_types::error::node::NodeError;
use radicle_types::error::Error;
-
use radicle_types::traits::auth::Auth;

use crate::AppState;

#[tauri::command]
-
pub(crate) fn authenticate(ctx: tauri::State<AppState>) -> Result<(), Error> {
-
    ctx.authenticate().map_err(Error::from)
+
pub fn authenticate(
+
    ctx: tauri::State<AppState>,
+
    passphrase: Option<Passphrase>,
+
) -> Result<(), Error> {
+
    let profile = &ctx.profile;
+
    if !profile.keystore.is_encrypted()? {
+
        return Ok(());
+
    }
+
    match ssh::agent::Agent::connect() {
+
        Ok(mut agent) => {
+
            if agent.request_identities()?.contains(&profile.public_key) {
+
                return Ok(());
+
            }
+

+
            if let Some(pass) = passphrase {
+
                profile.keystore.secret_key(Some(pass.clone()))?;
+
                register(&mut agent, profile, pass)?;
+
            }
+

+
            Ok(())
+
        }
+
        Err(e) if e.is_not_running() => Err(Error::NodeControlError(NodeError::AgentNotRunning))?,
+
        Err(e) => Err(e)?,
+
    }
+
}
+

+
#[tauri::command]
+
pub(crate) fn init(alias: String, passphrase: Passphrase) -> Result<(), Error> {
+
    let home = radicle::profile::home()?;
+
    let alias = Alias::from_str(&alias)?;
+

+
    if passphrase.is_empty() {
+
        return Err(Error::Crypto(
+
            radicle::crypto::ssh::keystore::Error::PassphraseMissing,
+
        ));
+
    }
+
    let profile = radicle::Profile::init(home, alias, Some(passphrase.clone()), env::seed())?;
+
    match ssh::agent::Agent::connect() {
+
        Ok(mut agent) => register(&mut agent, &profile, passphrase.clone())?,
+
        Err(e) if e.is_not_running() => {
+
            return Err(Error::NodeControlError(NodeError::AgentNotRunning))
+
        }
+
        Err(e) => Err(e)?,
+
    }
+

+
    Ok(())
+
}
+

+
pub fn register(
+
    agent: &mut ssh::agent::Agent,
+
    profile: &radicle::Profile,
+
    passphrase: ssh::Passphrase,
+
) -> Result<(), Error> {
+
    let secret = profile
+
        .keystore
+
        .secret_key(Some(passphrase))
+
        .map_err(|e| {
+
            if e.is_crypto_err() {
+
                Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(
+
                    ssh_key::Error::Crypto,
+
                ))
+
            } else {
+
                e.into()
+
            }
+
        })?
+
        .ok_or(Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(
+
            ssh_key::Error::Crypto,
+
        )))?;
+

+
    agent.register(&secret)?;
+

+
    Ok(())
}
modified crates/radicle-tauri/src/commands/cob.rs
@@ -1,7 +1,5 @@
use std::path::PathBuf;

-
use anyhow::{Context, Result};
-

use radicle::git;
use radicle::identity;
use radicle_types as types;
@@ -44,8 +42,7 @@ pub async fn save_embed_by_clipboard(
    let content = app_handle
        .clipboard()
        .read_image()
-
        .map(|i| i.rgba().to_vec())
-
        .context("Not able to read the image from the clipboard")?;
+
        .map(|i| i.rgba().to_vec())?;

    ctx.save_embed_by_bytes(rid, name, content)
}
@@ -68,16 +65,15 @@ pub async fn save_embed_to_disk(
    oid: git::Oid,
    name: String,
) -> Result<(), Error> {
-
    let path = app_handle
+
    let Some(path) = app_handle
        .dialog()
        .file()
        .set_file_name(name)
        .blocking_save_file()
-
        .context("no path defined")?;
-

-
    let path = path
-
        .into_path()
-
        .context("Not able to convert into PathBuf")?;
+
    else {
+
        return Err(Error::SaveEmbedError);
+
    };
+
    let path = path.into_path()?;

    ctx.save_embed_to_disk(rid, oid, path)
}
deleted crates/radicle-tauri/src/commands/init.rs
@@ -1,114 +0,0 @@
-
use std::collections::BTreeMap;
-

-
use radicle::cob::cache::COBS_DB_FILE;
-
use radicle::identity::RepoId;
-
use radicle::node::{Handle, Node, NOTIFICATIONS_DB_FILE};
-
use radicle::storage::ReadStorage;
-
use tauri::{AppHandle, Emitter, Manager};
-

-
use radicle_types::config::Config;
-
use radicle_types::error::Error;
-
use radicle_types::traits::Profile;
-
use radicle_types::{domain, AppState};
-

-
#[tauri::command]
-
pub(crate) fn startup(app: AppHandle) -> Result<Config, Error> {
-
    let profile = radicle::Profile::load()?;
-

-
    let inbox_db = radicle_types::outbound::sqlite::Sqlite::reader(
-
        profile.node().join(NOTIFICATIONS_DB_FILE),
-
    )?;
-
    let cob_db =
-
        radicle_types::outbound::sqlite::Sqlite::reader(profile.cobs().join(COBS_DB_FILE))?;
-

-
    let inbox_service = domain::inbox::service::Service::new(inbox_db);
-
    let patch_service = domain::patch::service::Service::new(cob_db);
-

-
    app.manage(inbox_service);
-
    app.manage(patch_service);
-

-
    let state = AppState { profile };
-
    app.manage(state.clone());
-

-
    Ok(state.config())
-
}
-

-
#[tauri::command]
-
pub(crate) fn node_status_events(app: AppHandle, ctx: tauri::State<AppState>) -> Result<(), Error> {
-
    let app_handle = app.clone();
-

-
    let node = Node::new(ctx.profile.socket());
-
    let node_status = node.clone();
-

-
    tauri::async_runtime::spawn(async move {
-
        loop {
-
            let _ = app_handle.emit("node_running", node_status.is_running());
-
            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
-
        }
-
    });
-

-
    Ok(())
-
}
-

-
#[tauri::command]
-
pub(crate) fn repo_sync_events(app: AppHandle, ctx: tauri::State<AppState>) -> Result<(), Error> {
-
    let profile = &ctx.profile;
-
    let repositories = profile.storage.repositories()?;
-

-
    let app_handle = app.clone();
-
    let public_key = profile.public_key;
-

-
    let node = Node::new(profile.socket());
-
    let mut node_seeds = node.clone();
-

-
    tauri::async_runtime::spawn(async move {
-
        loop {
-
            let mut sync_status =
-
                BTreeMap::<RepoId, Option<radicle_types::cobs::repo::SyncStatus>>::new();
-
            for repo in &repositories {
-
                if let Ok(seeds) = node_seeds.seeds(repo.rid).map(Into::<Vec<_>>::into) {
-
                    if let Some(status) =
-
                        seeds
-
                            .iter()
-
                            .find_map(|radicle::node::Seed { nid, sync, .. }| {
-
                                (*nid == public_key).then_some(sync.clone())
-
                            })
-
                    {
-
                        sync_status.insert(repo.rid, status.map(Into::into));
-
                    } else {
-
                        // The local node wasn't found in the seed nodes table.
-
                        sync_status.insert(repo.rid, None);
-
                    }
-
                }
-
            }
-
            let _ = app_handle.emit("sync_status", sync_status);
-
            tokio::time::sleep(std::time::Duration::from_secs(10)).await;
-
        }
-
    });
-

-
    Ok(())
-
}
-

-
#[tauri::command]
-
pub(crate) fn node_events(app: AppHandle, ctx: tauri::State<AppState>) -> Result<(), Error> {
-
    let app_handle = app.clone();
-
    let node = Node::new(ctx.profile.socket());
-

-
    tauri::async_runtime::spawn(async move {
-
        loop {
-
            if node.is_running() {
-
                log::debug!("node: spawned node event subscription.");
-
                while let Ok(events) = node.subscribe(std::time::Duration::MAX) {
-
                    for event in events.into_iter().flatten() {
-
                        let _ = app_handle.emit("event", event);
-
                    }
-
                }
-
                log::debug!("node: event subscription loop has exited.");
-
            }
-

-
            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
-
        }
-
    });
-

-
    Ok(())
-
}
added crates/radicle-tauri/src/commands/node.rs
@@ -0,0 +1,117 @@
+
use std::time;
+

+
use crossbeam_channel as chan;
+
use radicle::crypto::ssh::keystore::MemorySigner;
+
use radicle::identity::{Did, RepoId};
+
use radicle::node::{Address, ConnectOptions, ConnectResult, Handle};
+
use radicle::Node;
+
use radicle_node::Runtime;
+
use radicle_types::cobs::repo::Seed;
+
use radicle_types::error::node::NodeError;
+
use radicle_types::error::Error;
+

+
use crate::AppState;
+

+
/// How long to wait for the node to fetch a repo from the network.
+
pub const NODE_FETCH_TIMEOUT: time::Duration = time::Duration::from_secs(9);
+
/// How long to wait for the node to respond to a command.
+
pub const NODE_COMMAND_TIMEOUT: time::Duration = time::Duration::from_secs(9);
+

+
#[tauri::command]
+
pub(crate) fn stop_node(ctx: tauri::State<'_, AppState>) -> Result<(), Error> {
+
    let node = Node::new(ctx.profile.socket());
+
    node.shutdown()?;
+

+
    Ok(())
+
}
+

+
#[tauri::command]
+
pub(crate) async fn connect_node(
+
    ctx: tauri::State<'_, AppState>,
+
    from: Did,
+
    address: Address,
+
) -> Result<(), Error> {
+
    let mut node = Node::new(ctx.profile.socket());
+
    match node.connect(
+
        from.into(),
+
        address,
+
        ConnectOptions {
+
            persistent: true,
+
            timeout: NODE_COMMAND_TIMEOUT,
+
        },
+
    ) {
+
        Ok(ConnectResult::Connected) => Ok(()),
+
        Ok(ConnectResult::Disconnected { reason }) => {
+
            Err(Error::NodeControlError(NodeError::NodeConnect(reason)))
+
        }
+
        Err(e) => Err(e.into()),
+
    }
+
}
+

+
#[tauri::command]
+
pub(crate) async fn start_node(
+
    ctx: tauri::State<'_, AppState>,
+
    passphrase: String,
+
) -> Result<(), Error> {
+
    let profile = ctx.profile.clone();
+
    let node = Node::new(profile.socket());
+
    if node.is_running() {
+
        log::info!(target: "node", "Node is already running.");
+
        return Ok(());
+
    }
+

+
    log::info!(target: "node", "Starting node..");
+

+
    // Remove control socket if still around.
+
    std::fs::remove_file(profile.socket()).ok();
+

+
    log::info!(target: "node", "Unlocking node keystore..");
+
    let signer = radicle::crypto::ssh::keystore::MemorySigner::load(
+
        &ctx.profile.keystore,
+
        Some(passphrase.into()),
+
    )?;
+

+
    let mut config = radicle::profile::Config::load(&profile.home.config())?;
+
    config.node.connect.extend(config.preferred_seeds);
+

+
    if let Err(e) = radicle::io::set_file_limit(config.node.limits.max_open_files) {
+
        log::warn!(target: "node", "Unable to set process open file limit: {e}");
+
    }
+

+
    let (notify, signals) = chan::bounded(1);
+
    if let Err(e) = radicle_signals::install(notify) {
+
        log::warn!(target: "node", "Unable to install signal handlers: {e}");
+
    }
+

+
    Runtime::init::<MemorySigner>(
+
        profile.home,
+
        config.node.clone(),
+
        config.node.listen,
+
        signals,
+
        signer,
+
    )?
+
    .run()
+
    .map_err(Into::into)
+
}
+

+
/// Returns a tuple with [connected, disconnected] seeds
+
#[tauri::command]
+
pub(crate) async fn node_seeds(
+
    ctx: tauri::State<'_, AppState>,
+
    rid: RepoId,
+
) -> Result<(Vec<Seed>, Vec<Seed>), Error> {
+
    let profile = &ctx.profile;
+
    let mut node = Node::new(profile.socket());
+

+
    if !node.is_running() {
+
        return Err(Error::NodeControlError(NodeError::NodeNotRunning));
+
    }
+

+
    let seeds = node.seeds(rid)?;
+
    let (connected, disconnected) = seeds.partition();
+

+
    Ok((
+
        connected.into_iter().map(Into::into).collect(),
+
        disconnected.into_iter().map(Into::into).collect(),
+
    ))
+
}
modified crates/radicle-tauri/src/commands/repo.rs
@@ -1,12 +1,24 @@
-
use radicle::git;
-
use radicle::identity::RepoId;
+
use std::collections::{BTreeSet, VecDeque};
+
use std::str::FromStr;

+
use radicle::identity::project::ProjectName;
+
use radicle::identity::{doc, Project, RepoId, Visibility};
+
use radicle::node::{self, policy, FetchResults, Handle, NodeId, DEFAULT_TIMEOUT};
+
use radicle::prelude::Did;
+
use radicle::rad::InitError;
+
use radicle::storage::git::Repository;
+
use radicle::storage::refs::branch_of;
+
use radicle::storage::{SignRepository, WriteRepository};
+
use radicle::{git, Node};
use radicle_types as types;
+
use radicle_types::error::node::NodeError;
use radicle_types::error::Error;
use radicle_types::traits::repo::{Repo, Show};

use crate::AppState;

+
use super::node::NODE_FETCH_TIMEOUT;
+

#[tauri::command]
pub fn list_repos(
    ctx: tauri::State<AppState>,
@@ -47,3 +59,145 @@ pub async fn list_commits(
) -> Result<Vec<types::repo::Commit>, Error> {
    ctx.list_commits(rid, base, head)
}
+

+
#[tauri::command]
+
pub(crate) async fn create_repo(
+
    ctx: tauri::State<'_, AppState>,
+
    name: String,
+
    description: String,
+
    default_branch: git::RefString,
+
) -> Result<(), Error> {
+
    let profile = &ctx.profile;
+
    let storage = &profile.storage;
+
    let signer = ctx.profile.signer()?;
+

+
    let name = ProjectName::from_str(&name)?;
+
    if description.len() > doc::MAX_STRING_LENGTH {
+
        return Err(Error::ProjectError(
+
            radicle::identity::project::ProjectError::Description("Cannot exceed 255 characters."),
+
        ));
+
    }
+

+
    let visibility = Visibility::Private {
+
        allow: BTreeSet::default(),
+
    };
+

+
    let proj = Project::new(name, description, default_branch.clone()).map_err(|errs| {
+
        InitError::ProjectPayload(
+
            errs.into_iter()
+
                .map(|err| err.to_string())
+
                .collect::<Vec<_>>()
+
                .join(", "),
+
        )
+
    })?;
+
    let doc = radicle::identity::Doc::initial(proj, profile.public_key.into(), visibility);
+
    let (project, identity) = Repository::init(&doc, &storage, &signer)?;
+

+
    let tree_id = {
+
        let mut index = project.backend.index()?;
+

+
        index.write_tree()
+
    }?;
+
    let sig = project.backend.signature()?;
+
    let tree = project.backend.find_tree(tree_id)?;
+

+
    project.set_remote_identity_root_to(signer.public_key(), identity)?;
+
    project.set_identity_head_to(identity)?;
+

+
    let base = project
+
        .backend
+
        .commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])?;
+

+
    let ns_head = branch_of(&ctx.profile.public_key, &default_branch);
+
    project
+
        .backend
+
        .reference(ns_head.as_str(), base, false, "Created namespace ref")?;
+

+
    project.set_head()?;
+
    project.sign_refs(&signer)?;
+

+
    Ok(())
+
}
+

+
#[tauri::command]
+
pub(crate) async fn fetch_repo(
+
    ctx: tauri::State<'_, AppState>,
+
    rid: RepoId,
+
    from: Did,
+
) -> Result<(), Error> {
+
    let profile = &ctx.profile;
+
    let replicas = 3usize;
+
    let mut results = FetchResults::default();
+
    let mut node = Node::new(profile.socket());
+

+
    if !node.is_running() {
+
        return Err(Error::NodeControlError(NodeError::NodeNotRunning));
+
    }
+

+
    node.seed(rid, policy::Scope::All)?;
+

+
    let seeds = node.seeds(rid)?;
+
    let (connected, mut disconnected) = seeds.partition();
+

+
    let mut connected = connected
+
        .into_iter()
+
        .map(|c| c.nid)
+
        .take(replicas)
+
        .collect::<VecDeque<_>>();
+
    while results.success().count() < replicas {
+
        let Some(nid) = connected.pop_front() else {
+
            break;
+
        };
+
        let result = node.fetch(rid, *from, DEFAULT_TIMEOUT)?;
+
        results.push(nid, result);
+
    }
+

+
    // Try to connect to disconnected seeds and fetch from them.
+
    while results.success().count() < replicas {
+
        let Some(seed) = disconnected.pop() else {
+
            break;
+
        };
+
        if seed.nid == profile.public_key {
+
            // Skip our own node.
+
            continue;
+
        }
+
        if connect(
+
            seed.nid,
+
            seed.addrs.into_iter().map(|ka| ka.addr),
+
            &mut node,
+
        ) {
+
            let result = node.fetch(rid, seed.nid, DEFAULT_TIMEOUT)?;
+
            results.push(seed.nid, result);
+
        }
+
    }
+

+
    node.add_inventory(rid)?;
+

+
    Ok(())
+
}
+

+
fn connect(nid: NodeId, addrs: impl Iterator<Item = node::Address>, node: &mut Node) -> bool {
+
    for addr in addrs {
+
        let cr = node.connect(
+
            nid,
+
            addr,
+
            node::ConnectOptions {
+
                persistent: false,
+
                timeout: NODE_FETCH_TIMEOUT,
+
            },
+
        );
+
        match cr {
+
            Ok(node::ConnectResult::Connected) => {
+
                return true;
+
            }
+
            Ok(node::ConnectResult::Disconnected { .. }) => {
+
                continue;
+
            }
+
            Err(_) => {
+
                continue;
+
            }
+
        }
+
    }
+

+
    false
+
}
added crates/radicle-tauri/src/commands/startup.rs
@@ -0,0 +1,93 @@
+
use std::collections::BTreeMap;
+

+
use radicle::cob::cache::COBS_DB_FILE;
+
use radicle::identity::RepoId;
+
use radicle::node::{Handle, Node, NOTIFICATIONS_DB_FILE};
+
use radicle::storage::ReadStorage;
+
use tauri::{AppHandle, Emitter, Manager};
+

+
use radicle_types::config::Config;
+
use radicle_types::error::Error;
+
use radicle_types::traits::Profile;
+
use radicle_types::{domain, AppState};
+

+
#[tauri::command]
+
pub(crate) fn startup(app: AppHandle) -> Result<Config, Error> {
+
    let profile = radicle::Profile::load()?;
+
    let repositories = profile.storage.repositories()?;
+
    let public_key = profile.public_key;
+

+
    let inbox_db = radicle_types::outbound::sqlite::Sqlite::reader(
+
        profile.node().join(NOTIFICATIONS_DB_FILE),
+
    )?;
+
    let cob_db =
+
        radicle_types::outbound::sqlite::Sqlite::reader(profile.cobs().join(COBS_DB_FILE))?;
+

+
    let inbox_service = domain::inbox::service::Service::new(inbox_db);
+
    let patch_service = domain::patch::service::Service::new(cob_db);
+

+
    let node_handle = app.app_handle().clone();
+
    let sync_handle = app.app_handle().clone();
+
    let events_handle = app.app_handle().clone();
+

+
    let node = Node::new(profile.socket());
+
    let node_status = node.clone();
+

+
    let mut node_seeds = node.clone();
+

+
    app.manage(inbox_service);
+
    app.manage(patch_service);
+

+
    tauri::async_runtime::spawn(async move {
+
        loop {
+
            let _ = node_handle.emit("node_running", node_status.is_running());
+
            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
+
        }
+
    });
+

+
    tauri::async_runtime::spawn(async move {
+
        loop {
+
            let mut sync_status =
+
                BTreeMap::<RepoId, Option<radicle_types::cobs::repo::SyncStatus>>::new();
+
            for repo in &repositories {
+
                if let Ok(seeds) = node_seeds.seeds(repo.rid).map(Into::<Vec<_>>::into) {
+
                    if let Some(status) =
+
                        seeds
+
                            .iter()
+
                            .find_map(|radicle::node::Seed { nid, sync, .. }| {
+
                                (*nid == public_key).then_some(sync.clone())
+
                            })
+
                    {
+
                        sync_status.insert(repo.rid, status.map(Into::into));
+
                    } else {
+
                        // The local node wasn't found in the seed nodes table.
+
                        sync_status.insert(repo.rid, None);
+
                    }
+
                }
+
            }
+
            let _ = sync_handle.emit("sync_status", sync_status);
+
            tokio::time::sleep(std::time::Duration::from_secs(10)).await;
+
        }
+
    });
+

+
    tauri::async_runtime::spawn(async move {
+
        loop {
+
            if node.is_running() {
+
                log::info!("node: spawned node event subscription.");
+
                while let Ok(events) = node.subscribe(std::time::Duration::MAX) {
+
                    for event in events.into_iter().flatten() {
+
                        let _ = events_handle.emit("event", event);
+
                    }
+
                }
+
                log::info!("node: event subscription loop has exited.");
+
            }
+

+
            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
+
        }
+
    });
+

+
    let state = AppState { profile };
+
    app.manage(state.clone());
+

+
    Ok(state.config())
+
}
modified crates/radicle-tauri/src/lib.rs
@@ -2,14 +2,18 @@ mod commands;

use radicle_types::AppState;

-
use commands::{auth, cob, diff, inbox, init, profile, repo, thread};
+
use commands::{auth, cob, diff, inbox, node, profile, repo, startup, thread};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    #[cfg(debug_assertions)]
    let builder = tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
-
        .plugin(tauri_plugin_log::Builder::new().build());
+
        .plugin(
+
            tauri_plugin_log::Builder::new()
+
                .level(log::LevelFilter::Info)
+
                .build(),
+
        );
    #[cfg(not(debug_assertions))]
    let builder = tauri::Builder::default();

@@ -18,41 +22,45 @@ pub fn run() {
        .plugin(tauri_plugin_clipboard_manager::init())
        .plugin(tauri_plugin_window_state::Builder::default().build())
        .invoke_handler(tauri::generate_handler![
-
            init::startup,
-
            init::node_status_events,
-
            init::repo_sync_events,
-
            init::node_events,
            auth::authenticate,
-
            repo::repo_count,
-
            repo::list_repos,
-
            repo::repo_by_id,
-
            repo::diff_stats,
-
            repo::list_commits,
-
            diff::get_diff,
-
            inbox::list_notifications,
-
            inbox::count_notifications_by_repo,
-
            inbox::clear_notifications,
+
            auth::init,
            cob::get_embed,
-
            cob::save_embed_to_disk,
-
            cob::save_embed_by_path,
-
            cob::save_embed_by_clipboard,
-
            cob::save_embed_by_bytes,
            cob::issue::activity_by_issue,
-
            cob::issue::list_issues,
-
            cob::issue::issue_by_id,
            cob::issue::comment_threads_by_issue_id,
            cob::issue::create_issue,
            cob::issue::edit_issue,
+
            cob::issue::issue_by_id,
+
            cob::issue::list_issues,
            cob::patch::activity_by_patch,
+
            cob::patch::edit_patch,
            cob::patch::list_patches,
            cob::patch::patch_by_id,
-
            cob::patch::edit_patch,
-
            cob::patch::revisions_by_patch,
            cob::patch::revision_by_patch_and_id,
+
            cob::patch::revisions_by_patch,
+
            cob::save_embed_by_bytes,
+
            cob::save_embed_by_clipboard,
+
            cob::save_embed_by_path,
+
            cob::save_embed_to_disk,
+
            diff::get_diff,
+
            inbox::clear_notifications,
+
            inbox::count_notifications_by_repo,
+
            inbox::list_notifications,
+
            node::node_seeds,
+
            node::connect_node,
+
            node::start_node,
+
            node::stop_node,
+
            profile::alias,
+
            profile::config,
+
            repo::create_repo,
+
            repo::diff_stats,
+
            repo::fetch_repo,
+
            repo::list_commits,
+
            repo::list_repos,
+
            repo::repo_by_id,
+
            repo::repo_count,
+
            startup::startup,
            thread::create_issue_comment,
            thread::create_patch_comment,
-
            profile::config,
-
            profile::alias,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
modified crates/radicle-types/Cargo.toml
@@ -7,15 +7,20 @@ edition = "2021"
anyhow = { version = "1.0.90" }
axum = { version = "0.7.5", default-features = false, features = ["json"] }
base64 = { version = "0.22.1" }
+
infer = { version = "0.3" }
localtime = { version = "1.3.1" }
log = { version = "0.4.22" }
-
infer = { version = "0.3" }
mime-infer = { version = "3.0.0" }
radicle = { git = "https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", package = "radicle", rev = "7c902b6905724345ba850eb6cca8f8becc9a9c72", features = ["test"] }
+
radicle-node = { git = "https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", package = "radicle-node", rev = "7c902b6905724345ba850eb6cca8f8becc9a9c72" }
radicle-surf = { version = "0.22.1", features = ["serde"] }
serde = { version = "1.0.210", features = ["derive"] }
serde_json = { version = "1.0.132" }
sqlite = { version = "0.32.0", features = ["bundled"] }
+
ssh-key = { version = "0.6.3" }
+
strum = { version = "0.27.1", features = ["derive"]}
+
tauri-plugin-clipboard-manager = { version = "2.2.1" }
+
tauri-plugin-fs = { version = "2.2.0" }
tempfile = { version = "3.14.0" }
thiserror = { version = "1.0.65" }
tree-sitter-bash = { version = "0.23.3" }
added crates/radicle-types/bindings/error/ErrorWrapper.ts
@@ -0,0 +1,3 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type ErrorWrapper = { code: string; message?: string };
added crates/radicle-types/bindings/repo/Seed.ts
@@ -0,0 +1,12 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { SyncStatus } from "./SyncStatus";
+

+
/**
+
 * A seed for some repository, with metadata about its status.
+
 */
+
export type Seed = {
+
  nid: string;
+
  addrs: { addr: { host: string; port: number } }[];
+
  state?: string;
+
  sync?: SyncStatus;
+
};
modified crates/radicle-types/src/cobs/issue.rs
@@ -1,12 +1,12 @@
use std::collections::BTreeSet;

-
use radicle::node::AliasStore;
use serde::{Deserialize, Serialize};
use ts_rs::TS;

use radicle::cob;
use radicle::identity;
use radicle::issue;
+
use radicle::node::AliasStore;

use crate::cobs;

modified crates/radicle-types/src/cobs/repo.rs
@@ -1,4 +1,5 @@
use localtime::LocalTime;
+
use radicle::node::{KnownAddress, NodeId, State};

#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize, ts_rs::TS)]
#[serde(tag = "status")]
@@ -55,3 +56,32 @@ impl From<radicle::node::SyncedAt> for SyncedAt {
        }
    }
}
+

+
/// A seed for some repository, with metadata about its status.
+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub struct Seed {
+
    #[ts(as = "String")]
+
    pub nid: NodeId,
+
    #[ts(type = "{ addr: string; }[]")]
+
    pub addrs: Vec<KnownAddress>,
+
    #[ts(as = "Option<String>", optional)]
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    pub state: Option<State>,
+
    #[ts(optional)]
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    pub sync: Option<SyncStatus>,
+
}
+

+
impl From<radicle::node::Seed> for Seed {
+
    fn from(value: radicle::node::Seed) -> Self {
+
        Self {
+
            nid: value.nid,
+
            addrs: value.addrs,
+
            state: value.state,
+
            sync: value.sync.map(Into::into),
+
        }
+
    }
+
}
modified crates/radicle-types/src/domain/patch/models/patch.rs
@@ -1,13 +1,13 @@
use std::collections::BTreeMap;
use std::collections::BTreeSet;

-
use radicle::node::AliasStore;
use serde::{Deserialize, Serialize};
use ts_rs::TS;

use radicle::cob;
use radicle::git;
use radicle::identity;
+
use radicle::node::AliasStore;
use radicle::patch;

use crate::cobs;
modified crates/radicle-types/src/error.rs
@@ -2,14 +2,57 @@ use axum::body::Body;
use axum::http::{Response, StatusCode};
use axum::response::IntoResponse;
use serde::Serialize;
+
use ts_rs::TS;

use crate::cobs::stream;

+
pub mod node;
+

#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// Profile error.
    #[error(transparent)]
-
    Profile(#[from] radicle::profile::Error),
+
    ProfileError(#[from] radicle::profile::Error),
+

+
    /// Embeds error.
+
    #[error("not able to save embed")]
+
    SaveEmbedError,
+

+
    /// Memory Signer error.
+
    #[error(transparent)]
+
    RuntimeError(#[from] radicle_node::runtime::Error),
+

+
    /// Memory Signer error.
+
    #[error(transparent)]
+
    MemorySignerError(#[from] radicle::crypto::ssh::keystore::MemorySignerError),
+

+
    /// Memory Signer error.
+
    #[error(transparent)]
+
    NodeControlError(#[from] node::NodeError),
+

+
    /// Init Error error.
+
    #[error(transparent)]
+
    InitError(#[from] radicle::rad::InitError),
+

+
    /// Alias error.
+
    #[error(transparent)]
+
    AliasError(#[from] radicle::node::AliasError),
+

+
    /// Tauri Plugin Clipboard error.
+
    #[error(transparent)]
+
    TauriPluginClipboard(#[from] tauri_plugin_clipboard_manager::Error),
+

+
    /// Tauri Plugin Fs error.
+
    #[error(transparent)]
+
    TauriPluginFs(#[from] tauri_plugin_fs::Error),
+

+
    /// Node Config error.
+
    #[error(transparent)]
+
    NodeConfigError(#[from] radicle::profile::ConfigError),
+

+
    /// Project error.
+
    #[error(transparent)]
+
    ProjectError(#[from] radicle::identity::project::ProjectError),

    /// List notification error.
    #[error(transparent)]
@@ -25,10 +68,6 @@ pub enum Error {
    #[error(transparent)]
    CobStore(#[from] radicle::cob::store::Error),

-
    /// Anyhow error.
-
    #[error(transparent)]
-
    Anyhow(#[from] anyhow::Error),
-

    /// Io error.
    #[error(transparent)]
    Io(#[from] std::io::Error),
@@ -109,45 +148,71 @@ pub enum Error {
    #[error(transparent)]
    Node(#[from] radicle::node::Error),

-
    /// An error with a hint.
-
    #[error("{err} {hint}")]
-
    WithHint {
-
        err: anyhow::Error,
-
        hint: &'static str,
-
    },
-

    /// Serde JSON error.
    #[error(transparent)]
    SerdeJSON(#[from] serde_json::error::Error),
+

+
    /// Node Address error.
+
    #[error(transparent)]
+
    NodeAddress(#[from] radicle::node::address::Error),
}

-
#[derive(Serialize)]
-
struct ErrorWrapperWithHint {
-
    err: String,
-
    hint: String,
+
impl Error {
+
    #[must_use]
+
    pub const fn code(&self) -> &'static str {
+
        match self {
+
            Error::Node(radicle::node::Error::TimedOut) => "NodeError.TimedOut",
+
            Error::NodeControlError(node::NodeError::NodeConnect(_)) => {
+
                "NodeError.ConnectionUnavailable"
+
            }
+
            Error::ProjectError(radicle::identity::project::ProjectError::Name(_)) => {
+
                "ProjectError.InvalidName"
+
            }
+
            Error::ProjectError(radicle::identity::project::ProjectError::Description(_)) => {
+
                "ProjectError.InvalidDescription"
+
            }
+
            Error::MemorySignerError(
+
                radicle::crypto::ssh::keystore::MemorySignerError::InvalidPassphrase,
+
            )
+
            | Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(ssh_key::Error::Crypto))
+
            | Error::Crypto(radicle::crypto::ssh::keystore::Error::PassphraseMissing) => {
+
                "PassphraseError.InvalidPassphrase"
+
            }
+
            Error::AliasError(radicle::node::AliasError::Empty) => "AliasError.EmptyAlias",
+
            Error::AliasError(radicle::node::AliasError::MaxBytesExceeded) => {
+
                "AliasError.TooLongAlias"
+
            }
+
            Error::ProfileError(radicle::profile::Error::NotFound(_)) => {
+
                "IdentityError.MissingProfile"
+
            }
+
            Error::AliasError(radicle::node::AliasError::InvalidCharacter) => {
+
                "AliasError.InvalidAlias"
+
            }
+
            _ => "UnknownError",
+
        }
+
    }
}

-
#[derive(Serialize)]
-
struct ErrorWrapper {
-
    err: String,
+
#[derive(Serialize, TS, Debug)]
+
#[ts(export)]
+
#[ts(export_to = "error/")]
+
pub struct ErrorWrapper {
+
    code: String,
+
    #[ts(optional)]
+
    message: Option<String>,
}

-
impl Serialize for Error {
+
impl serde::Serialize for Error {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::ser::Serializer,
    {
-
        match self {
-
            Error::WithHint { err, hint } => ErrorWrapperWithHint {
-
                err: err.to_string(),
-
                hint: hint.to_string(),
-
            }
-
            .serialize(serializer),
-
            err => ErrorWrapper {
-
                err: err.to_string(),
-
            }
-
            .serialize(serializer),
-
        }
+
        use serde::ser::SerializeStruct;
+

+
        let mut state = serializer.serialize_struct("ErrorWrapper", 2)?;
+
        state.serialize_field("code", &self.code().to_string())?;
+
        state.serialize_field("message", &self.to_string())?;
+
        state.end()
    }
}

@@ -164,14 +229,27 @@ impl IntoResponse for Error {
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
-
    use super::Error;
-
    use anyhow::anyhow;
+
    use crate::error::Error;
+

+
    #[test]
+
    fn serialize_nested_errors() {
+
        let serialized = serde_json::to_string(&Error::Crypto(
+
            radicle::crypto::ssh::keystore::Error::Ssh(ssh_key::Error::Crypto),
+
        ))
+
        .unwrap();
+
        assert_eq!(
+
            serialized,
+
            "{\"code\":\"PassphraseError.InvalidPassphrase\",\"message\":\"ssh keygen: cryptographic error\"}"
+
        );
+
    }

    #[test]
-
    fn serialize_errors() {
-
        assert_eq!(serde_json::to_string(&Error::WithHint {
-
            err: anyhow!("Not able to find your keys in the ssh agent"),
-
            hint: "Make sure to run <code>rad auth</code> in your terminal to add your keys to the ssh-agent.",
-
        }).unwrap(),"{\"err\":\"Not able to find your keys in the ssh agent\",\"hint\":\"Make sure to run <code>rad auth</code> in your terminal to add your keys to the ssh-agent.\"}");
+
    fn serialize_unknown_errors() {
+
        let serialized =
+
            serde_json::to_string(&Error::Issue(radicle::issue::Error::MissingIdentity)).unwrap();
+
        assert_eq!(
+
            serialized,
+
            "{\"code\":\"UnknownError\",\"message\":\"identity document missing\"}"
+
        );
    }
}
added crates/radicle-types/src/error/node.rs
@@ -0,0 +1,26 @@
+
#[derive(Debug, thiserror::Error)]
+
pub enum NodeError {
+
    /// Node error.
+
    #[error("node has not been started")]
+
    NodeNotStarted,
+

+
    /// Node error.
+
    #[error("node is not running")]
+
    NodeNotRunning,
+

+
    /// Node error.
+
    #[error("no addresses found")]
+
    NoAddressesFound,
+

+
    /// Fetch repo error.
+
    #[error("repo not fetchable")]
+
    RepoFetchError,
+

+
    /// Missing SSH Agent error.
+
    #[error("ssh agent not running")]
+
    AgentNotRunning,
+

+
    /// Connect node error.
+
    #[error("unable to connect to node: {0}")]
+
    NodeConnect(String),
+
}
modified crates/radicle-types/src/lib.rs
@@ -1,4 +1,3 @@
-
use traits::auth::Auth;
use traits::cobs::Cobs;
use traits::issue::{Issues, IssuesMut};
use traits::patch::{Patches, PatchesMut};
@@ -22,7 +21,6 @@ pub struct AppState {
    pub profile: radicle::Profile,
}

-
impl Auth for AppState {}
impl Repo for AppState {}
impl Thread for AppState {}
impl Cobs for AppState {}
modified crates/radicle-types/src/repo.rs
@@ -100,7 +100,7 @@ impl TryFrom<identity::doc::Payload> for ProjectPayloadData {
    type Error = error::Error;

    fn try_from(value: identity::doc::Payload) -> Result<Self, Self::Error> {
-
        serde_json::from_value::<Self>((*value).clone()).map_err(error::Error::from)
+
        serde_json::from_value::<Self>((*value).clone()).map_err(Into::into)
    }
}

modified crates/radicle-types/src/traits.rs
@@ -2,7 +2,6 @@ use radicle::node::{AliasStore, NodeId};

use crate::config::Config;

-
pub mod auth;
pub mod cobs;
pub mod issue;
pub mod patch;
deleted crates/radicle-types/src/traits/auth.rs
@@ -1,31 +0,0 @@
-
use anyhow::anyhow;
-

-
use radicle::crypto::ssh;
-

-
use crate::error::Error;
-

-
use super::Profile;
-

-
pub trait Auth: Profile {
-
    fn authenticate(&self) -> Result<(), Error> {
-
        let profile = &self.profile();
-

-
        if !profile.keystore.is_encrypted()? {
-
            return Ok(());
-
        }
-
        match ssh::agent::Agent::connect() {
-
        Ok(mut agent) => {
-
            if agent.request_identities()?.contains(&profile.public_key) {
-
                Ok(())
-
            } else {
-
                Err(Error::WithHint {
-
                    err: anyhow!("Not able to find your keys in the ssh agent"),
-
                    hint: "Make sure to run <code>rad auth</code> in your terminal to add your keys to the ssh-agent.",
-
                })?
-
            }
-
        }
-
        Err(e) if e.is_not_running() => Err(Error::WithHint { err: anyhow!("SSH Agent is not running"), hint: "For now we require the user to have an ssh agent running, since we don't have passphrase inputs yet." })?, 
-
        Err(e) => Err(e)?,
-
    }
-
    }
-
}
modified crates/test-http-api/src/api.rs
@@ -23,7 +23,6 @@ use radicle_types::domain::patch::service::Service;
use radicle_types::domain::patch::traits::PatchService;
use radicle_types::error::Error;
use radicle_types::outbound::sqlite::Sqlite;
-
use radicle_types::traits::auth::Auth;
use radicle_types::traits::cobs::Cobs;
use radicle_types::traits::issue::{Issues, IssuesMut};
use radicle_types::traits::patch::{Patches, PatchesMut};
@@ -37,7 +36,6 @@ pub struct Context {
    patches: Arc<Service<Sqlite>>,
}

-
impl Auth for Context {}
impl Repo for Context {}
impl Cobs for Context {}
impl Thread for Context {}
@@ -107,9 +105,7 @@ async fn config_handler(State(ctx): State<Context>) -> impl IntoResponse {
    Ok::<_, Error>(Json(config))
}

-
async fn auth_handler(State(ctx): State<Context>) -> impl IntoResponse {
-
    ctx.authenticate()?;
-

+
async fn auth_handler() -> impl IntoResponse {
    Ok::<_, Error>(Json(()))
}

modified public/index.css
@@ -65,6 +65,16 @@ body {
  flex-shrink: 0;
}

+
.global-link {
+
  color: var(--color-foreground-default);
+
  text-decoration: none;
+
}
+
.global-link:hover {
+
  text-decoration: underline;
+
  text-decoration-thickness: 1px;
+
  text-underline-offset: 2px;
+
}
+

:root {
  --elevation-low: 0 0 48px 0 #000000ee;
}
deleted public/radicle.svg
@@ -1,63 +0,0 @@
-
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
-
  <g shape-rendering="crispEdges">
-
    <rect x="8" y="0" width="4" height="4" fill="#5555FF"/>
-
    <rect x="32" y="0" width="4" height="4" fill="#5555FF"/>
-
    <rect x="12" y="4" width="4" height="4" fill="#5555FF"/>
-
    <rect x="28" y="4" width="4" height="4" fill="#5555FF"/>
-
    <rect x="12" y="8" width="4" height="4" fill="#5555FF"/>
-
    <rect x="16" y="8" width="4" height="4" fill="#3333DD"/>
-
    <rect x="20" y="8" width="4" height="4" fill="#5555FF"/>
-
    <rect x="24" y="8" width="4" height="4" fill="#3333DD"/>
-
    <rect x="28" y="8" width="4" height="4" fill="#5555FF"/>
-
    <rect x="8" y="12" width="4" height="4" fill="#5555FF"/>
-
    <rect x="12" y="12" width="4" height="4" fill="#5555FF"/>
-
    <rect x="16" y="12" width="4" height="4" fill="#5555FF"/>
-
    <rect x="20" y="12" width="4" height="4" fill="#5555FF"/>
-
    <rect x="24" y="12" width="4" height="4" fill="#5555FF"/>
-
    <rect x="28" y="12" width="4" height="4" fill="#5555FF"/>
-
    <rect x="32" y="12" width="4" height="4" fill="#5555FF"/>
-
    <rect x="4" y="16" width="4" height="4" fill="#5555FF"/>
-
    <rect x="8" y="16" width="4" height="4" fill="#5555FF"/>
-
    <rect x="12" y="16" width="4" height="4" fill="#F4F4F4"/>
-
    <rect x="16" y="16" width="4" height="4" fill="#F4F4F4"/>
-
    <rect x="20" y="16" width="4" height="4" fill="#5555FF"/>
-
    <rect x="24" y="16" width="4" height="4" fill="#5555FF"/>
-
    <rect x="28" y="16" width="4" height="4" fill="#F4F4F4"/>
-
    <rect x="32" y="16" width="4" height="4" fill="#F4F4F4"/>
-
    <rect x="36" y="16" width="4" height="4" fill="#5555FF"/>
-
    <rect x="4" y="20" width="4" height="4" fill="#5555FF"/>
-
    <rect x="8" y="20" width="4" height="4" fill="#5555FF"/>
-
    <rect x="12" y="20" width="4" height="4" fill="#F4F4F4"/>
-
    <rect x="16" y="20" width="4" height="4" fill="#FF55FF"/>
-
    <rect x="20" y="20" width="4" height="4" fill="#5555FF"/>
-
    <rect x="24" y="20" width="4" height="4" fill="#5555FF"/>
-
    <rect x="28" y="20" width="4" height="4" fill="#F4F4F4"/>
-
    <rect x="32" y="20" width="4" height="4" fill="#FF55FF"/>
-
    <rect x="36" y="20" width="4" height="4" fill="#5555FF"/>
-
    <rect x="0" y="24" width="4" height="4" fill="#3333DD"/>
-
    <rect x="4" y="24" width="4" height="4" fill="#3333DD"/>
-
    <rect x="8" y="24" width="4" height="4" fill="#3333DD"/>
-
    <rect x="12" y="24" width="4" height="4" fill="#3333DD"/>
-
    <rect x="16" y="24" width="4" height="4" fill="#3333DD"/>
-
    <rect x="20" y="24" width="4" height="4" fill="#3333DD"/>
-
    <rect x="24" y="24" width="4" height="4" fill="#3333DD"/>
-
    <rect x="28" y="24" width="4" height="4" fill="#3333DD"/>
-
    <rect x="32" y="24" width="4" height="4" fill="#3333DD"/>
-
    <rect x="36" y="24" width="4" height="4" fill="#3333DD"/>
-
    <rect x="40" y="24" width="4" height="4" fill="#3333DD"/>
-
    <rect x="8" y="28" width="4" height="4" fill="#3333DD"/>
-
    <rect x="16" y="28" width="4" height="4" fill="#5555FF"/>
-
    <rect x="24" y="28" width="4" height="4" fill="#5555FF"/>
-
    <rect x="32" y="28" width="4" height="4" fill="#3333DD"/>
-
    <rect x="8" y="32" width="4" height="4" fill="#3333DD"/>
-
    <rect x="16" y="32" width="4" height="4" fill="#5555FF"/>
-
    <rect x="24" y="32" width="4" height="4" fill="#5555FF"/>
-
    <rect x="32" y="32" width="4" height="4" fill="#3333DD"/>
-
    <rect x="16" y="36" width="4" height="4" fill="#5555FF"/>
-
    <rect x="24" y="36" width="4" height="4" fill="#5555FF"/>
-
    <rect x="12" y="40" width="4" height="4" fill="#5555FF"/>
-
    <rect x="16" y="40" width="4" height="4" fill="#5555FF"/>
-
    <rect x="24" y="40" width="4" height="4" fill="#5555FF"/>
-
    <rect x="28" y="40" width="4" height="4" fill="#5555FF"/>
-
  </g>
-
</svg>
modified src/App.svelte
@@ -1,79 +1,60 @@
<script lang="ts">
+
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
  import type { Config } from "@bindings/config/Config";
  import type { UnlistenFn } from "@tauri-apps/api/event";
-
  import type { SyncStatus } from "@bindings/repo/SyncStatus";

-
  import { SvelteMap } from "svelte/reactivity";
  import { onDestroy, onMount } from "svelte";

-
  import { invoke } from "@app/lib/invoke";
-
  import { listen } from "@tauri-apps/api/event";
-

  import * as router from "@app/lib/router";
-
  import { nodeRunning, syncStatus } from "@app/lib/events";
+
  import { checkAuth, startup } from "@app/lib/auth.svelte";
+
  import { dynamicInterval } from "@app/lib/time";
+
  import { createEventEmittersOnce } from "@app/lib/startup.svelte";
+
  import { invoke } from "@app/lib/invoke";
  import { theme } from "@app/components/ThemeSwitch.svelte";
  import { unreachable } from "@app/lib/utils";

-
  import AuthenticationError from "@app/views/AuthenticationError.svelte";
+
  import Auth from "@app/views/booting/Auth.svelte";
+
  import CreateIdentity from "@app/views/booting/CreateIdentity.svelte";
  import CreateIssue from "@app/views/repo/CreateIssue.svelte";
-
  import Inbox from "./views/home/Inbox.svelte";
+
  import Inbox from "@app/views/home/Inbox.svelte";
  import Issue from "@app/views/repo/Issue.svelte";
  import Issues from "@app/views/repo/Issues.svelte";
  import Patch from "@app/views/repo/Patch.svelte";
  import Patches from "@app/views/repo/Patches.svelte";
-
  import Repos from "./views/home/Repos.svelte";
-
  import { dynamicInterval, checkAuth } from "./lib/auth";
+
  import Repos from "@app/views/home/Repos.svelte";

  const activeRouteStore = router.activeRouteStore;

+
  let profile = $state<Config>();
  let unlistenEvents: UnlistenFn | undefined = undefined;
  let unlistenNodeEvents: UnlistenFn | undefined = undefined;
  let unlistenSyncStatus: UnlistenFn | undefined = undefined;

-
  let error = $state<undefined | unknown>();
-

  onMount(async () => {
    try {
-
      await invoke<Config>("startup");
-
    } catch (e: unknown) {
-
      error = e;
+
      profile = await invoke<Config>("startup");
+
    } catch (err) {
+
      startup.error = err as ErrorWrapper;
      return;
    }

    if (window.__TAURI_INTERNALS__) {
-
      unlistenEvents = await listen("event", () => {
-
        // Add handler for incoming events
-
      });
-

-
      unlistenSyncStatus = await listen<Record<string, SyncStatus>>(
-
        "sync_status",
-
        event => {
-
          syncStatus.set(new SvelteMap(Object.entries(event.payload)));
-
        },
-
      );
-

-
      unlistenNodeEvents = await listen<boolean>("node_running", event => {
-
        nodeRunning.set(event.payload);
-
      });
+
      [unlistenEvents, unlistenNodeEvents, unlistenSyncStatus] =
+
        await createEventEmittersOnce();
    }

    try {
-
      await invoke("authenticate");
+
      await invoke("authenticate", { passphrase: "" });
      void router.loadFromLocation();
-
      void dynamicInterval(
+
      dynamicInterval(
+
        "auth",
        checkAuth,
        import.meta.env.VITE_AUTH_LONG_DELAY || 30_000,
      );
-
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-
    } catch (e: any) {
-
      void router.push({
-
        resource: "authenticationError",
-
        params: {
-
          error: e.err,
-
          hint: e.hint,
-
        },
-
      });
-
      void dynamicInterval(checkAuth, 1000);
+
    } catch (err) {
+
      startup.error = err as ErrorWrapper;
+
      void router.push({ resource: "booting" });
+
      dynamicInterval("auth", checkAuth, 5_000);
    }
  });

@@ -93,10 +74,11 @@
</script>

{#if $activeRouteStore.resource === "booting"}
-
  {#if error && typeof error === "object" && "err" in error && typeof error.err === "string"}
-
    <AuthenticationError error={error.err} />
+
  {#if startup.error?.code === "IdentityError.MissingProfile"}
+
    <CreateIdentity />
+
  {:else if startup.error?.code === "PassphraseError.InvalidPassphrase" && profile}
+
    <Auth profile={{ did: profile.publicKey, alias: profile.alias }} />
  {/if}
-
  <!-- Don't show anything -->
{:else if $activeRouteStore.resource === "home"}
  <Repos {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "inbox"}
@@ -111,8 +93,6 @@
  <Patch {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.patches"}
  <Patches {...$activeRouteStore.params} />
-
{:else if $activeRouteStore.resource === "authenticationError"}
-
  <AuthenticationError {...$activeRouteStore.params} />
{:else}
  {unreachable($activeRouteStore)}
{/if}
added src/components/ActionItem.svelte
@@ -0,0 +1,59 @@
+
<script lang="ts" module>
+
  export type Status = "done" | "running" | "error";
+
  export type Type = "start" | "connect" | "fetch" | "stop";
+
</script>
+

+
<script lang="ts">
+
  import type { ComponentProps, Snippet } from "svelte";
+
  import Icon from "./Icon.svelte";
+
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
+

+
  interface Props {
+
    error?: ErrorWrapper;
+
    status?: Status;
+
    position: string;
+
    children: Snippet;
+
  }
+

+
  function iconName(status: Status): ComponentProps<typeof Icon>["name"] {
+
    if (status === "done") {
+
      return "checkmark";
+
    } else if (status === "error") {
+
      return "warning";
+
    } else {
+
      return "ellipsis";
+
    }
+
  }
+

+
  const { status, error, position, children }: Props = $props();
+
</script>
+

+
<style>
+
  .first-col {
+
    grid-column: 1;
+
    gap: 0.25rem;
+
  }
+
  .error {
+
    color: var(--color-foreground-red);
+
    font-size: var(--font-size-small);
+
    grid-column: 2;
+
    display: flex;
+
    gap: 0.25rem;
+
  }
+
</style>
+

+
{#if status === undefined}
+
  <div class="global-flex first-col" class:txt-missing={status === undefined}>
+
    <span class="txt-monospace">{position}</span>
+
    {@render children()}
+
  </div>
+
{:else}
+
  <div class="global-flex first-col">
+
    <Icon name={iconName(status)} />{@render children()}
+
  </div>
+
  <div class="error">
+
    {#if error && error.code !== "PassphraseError.InvalidPassphrase"}
+
      <Icon name="warning" />{error.message}
+
    {/if}
+
  </div>
+
{/if}
modified src/components/Icon.svelte
@@ -36,6 +36,7 @@
      | "face"
      | "file"
      | "home"
+
      | "info"
      | "inbox"
      | "issue"
      | "issue-closed"
@@ -61,6 +62,7 @@
      | "settings"
      | "sun"
      | "user"
+
      | "time"
      | "warning";
  }

@@ -562,6 +564,21 @@
    <path d="M6 9H10L10 10H6L6 9Z" />
    <path d="M3 13H13V14H3L3 13Z" />
    <path d="M3 2H13V3H3L3 2Z" />
+
  {:else if name === "info"}
+
    <path d="M10 13V14L6 14V13L10 13Z" />
+
    <path d="M4 12H6L6 13L4 13L4 12Z" />
+
    <path d="M3 10H4L4 12L3 12L3 10Z" />
+
    <path d="M3 6L3 10H2L2 6H3Z" />
+
    <path d="M4 4L4 6H3L3 4L4 4Z" />
+
    <path d="M6 3L6 4L4 4L4 3L6 3Z" />
+
    <path d="M10 3L6 3V2L10 2V3Z" />
+
    <path d="M12 4L10 4V3L12 3V4Z" />
+
    <path d="M13 6L12 6V4H13V6Z" />
+
    <path d="M13 10L13 6H14L14 10H13Z" />
+
    <path d="M12 12V10L13 10V12L12 12Z" />
+
    <path d="M12 12H10V13H12V12Z" />
+
    <path d="M9 7V10L10 10V11L6 11L6 10H7V8H6V7L9 7Z" />
+
    <path d="M9 4L7 4L7 6L9 6V4Z" />
  {:else if name === "issue"}
    <path d="M6 13H8V14H6V13Z" />
    <path d="M10 13L8 13V14L10 14V13Z" />
@@ -1053,6 +1070,29 @@
    <path d="M5 3L11 3V5H5V3Z" />
    <path d="M9 6H10V7H9V6Z" />
    <path d="M6 6H7V7H6V6Z" />
+
  {:else if name === "time"}
+
    <path d="M6 13H8V14H6V13Z" />
+
    <path d="M10 13L8 13V14L10 14V13Z" />
+
    <path d="M3 6L3 8H2L2 6H3Z" />
+
    <path d="M13 6V8H14V6H13Z" />
+
    <path d="M4 12H6V13H4V12Z" />
+
    <path d="M12 12H10V13H12V12Z" />
+
    <path d="M4 4L4 6H3L3 4H4Z" />
+
    <path d="M12 4V6L13 6V4L12 4Z" />
+
    <path d="M4 10L4 12H3L3 10H4Z" />
+
    <path d="M12 10V12H13V10L12 10Z" />
+
    <path d="M6 4L4 4L4 3L6 3V4Z" />
+
    <path d="M10 4L12 4V3L10 3V4Z" />
+
    <path d="M3 8L3 10H2L2 8H3Z" />
+
    <path d="M13 8L13 10H14L14 8H13Z" />
+
    <path d="M8 3L6 3V2L8 2V3Z" />
+
    <path d="M8 3L10 3L10 2L8 2V3Z" />
+
    <path d="M8 8H9V9H8V8Z" />
+
    <path d="M9 7H10V8H9V7Z" />
+
    <path d="M10 6H11V7L10 7V6Z" />
+
    <path d="M7 7H8V8L7 8V7Z" />
+
    <path d="M6 6H7V7L6 7V6Z" />
+
    <path d="M5 5H6L6 6L5 6V5Z" />
  {:else if name === "warning"}
    <path d="M7 2H9V3H7V2Z" />
    <path d="M6 3H7V5H6V3Z" />
modified src/components/IssueTimeline.svelte
@@ -74,15 +74,6 @@
</script>

<style>
-
  a {
-
    color: var(--color-foreground-default);
-
    text-decoration: none;
-
  }
-
  a:hover {
-
    text-decoration: underline;
-
    text-decoration-thickness: 1px;
-
    text-underline-offset: 2px;
-
  }
  .timeline {
    display: flex;
    gap: 0.75rem;
@@ -254,7 +245,7 @@
          </div>
          <div class="wrapper">
            <NodeId {...authorForNodeId(op.author)} />
-
            <a href="#{op.id}">
+
            <a class="global-link" href="#{op.id}">
              {op.replyTo && op.replyTo !== activity[0].id
                ? "replied to a comment"
                : "commented"}
modified src/components/Markdown.svelte
@@ -134,20 +134,6 @@
        }
      }

-
      // Replace standard HTML checkboxes with our custom radicle-icon-small element
-
      for (const i of container.querySelectorAll('input[type="checkbox"]')) {
-
        i.parentElement?.classList.add("task-item");
-

-
        const checkbox = document.createElement("radicle-icon-small");
-
        const checked = i.getAttribute("checked");
-
        checkbox.setAttribute(
-
          "name",
-
          checked === null ? "checkbox-unchecked" : "checkbox-checked",
-
        );
-
        i.insertAdjacentElement("beforebegin", checkbox);
-
        i.remove();
-
      }
-

      // Replaces code blocks in the background with highlighted code.
      const prefix = "language-";
      const nodes = Array.from(document.body.querySelectorAll("pre code"));
modified src/components/Review.svelte
@@ -122,7 +122,6 @@
    embeds: Embed[],
    replyTo?: string,
  ) {
-
    console.log({ replyTo });
    try {
      await invoke("edit_patch", {
        rid: repo.rid,
modified src/components/TextInput.svelte
@@ -6,6 +6,7 @@

  interface Props {
    name?: string;
+
    type?: "text" | "password";
    placeholder?: string;
    value?: string;
    autofocus?: boolean;
@@ -19,6 +20,7 @@

  /* eslint-disable prefer-const */
  let {
+
    type = "text",
    name,
    placeholder,
    value = $bindable(undefined),
@@ -84,6 +86,7 @@
  }
  input[disabled] {
    cursor: not-allowed;
+
    color: var(--color-foreground-dim);
  }
</style>

@@ -98,7 +101,7 @@
      focussed = false;
    }}
    bind:this={inputElement}
-
    type="text"
+
    {type}
    {name}
    {placeholder}
    {disabled}
added src/lib/auth.svelte.ts
@@ -0,0 +1,37 @@
+
import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
+

+
import * as router from "@app/lib/router";
+
import { dynamicInterval } from "@app/lib/time";
+
import { get } from "svelte/store";
+
import { invoke } from "@app/lib/invoke";
+

+
export const startup = $state<{ error?: ErrorWrapper }>({ error: undefined });
+

+
let lock = false;
+

+
export async function checkAuth() {
+
  try {
+
    if (lock) {
+
      return;
+
    }
+
    lock = true;
+
    await invoke("authenticate", { passphrase: "" });
+
    dynamicInterval(
+
      "auth",
+
      checkAuth,
+
      import.meta.env.VITE_AUTH_LONG_DELAY || 30_000,
+
    );
+
    if (get(router.activeRouteStore).resource === "booting") {
+
      void router.push({ resource: "home" });
+
    }
+
  } catch (err) {
+
    const error = err as ErrorWrapper;
+
    startup.error = error;
+
    if (get(router.activeRouteStore).resource !== "booting") {
+
      void router.push({ resource: "booting" });
+
    }
+
    dynamicInterval("auth", checkAuth, 5_000);
+
  } finally {
+
    lock = false;
+
  }
+
}
deleted src/lib/auth.ts
@@ -1,45 +0,0 @@
-
import { invoke } from "@app/lib/invoke";
-
import { activeRouteStore } from "@app/lib/router";
-
import { get } from "svelte/store";
-
import * as router from "@app/lib/router";
-

-
let intervalId: ReturnType<typeof setTimeout>;
-

-
export function dynamicInterval(callback: () => void, period: number) {
-
  clearTimeout(intervalId);
-

-
  intervalId = setTimeout(() => {
-
    callback();
-
    dynamicInterval(callback, period);
-
  }, period);
-
}
-

-
let lock = false;
-

-
export async function checkAuth() {
-
  try {
-
    if (lock) {
-
      return;
-
    }
-
    lock = true;
-
    await invoke("authenticate");
-
    if (get(activeRouteStore).resource === "authenticationError") {
-
      window.history.back();
-
    }
-
    dynamicInterval(checkAuth, import.meta.env.VITE_AUTH_LONG_DELAY || 30_000);
-
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-
  } catch (e: any) {
-
    if (get(activeRouteStore).resource !== "authenticationError") {
-
      await router.push({
-
        resource: "authenticationError",
-
        params: {
-
          error: e.err,
-
          hint: e.hint,
-
        },
-
      });
-
      dynamicInterval(checkAuth, 1000);
-
    }
-
  } finally {
-
    lock = false;
-
  }
-
}
added src/lib/images/radicle.svg
@@ -0,0 +1,63 @@
+
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
+
  <g shape-rendering="crispEdges">
+
    <rect x="8" y="0" width="4" height="4" fill="#5555FF"/>
+
    <rect x="32" y="0" width="4" height="4" fill="#5555FF"/>
+
    <rect x="12" y="4" width="4" height="4" fill="#5555FF"/>
+
    <rect x="28" y="4" width="4" height="4" fill="#5555FF"/>
+
    <rect x="12" y="8" width="4" height="4" fill="#5555FF"/>
+
    <rect x="16" y="8" width="4" height="4" fill="#3333DD"/>
+
    <rect x="20" y="8" width="4" height="4" fill="#5555FF"/>
+
    <rect x="24" y="8" width="4" height="4" fill="#3333DD"/>
+
    <rect x="28" y="8" width="4" height="4" fill="#5555FF"/>
+
    <rect x="8" y="12" width="4" height="4" fill="#5555FF"/>
+
    <rect x="12" y="12" width="4" height="4" fill="#5555FF"/>
+
    <rect x="16" y="12" width="4" height="4" fill="#5555FF"/>
+
    <rect x="20" y="12" width="4" height="4" fill="#5555FF"/>
+
    <rect x="24" y="12" width="4" height="4" fill="#5555FF"/>
+
    <rect x="28" y="12" width="4" height="4" fill="#5555FF"/>
+
    <rect x="32" y="12" width="4" height="4" fill="#5555FF"/>
+
    <rect x="4" y="16" width="4" height="4" fill="#5555FF"/>
+
    <rect x="8" y="16" width="4" height="4" fill="#5555FF"/>
+
    <rect x="12" y="16" width="4" height="4" fill="#F4F4F4"/>
+
    <rect x="16" y="16" width="4" height="4" fill="#F4F4F4"/>
+
    <rect x="20" y="16" width="4" height="4" fill="#5555FF"/>
+
    <rect x="24" y="16" width="4" height="4" fill="#5555FF"/>
+
    <rect x="28" y="16" width="4" height="4" fill="#F4F4F4"/>
+
    <rect x="32" y="16" width="4" height="4" fill="#F4F4F4"/>
+
    <rect x="36" y="16" width="4" height="4" fill="#5555FF"/>
+
    <rect x="4" y="20" width="4" height="4" fill="#5555FF"/>
+
    <rect x="8" y="20" width="4" height="4" fill="#5555FF"/>
+
    <rect x="12" y="20" width="4" height="4" fill="#F4F4F4"/>
+
    <rect x="16" y="20" width="4" height="4" fill="#FF55FF"/>
+
    <rect x="20" y="20" width="4" height="4" fill="#5555FF"/>
+
    <rect x="24" y="20" width="4" height="4" fill="#5555FF"/>
+
    <rect x="28" y="20" width="4" height="4" fill="#F4F4F4"/>
+
    <rect x="32" y="20" width="4" height="4" fill="#FF55FF"/>
+
    <rect x="36" y="20" width="4" height="4" fill="#5555FF"/>
+
    <rect x="0" y="24" width="4" height="4" fill="#3333DD"/>
+
    <rect x="4" y="24" width="4" height="4" fill="#3333DD"/>
+
    <rect x="8" y="24" width="4" height="4" fill="#3333DD"/>
+
    <rect x="12" y="24" width="4" height="4" fill="#3333DD"/>
+
    <rect x="16" y="24" width="4" height="4" fill="#3333DD"/>
+
    <rect x="20" y="24" width="4" height="4" fill="#3333DD"/>
+
    <rect x="24" y="24" width="4" height="4" fill="#3333DD"/>
+
    <rect x="28" y="24" width="4" height="4" fill="#3333DD"/>
+
    <rect x="32" y="24" width="4" height="4" fill="#3333DD"/>
+
    <rect x="36" y="24" width="4" height="4" fill="#3333DD"/>
+
    <rect x="40" y="24" width="4" height="4" fill="#3333DD"/>
+
    <rect x="8" y="28" width="4" height="4" fill="#3333DD"/>
+
    <rect x="16" y="28" width="4" height="4" fill="#5555FF"/>
+
    <rect x="24" y="28" width="4" height="4" fill="#5555FF"/>
+
    <rect x="32" y="28" width="4" height="4" fill="#3333DD"/>
+
    <rect x="8" y="32" width="4" height="4" fill="#3333DD"/>
+
    <rect x="16" y="32" width="4" height="4" fill="#5555FF"/>
+
    <rect x="24" y="32" width="4" height="4" fill="#5555FF"/>
+
    <rect x="32" y="32" width="4" height="4" fill="#3333DD"/>
+
    <rect x="16" y="36" width="4" height="4" fill="#5555FF"/>
+
    <rect x="24" y="36" width="4" height="4" fill="#5555FF"/>
+
    <rect x="12" y="40" width="4" height="4" fill="#5555FF"/>
+
    <rect x="16" y="40" width="4" height="4" fill="#5555FF"/>
+
    <rect x="24" y="40" width="4" height="4" fill="#5555FF"/>
+
    <rect x="28" y="40" width="4" height="4" fill="#5555FF"/>
+
  </g>
+
</svg>
modified src/lib/router.ts
@@ -113,9 +113,6 @@ function urlToRoute(url: URL): Route | null {
    case "repos": {
      return repoUrlToRoute(segments, url.searchParams);
    }
-
    case "authenticationError": {
-
      return { resource: "authenticationError", params: { error: "" } };
-
    }
    default: {
      return null;
    }
@@ -127,8 +124,6 @@ export function routeToPath(route: Route): string {
    return "/";
  } else if (route.resource === "inbox") {
    return "/inbox";
-
  } else if (route.resource === "authenticationError") {
-
    return "/authenticationError";
  } else if (
    route.resource === "repo.createIssue" ||
    route.resource === "repo.issue" ||
modified src/lib/router/definitions.ts
@@ -29,14 +29,6 @@ interface BootingRoute {
  resource: "booting";
}

-
interface AuthenticationErrorRoute {
-
  resource: "authenticationError";
-
  params: {
-
    error: string;
-
    hint?: string;
-
  };
-
}
-

interface HomeRoute {
  resource: "home";
  activeTab?: HomeReposTab;
@@ -76,15 +68,9 @@ interface LoadedHomeRoute {
  };
}

-
export type Route =
-
  | AuthenticationErrorRoute
-
  | InboxRoute
-
  | BootingRoute
-
  | HomeRoute
-
  | RepoRoute;
+
export type Route = InboxRoute | BootingRoute | HomeRoute | RepoRoute;

export type LoadedRoute =
-
  | AuthenticationErrorRoute
  | LoadedInboxRoute
  | BootingRoute
  | LoadedHomeRoute
deleted src/lib/sleep.ts
@@ -1,5 +0,0 @@
-
export function sleep(timeMs: number): Promise<void> {
-
  return new Promise(resolve => {
-
    setTimeout(resolve, timeMs);
-
  });
-
}
added src/lib/startup.svelte.ts
@@ -0,0 +1,26 @@
+
import type { SyncStatus } from "@bindings/repo/SyncStatus";
+

+
import { listen } from "@tauri-apps/api/event";
+
import { SvelteMap } from "svelte/reactivity";
+
import { nodeRunning, syncStatus } from "./events";
+
import once from "lodash/once";
+

+
// Will be called once in the startup of the app
+
export const createEventEmittersOnce = once(async () => {
+
  const unlistenEvents = await listen("event", () => {
+
    // Add handler for incoming events
+
  });
+

+
  const unlistenSyncStatus = await listen<Record<string, SyncStatus>>(
+
    "sync_status",
+
    event => {
+
      syncStatus.set(new SvelteMap(Object.entries(event.payload)));
+
    },
+
  );
+

+
  const unlistenNodeEvents = await listen<boolean>("node_running", event => {
+
    nodeRunning.set(event.payload);
+
  });
+

+
  return [unlistenEvents, unlistenSyncStatus, unlistenNodeEvents];
+
});
added src/lib/time.ts
@@ -0,0 +1,30 @@
+
export function sleep(timeMs: number): Promise<void> {
+
  return new Promise(resolve => {
+
    setTimeout(resolve, timeMs);
+
  });
+
}
+

+
const dynamicIntervals = new Map<string, ReturnType<typeof setTimeout>>();
+

+
export function dynamicInterval(
+
  key: string,
+
  callback: () => void,
+
  period: number,
+
) {
+
  // Clear an existing interval for this key, if any.
+
  if (dynamicIntervals.has(key)) {
+
    clearTimeout(dynamicIntervals.get(key));
+
  }
+

+
  // Set up a new dynamic interval.
+
  const id = setTimeout(() => {
+
    callback();
+
    dynamicInterval(key, callback, period);
+
  }, period);
+

+
  dynamicIntervals.set(key, id);
+
}
+

+
export function resetDynamicInterval(key: string) {
+
  dynamicIntervals.delete(key);
+
}
deleted src/views/AuthenticationError.svelte
@@ -1,40 +0,0 @@
-
<script lang="ts">
-
  import Icon from "@app/components/Icon.svelte";
-

-
  interface Props {
-
    error: string;
-
    hint?: string;
-
  }
-

-
  const { error, hint }: Props = $props();
-
</script>
-

-
<style>
-
  main {
-
    display: flex;
-
    flex-direction: column;
-
    justify-content: center;
-
    align-items: center;
-
    height: 100%;
-
    width: 100%;
-
    row-gap: 0.5rem;
-
  }
-

-
  /* This tag comes from the backend. */
-
  :global(code) {
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-small);
-
    background-color: var(--color-fill-ghost);
-
    padding: 0.125rem 0.25rem;
-
  }
-
</style>
-

-
<main>
-
  <Icon name="warning" size="32" />
-
  <div class="txt-medium txt-semibold">
-
    {error}
-
  </div>
-
  {#if hint}
-
    <div class="txt-small">{@html hint}</div>
-
  {/if}
-
</main>
added src/views/booting/Auth.svelte
@@ -0,0 +1,142 @@
+
<script lang="ts">
+
  import type { Author } from "@bindings/cob/Author";
+
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
+

+
  import * as router from "@app/lib/router";
+
  import Border from "@app/components/Border.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import logo from "@app/lib/images/radicle.svg?url";
+
  import { createEventEmittersOnce } from "@app/lib/startup.svelte";
+
  import { invoke } from "@app/lib/invoke";
+
  import { truncateDid } from "@app/lib/utils";
+

+
  interface Props {
+
    profile: Author;
+
  }
+

+
  let error = $state<ErrorWrapper>();
+
  let passphrase = $state("");
+
  let authInProgress = $state(false);
+
  const { profile }: Props = $props();
+

+
  async function authenticate() {
+
    try {
+
      await invoke("authenticate", { passphrase });
+
      passphrase = "";
+
      if (window.__TAURI_INTERNALS__) {
+
        await createEventEmittersOnce();
+
      }
+

+
      void router.push({ resource: "home" });
+
    } catch (err) {
+
      error = err as ErrorWrapper;
+
    }
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    align-items: center;
+
    gap: 1rem;
+
    text-align: center;
+
    padding-top: 10rem;
+
  }
+
  .logo {
+
    height: 3rem;
+
  }
+
  .text-center {
+
    text-align: center;
+
    margin: auto;
+
  }
+
  .form {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    margin-top: 1.5rem;
+
    width: 23rem;
+
  }
+
  .label {
+
    margin-bottom: 0.5rem;
+
  }
+
  .hint {
+
    padding: 0.25rem 0 0 0.25rem;
+
    gap: 0.25rem;
+
  }
+
</style>
+

+
<div class="container">
+
  <img src={logo} alt="Radicle Space Invader" class="logo" />
+

+
  <div class="txt-medium txt-bold">Unlock keys</div>
+
  <div class="txt-small">Your passphrase is needed to unlock your keys.</div>
+
  <div class="form">
+
    <div>
+
      <Border stylePadding="0.5rem 0.75rem" variant="ghost" flatBottom={true}>
+
        <div style:text-align="left">
+
          <div class="label txt-tiny">Alias</div>
+
          <div>
+
            {profile.alias}
+
          </div>
+
        </div>
+
      </Border>
+
      <Border stylePadding="0.5rem 0.75rem" variant="ghost" flatTop={true}>
+
        <div style:text-align="left">
+
          <div class="label txt-tiny">DID</div>
+
          <div>
+
            {truncateDid(profile.did)}
+
          </div>
+
        </div>
+
      </Border>
+
    </div>
+
    <div style:text-align="left">
+
      <div class="label txt-tiny">Passphrase</div>
+
      <TextInput
+
        autofocus
+
        onSubmit={() => {
+
          if (passphrase.length > 0) {
+
            authInProgress = true;
+
            void authenticate().finally(() => {
+
              authInProgress = false;
+
            });
+
          }
+
        }}
+
        oninput={() => {
+
          error = undefined;
+
        }}
+
        placeholder="Enter passphrase to unlock your keys"
+
        type="password"
+
        bind:value={passphrase} />
+
      {#if error?.code === "PassphraseError.InvalidPassphrase"}
+
        <div
+
          style="color: var(--color-foreground-red);"
+
          class="hint txt-small global-flex">
+
          <Icon name="warning" />
+
          <span>Not able to decrypt keys with provided passphrase.</span>
+
        </div>
+
      {/if}
+
    </div>
+
    <Button
+
      disabled={authInProgress || passphrase.length === 0}
+
      variant="secondary"
+
      onclick={() => {
+
        if (passphrase.length > 0) {
+
          authInProgress = true;
+
          void authenticate().finally(() => {
+
            authInProgress = false;
+
          });
+
        }
+
      }}>
+
      <div class="global-flex text-center">
+
        {#if authInProgress}
+
          <Icon name="time" /> Unlocking…
+
        {:else}
+
          <Icon name="lock" /> Unlock
+
        {/if}
+
      </div>
+
    </Button>
+
  </div>
+
</div>
added src/views/booting/CreateIdentity.svelte
@@ -0,0 +1,218 @@
+
<script lang="ts">
+
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
+

+
  import * as router from "@app/lib/router";
+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import logo from "@app/lib/images/radicle.svg?url";
+
  import { invoke } from "@app/lib/invoke";
+
  import { createEventEmittersOnce } from "@app/lib/startup.svelte";
+
  import { debounce } from "lodash";
+

+
  let passphrase = $state("");
+
  let notMatchingPassphrases = $state<boolean>();
+
  let passphraseRepeat = $state("");
+
  let alias = $state("");
+
  const errors = $state<{ alias: ErrorWrapper[]; passphrase: ErrorWrapper[] }>({
+
    alias: [],
+
    passphrase: [],
+
  });
+

+
  const validatePassphraseRepeat = debounce(() => {
+
    if (passphrase !== passphraseRepeat && passphraseRepeat.length !== 0) {
+
      notMatchingPassphrases = true;
+
    }
+
  }, 400);
+

+
  function validateInput(field: "alias" | "passphrase") {
+
    if (field === "alias" && alias.length === 0) {
+
      errors.alias.push({ code: "AliasError.EmptyAlias" });
+
    }
+
    if (field === "alias" && alias.length > 32) {
+
      errors.alias.push({ code: "AliasError.TooLongAlias" });
+
    }
+
    if (field === "alias" && alias.includes(" ")) {
+
      errors.alias.push({ code: "AliasError.InvalidAlias" });
+
    }
+
    if (field === "passphrase" && passphrase.length === 0) {
+
      errors.passphrase.push({ code: "PassphraseError.InvalidPassphrase" });
+
    }
+
  }
+

+
  const validAlias = $derived(
+
    alias.length > 0 && alias.length <= 32 && !alias.includes(" "),
+
  );
+
  const validPassphrase = $derived(
+
    passphrase.length > 0 && passphrase === passphraseRepeat,
+
  );
+

+
  async function handleKeydown() {
+
    if (passphrase !== passphraseRepeat) {
+
      notMatchingPassphrases = true;
+

+
      return;
+
    }
+
    try {
+
      await invoke("init", { passphrase, alias });
+
      await invoke("startup");
+
      await invoke("authenticate", { passphrase });
+
      // Clearing the passphrases from memory.
+
      passphrase = "";
+
      passphraseRepeat = "";
+

+
      if (window.__TAURI_INTERNALS__) {
+
        await createEventEmittersOnce();
+
      }
+

+
      void router.loadFromLocation();
+
    } catch (err) {
+
      const e = err as ErrorWrapper;
+
      if (e.code.startsWith("AliasError")) {
+
        errors.alias.push(e);
+
      } else if (e.code.startsWith("PassphraseError")) {
+
        errors.passphrase.push(e);
+
      }
+
      console.error(err);
+
    }
+

+
    return;
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    align-items: center;
+
    gap: 1rem;
+
    text-align: center;
+
    padding-top: 10rem;
+
  }
+
  .logo {
+
    height: 3rem;
+
  }
+
  .text-center {
+
    text-align: center;
+
    margin: auto;
+
  }
+
  .form {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    margin-top: 1.5rem;
+
    width: 23rem;
+
  }
+
  .label {
+
    margin-bottom: 0.5rem;
+
  }
+
  .hint {
+
    padding: 0.25rem 0 0 0.25rem;
+
    gap: 0.25rem;
+
  }
+
</style>
+

+
<div class="container">
+
  <img src={logo} alt="Radicle Space Invader" class="logo" />
+

+
  <div class="txt-medium txt-bold">Log in to Radicle Desktop</div>
+
  <div class="txt-small">
+
    Create a Radicle identity in order to use the app.
+
  </div>
+

+
  <div class="form">
+
    <div style:text-align="left">
+
      <div class="label txt-tiny">Alias (required)</div>
+
      <TextInput
+
        autofocus
+
        onSubmit={handleKeydown}
+
        oninput={() => {
+
          errors.alias = [];
+
          if (alias.length > 0) {
+
            validateInput("alias");
+
          }
+
        }}
+
        placeholder="Enter desired alias"
+
        type="text"
+
        bind:value={alias}></TextInput>
+
      {#if errors.alias.some(e => e.code.startsWith("AliasError"))}
+
        {#each errors.alias as error}
+
          <div
+
            style="color: var(--color-foreground-red);"
+
            class="hint txt-small global-flex">
+
            <Icon name="warning" />
+
            {#if error.code === "AliasError.EmptyAlias"}
+
              <span>Alias cannot be empty.</span>
+
            {:else if error.code === "AliasError.TooLongAlias"}
+
              <span>Alias is too long, make it less than 32 characters.</span>
+
            {:else if error.code === "AliasError.InvalidAlias"}
+
              <span>Alias cannot contain whitespace.</span>
+
            {/if}
+
          </div>
+
        {/each}
+
      {:else}
+
        <div class="hint txt-small txt-missing global-flex">
+
          <Icon name="info" /> Max 32 characters, no whitespace.
+
        </div>
+
      {/if}
+
    </div>
+
    <div>
+
      <div style:text-align="left" style:margin-bottom="0.5rem">
+
        <div class="label txt-tiny">Passphrase (required)</div>
+
        <TextInput
+
          onSubmit={handleKeydown}
+
          oninput={() => {
+
            errors.passphrase = [];
+
            notMatchingPassphrases = false;
+
            if (passphrase.length > 0) {
+
              validateInput("passphrase");
+
            }
+
          }}
+
          placeholder="Enter passphrase to protect your keys"
+
          type="password"
+
          bind:value={passphrase}></TextInput>
+
        {#if errors.passphrase.some(e => e.code.startsWith("PassphraseError"))}
+
          {#each errors.passphrase as error}
+
            <div
+
              style="color: var(--color-foreground-red);"
+
              class="hint txt-small global-flex">
+
              <Icon name="warning" />
+
              {#if error.code === "PassphraseError.InvalidPassphrase"}
+
                <span>Passphrase cannot be empty.</span>
+
              {:else}
+
                <span>{error.message}</span>
+
              {/if}
+
            </div>
+
          {/each}
+
        {/if}
+
      </div>
+
      <div style:text-align="left">
+
        <TextInput
+
          onSubmit={handleKeydown}
+
          oninput={() => {
+
            errors.passphrase = [];
+
            notMatchingPassphrases = false;
+
            validatePassphraseRepeat();
+
          }}
+
          placeholder="Repeat passphrase"
+
          type="password"
+
          bind:value={passphraseRepeat}></TextInput>
+
        {#if notMatchingPassphrases}
+
          <div
+
            style="color: var(--color-foreground-red);"
+
            class="hint txt-small global-flex">
+
            <Icon name="warning" /> Passphrases don't match
+
          </div>
+
        {/if}
+
      </div>
+
    </div>
+
    <Button
+
      disabled={!(validAlias && validPassphrase)}
+
      variant="secondary"
+
      onclick={handleKeydown}>
+
      <div class="global-flex text-center">
+
        <Icon name="seedling" />Create new identity
+
      </div>
+
    </Button>
+
  </div>
+
</div>
added src/views/home/Onboarding.svelte
@@ -0,0 +1,508 @@
+
<script lang="ts">
+
  import { SvelteMap } from "svelte/reactivity";
+
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
+
  import type { Status, Type } from "@app/components/ActionItem.svelte";
+
  import type { Seed } from "@bindings/repo/Seed";
+

+
  import initialize from "@app/views/home/initialize.md?raw";
+
  import { invoke } from "@app/lib/invoke";
+
  import { nodeRunning } from "@app/lib/events";
+
  import { didFromPublicKey } from "@app/lib/utils";
+
  import { sleep } from "@app/lib/time";
+

+
  import ActionItem from "@app/components/ActionItem.svelte";
+
  import Border from "@app/components/Border.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import Tab from "@app/components/Tab.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import Textarea from "@app/components/Textarea.svelte";
+

+
  const GETTING_STARTED_REPO_RID = "rad:z3Wu3dtimpnoMAveMDFGty9UCdv9b";
+

+
  const { reload }: { reload: () => Promise<void> } = $props();
+

+
  type Action = {
+
    caption: string;
+
    key: string;
+
    status?: Status;
+
    error?: ErrorWrapper;
+
  };
+
  const errors = $state<{
+
    fetch: ErrorWrapper[];
+
    repoName: ErrorWrapper[];
+
    repoDescription: ErrorWrapper[];
+
  }>({
+
    fetch: [],
+
    repoName: [],
+
    repoDescription: [],
+
  });
+
  const nodeStartTimeout = 10_000;
+
  let passphrase = $state("");
+
  let tab = $state<"fetch" | "create" | "init">("fetch");
+
  let repoName = $state<string>("");
+
  let repoDescription = $state<string>("");
+
  let fetchInProgress = $state(false);
+
  let existingRunningNode = $state(false);
+
  let actions = $state<
+
    SvelteMap<
+
      string,
+
      {
+
        caption: string;
+
        status?: Status;
+
        error?: ErrorWrapper;
+
        subActions: Omit<Action, "key">[];
+
      }
+
    >
+
  >(
+
    new SvelteMap([
+
      ["start", { caption: "Start node", subActions: [] }],
+
      ["fetch", { caption: "Fetch getting-started repo", subActions: [] }],
+
      ["stop", { caption: "Stop node", subActions: [] }],
+
    ]),
+
  );
+

+
  const disableFetchButton = $derived(
+
    fetchInProgress || passphrase.length === 0,
+
  );
+

+
  function validateInput(field: "name" | "description") {
+
    if (field === "name" && !validRepoName) {
+
      errors.repoName.push({ code: "ProjectError.InvalidName" });
+
    }
+
    if (field === "description" && !validRepoDescription) {
+
      errors.repoDescription.push({ code: "ProjectError.InvalidDescription" });
+
    }
+
  }
+

+
  const validRepoName = $derived(/^[a-zA-Z0-9._-]+$/.test(repoName));
+
  const validRepoDescription = $derived(repoDescription.length <= 255);
+

+
  async function createRepo() {
+
    try {
+
      await invoke("create_repo", {
+
        name: repoName,
+
        description: repoDescription,
+
        defaultBranch: "master",
+
      });
+
      void reload();
+
    } catch (err) {
+
      const e = err as ErrorWrapper;
+
      if (e.code === "ProjectError.InvalidName") {
+
        errors.repoName.push(e);
+
      } else if (e.code === "ProjectError.InvalidDescription") {
+
        errors.repoDescription.push(e);
+
      }
+
      console.error(err);
+
    }
+
  }
+

+
  function resetStatus() {
+
    actions = new SvelteMap([
+
      ["start", { caption: "Start node", subActions: [] }],
+
      ["fetch", { caption: "Fetch getting-started repo", subActions: [] }],
+
      ["stop", { caption: "Stop node", subActions: [] }],
+
    ]);
+
  }
+

+
  function updateStatus(
+
    key: Type,
+
    status: Status,
+
    subActions: Omit<Action, "key">[] = [],
+
    error?: ErrorWrapper,
+
  ) {
+
    const item = actions.get(key)!;
+
    if (subActions.length === 0) {
+
      actions.set(key, { ...item, status, subActions: item.subActions, error });
+
    } else {
+
      actions.set(key, { ...item, status, subActions, error });
+
    }
+
  }
+

+
  async function fetchDemoRepo() {
+
    fetchInProgress = true;
+
    resetStatus();
+
    try {
+
      if ($nodeRunning) {
+
        existingRunningNode = true;
+
        updateStatus("start", "done");
+
      } else {
+
        updateStatus("start", "running");
+
        const time = Date.now();
+
        void invoke("start_node", { passphrase }).catch(err => {
+
          const e = err as ErrorWrapper;
+
          updateStatus("start", "error", [], e);
+
        });
+
        while ($nodeRunning === false) {
+
          await sleep(500);
+
          if (Date.now() > time + nodeStartTimeout) {
+
            updateStatus("start", "error", [], {
+
              code: "NodeError.TimedOut",
+
              message: "Node did not start, please retry fetching.",
+
            });
+
            fetchInProgress = false;
+
            return;
+
          }
+
        }
+
        updateStatus("start", "done");
+
      }
+
    } catch (err) {
+
      const e = err as ErrorWrapper;
+
      updateStatus("start", "error", [], e);
+
      fetchInProgress = false;
+
    }
+

+
    const fetchIntents: Omit<Action, "key">[] = [];
+
    updateStatus("fetch", "running");
+
    const [connected, disconnected] = await invoke<[Seed[], Seed[]]>(
+
      "node_seeds",
+
      { rid: GETTING_STARTED_REPO_RID },
+
    );
+

+
    for (const seed of connected) {
+
      fetchIntents.push({
+
        caption: `Fetch from ${seed.addrs[0].addr}`,
+
        status: "running",
+
      });
+
      updateStatus("fetch", "running", fetchIntents);
+
      try {
+
        await invoke("fetch_repo", {
+
          rid: GETTING_STARTED_REPO_RID,
+
          from: didFromPublicKey(seed.nid),
+
        });
+
        updateStatus("fetch", "running", [
+
          ...(fetchIntents.length > 1 ? fetchIntents.slice(0, -2) : []),
+
          { caption: `Fetch from ${seed.addrs[0].addr}`, status: "done" },
+
        ]);
+
        updateStatus("fetch", "done");
+

+
        // We obtained a copy of the demo repo.
+
        await stopNode();
+
        return;
+
      } catch {
+
        continue;
+
      }
+
    }
+

+
    // We try to reconnect to disconnected seeds to get the demo repo.
+
    for (const seed of disconnected) {
+
      if (seed.addrs.length === 0) {
+
        continue;
+
      }
+

+
      fetchIntents.push({
+
        caption: `Reconnect to ${seed.addrs[0].addr}`,
+
        status: "running",
+
      });
+
      updateStatus("fetch", "running", fetchIntents);
+
      try {
+
        await invoke("connect_node", {
+
          from: didFromPublicKey(seed.nid),
+
          address: seed.addrs[0].addr,
+
        });
+
        updateStatus("fetch", "running", [
+
          ...(fetchIntents.length > 1 ? fetchIntents.slice(0, -2) : []),
+
          { caption: `Reconnect to ${seed.addrs[0].addr}`, status: "done" },
+
        ]);
+
      } catch (err) {
+
        const e = err as ErrorWrapper;
+
        updateStatus("fetch", "running", [
+
          ...(fetchIntents.length > 1 ? fetchIntents.slice(0, -2) : []),
+
          {
+
            caption: `Reconnect to ${seed.addrs[0].addr}`,
+
            status: "error",
+
            error: e,
+
          },
+
        ]);
+

+
        continue;
+
      }
+

+
      fetchIntents.push({
+
        caption: `Fetch from ${seed.addrs[0].addr}`,
+
        status: "running",
+
      });
+
      updateStatus("fetch", "running", fetchIntents);
+
      try {
+
        await invoke("fetch_repo", {
+
          rid: GETTING_STARTED_REPO_RID,
+
          from: didFromPublicKey(seed.nid),
+
        });
+
        updateStatus("fetch", "running", [
+
          ...(fetchIntents.length > 1 ? fetchIntents.slice(0, -2) : []),
+
          { caption: `Fetch from ${seed.addrs[0].addr}`, status: "done" },
+
        ]);
+
        updateStatus("fetch", "done");
+
      } catch (err) {
+
        const e = err as ErrorWrapper;
+
        updateStatus("fetch", "running", [
+
          ...(fetchIntents.length > 1 ? fetchIntents.slice(0, -2) : []),
+
          {
+
            caption: `Fetch from ${seed.addrs[0].addr}`,
+
            status: "error",
+
            error: e,
+
          },
+
        ]);
+
        continue;
+
      }
+
    }
+

+
    await stopNode();
+
  }
+

+
  async function stopNode() {
+
    try {
+
      // We only stop radicle-node processes we spawned ourselves.
+
      if (!existingRunningNode) {
+
        updateStatus("stop", "running");
+
        await invoke("stop_node");
+
        updateStatus("stop", "done");
+
        await sleep(1000);
+
        fetchInProgress = false;
+
        await reload();
+
      }
+
    } catch (err) {
+
      const e = err as ErrorWrapper;
+
      updateStatus("stop", "error", [], e);
+
      fetchInProgress = false;
+
    }
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1rem;
+
  }
+
  .label {
+
    margin-bottom: 0.5rem;
+
  }
+
  .form {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
  }
+
  .grid {
+
    display: grid;
+
    width: 100%;
+
    row-gap: 0.5rem;
+
    grid-template-columns: 40% 50%;
+
  }
+

+
  .tab {
+
    height: 24px;
+
    color: var(--color-foreground-contrast);
+
  }
+
  .hint {
+
    padding: 0.25rem 0 0 0.25rem;
+
    gap: 0.25rem;
+
  }
+
  .create-repo-container {
+
    display: flex;
+
    gap: 2rem;
+
  }
+
</style>
+

+
{#snippet tabSnippet(name: typeof tab, content: string)}
+
  <Tab
+
    active={tab === name}
+
    onclick={() => {
+
      tab = name;
+
    }}>
+
    <span class="tab">{content}</span>
+
  </Tab>
+
{/snippet}
+

+
<div class="txt-missing txt-small" style:margin-bottom="1.5rem">
+
  You don't have any repositories in your Radicle storage yet. To get started,
+
  try one of the options below.
+
</div>
+
<Border
+
  stylePosition="relative"
+
  variant="ghost"
+
  flatBottom
+
  styleDisplay="flex"
+
  styleWidth="100%"
+
  styleGap="1rem"
+
  stylePadding="0 1rem">
+
  {@render tabSnippet("fetch", "Fetch a demo repo")}
+
  {@render tabSnippet("create", "Create a new repo")}
+
  {@render tabSnippet("init", "Initialize existing repo")}
+
</Border>
+

+
<Border
+
  variant="ghost"
+
  flatTop
+
  stylePadding="1rem"
+
  styleDisplay="block"
+
  styleFlexDirection="column"
+
  styleAlignItems="flex-start">
+
  {#if tab === "fetch"}
+
    <div class="container txt-small">
+
      <div>To fetch your first repo, we'll execute the following steps:</div>
+
      <Border
+
        styleBackgroundColor="var(--color-background-float)"
+
        styleFlexDirection="column"
+
        styleAlignItems="flex-start"
+
        stylePadding="1rem"
+
        variant="ghost">
+
        <div class="grid">
+
          {#each actions as [_key, action], position}
+
            <ActionItem
+
              error={action.error}
+
              status={action.status}
+
              position={`${(position + 1).toString()}.`}>
+
              <div class="txt-monospace">{action.caption}</div>
+
            </ActionItem>
+

+
            {#each action.subActions as subAction, subPosition}
+
              <ActionItem
+
                error={subAction.error}
+
                status={subAction.status}
+
                position={`${(position + 1).toString()}.${subPosition}.`}>
+
                <div class="txt-monospace">{subAction.caption}</div>
+
              </ActionItem>
+
            {/each}
+
          {/each}
+
        </div>
+
      </Border>
+

+
      <div style:width="23rem">
+
        <div>
+
          <div class="label txt-tiny">Passphrase</div>
+
          <TextInput
+
            placeholder="Enter passphrase to be able to start your node."
+
            oninput={() => {
+
              resetStatus();
+
              fetchInProgress = false;
+
            }}
+
            type="password"
+
            onSubmit={fetchDemoRepo}
+
            bind:value={passphrase} />
+
          {#if actions.get("start")?.error?.code === "PassphraseError.InvalidPassphrase"}
+
            <div
+
              style="color: var(--color-foreground-red);"
+
              class="hint txt-small global-flex">
+
              <Icon name="warning" />
+
              <span>Not able to decrypt keys with provided passphrase.</span>
+
            </div>
+
          {/if}
+
        </div>
+
      </div>
+
      <div style:width="max-content">
+
        <Button
+
          variant="secondary"
+
          disabled={disableFetchButton}
+
          onclick={fetchDemoRepo}>
+
          <span style:text-align="center">Fetch the demo repo</span>
+
        </Button>
+
      </div>
+
    </div>
+
  {:else if tab === "create"}
+
    <div class="txt-small create-repo-container">
+
      <div class="container" style="width: 50%;">
+
        <div>Create a new repo initialized with Radicle.</div>
+

+
        <div class="form">
+
          <div style:text-align="left">
+
            <div class="label txt-tiny">Repository name (required)</div>
+
            <TextInput
+
              bind:value={repoName}
+
              oninput={() => {
+
                errors.repoName = [];
+
                validateInput("name");
+
              }}
+
              placeholder="Name of your repo" />
+
            {#if errors.repoName.length > 0}
+
              {#each errors.repoName as error}
+
                {#if error.code === "ProjectError.InvalidName" && repoName.length > 0}
+
                  <div
+
                    style="color: var(--color-foreground-red);"
+
                    class="hint txt-small global-flex">
+
                    <Icon name="warning" />
+
                    <span>
+
                      Only alphanumeric characters, '-', '_' and '.' are
+
                      allowed.
+
                    </span>
+
                  </div>
+
                {/if}
+
              {/each}
+
            {/if}
+
          </div>
+

+
          <div style:text-align="left">
+
            <div class="label txt-tiny">Description</div>
+
            <Textarea
+
              borderVariant="ghost"
+
              placeholder="Add description"
+
              oninput={() => {
+
                errors.repoDescription = [];
+
                validateInput("description");
+
              }}
+
              submit={async () => {
+
                await invoke("create_repo", {
+
                  name: repoName,
+
                  description: repoDescription,
+
                  defaultBranch: "master",
+
                });
+
                void reload();
+
              }}
+
              bind:value={repoDescription} />
+
            {#if errors.repoDescription.length > 0}
+
              {#each errors.repoDescription as error}
+
                <div
+
                  style="color: var(--color-foreground-red);"
+
                  class="hint txt-small global-flex">
+
                  <Icon name="warning" />
+
                  {#if error.code === "ProjectError.InvalidDescription"}
+
                    <span>Description cannot exceed 255 characters.</span>
+
                  {:else}
+
                    <span>{error.message}</span>
+
                  {/if}
+
                </div>
+
              {/each}
+
            {/if}
+
          </div>
+
          <div style:width="max-content">
+
            <Button
+
              disabled={!(validRepoDescription && validRepoName)}
+
              variant="secondary"
+
              onclick={createRepo}>
+
              Create new repo
+
            </Button>
+
          </div>
+
        </div>
+
      </div>
+
      <Border
+
        styleHeight="max-content"
+
        styleDisplay="flex"
+
        styleFlexDirection="column"
+
        styleAlignItems="flex-start"
+
        stylePadding="1rem"
+
        styleGap="0.5rem"
+
        variant="float"
+
        styleBackgroundColor="var(--color-background-float)">
+
        <div>👾</div>
+
        <div class="txt-bold txt-regular">Did you know?</div>
+
        <div>
+
          This repository will be stored directly in Radicle, instead of a
+
          folder on your computer. That means you don't need to choose a
+
          location or manually create a folder.
+
          <p>
+
            Want to learn more about how Radicle storage works compared to a
+
            regular Git working copy?
+
          </p>
+
          <!-- For handling whitespace -->
+
          <!-- prettier-ignore -->
+
          <span>Check out the <a target="_blank" class="txt-missing global-link" href="https://radicle.xyz/guides/protocol#local-first-storage">Protocol Guide</a>.</span>
+
        </div>
+
      </Border>
+
    </div>
+
  {:else}
+
    <div class="container txt-small">
+
      <Markdown rid="" content={initialize} />
+
    </div>
+
  {/if}
+
</Border>
modified src/views/home/Repos.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
  import type { HomeReposTab } from "@app/lib/router/definitions";
  import type { Config } from "@bindings/config/Config";
  import type { NotificationCount } from "@bindings/cob/inbox/NotificationCount";
@@ -7,13 +8,15 @@

  import * as router from "@app/lib/router";
  import { didFromPublicKey } from "@app/lib/utils";
+
  import { dynamicInterval } from "@app/lib/time";
+
  import { invoke } from "@app/lib/invoke";
+
  import { onMount } from "svelte";

  import CopyableId from "@app/components/CopyableId.svelte";
  import HomeSidebar from "@app/components/HomeSidebar.svelte";
  import Layout from "@app/views/repo/Layout.svelte";
+
  import Onboarding from "@app/views/home/Onboarding.svelte";
  import RepoCard from "@app/components/RepoCard.svelte";
-
  import Border from "@app/components/Border.svelte";
-
  import Icon from "@app/components/Icon.svelte";

  interface Props {
    activeTab?: HomeReposTab;
@@ -24,9 +27,49 @@
  }

  /* eslint-disable prefer-const */
-
  let { config, repos, notificationCount, repoCount, activeTab }: Props =
+
  let {
+
    config,
+
    repos: initialRepos,
+
    notificationCount,
+
    repoCount,
+
    activeTab,
+
  }: Props =
    /* eslint-enable prefer-const */
    $props();
+

+
  let repos = $state(initialRepos);
+
  let lock = false;
+
  const startup = $state<{ error?: ErrorWrapper }>({ error: undefined });
+

+
  async function checkRepos() {
+
    try {
+
      if (lock) {
+
        return;
+
      }
+
      if (repos.length > 0) {
+
        return;
+
      }
+
      lock = true;
+
      await reload();
+
    } catch (err) {
+
      const error = err as ErrorWrapper;
+
      startup.error = error;
+
    } finally {
+
      lock = false;
+
    }
+
  }
+

+
  onMount(() => {
+
    dynamicInterval("repos", checkRepos, 5_000);
+
  });
+

+
  async function reload() {
+
    [repos, repoCount, config] = await Promise.all([
+
      invoke<RepoInfo[]>("list_repos", { show: "all" }),
+
      invoke<RepoCount>("repo_count"),
+
      invoke<Config>("config"),
+
    ]);
+
  }
</script>

<style>
@@ -65,7 +108,7 @@
  {/snippet}
  <div class="container">
    <div class="header">Repositories</div>
-
    {#if repos.length}
+
    {#if repos.length > 0}
      <div class="repo-grid">
        {#each repos as repo}
          {#if repo.payloads["xyz.radicle.project"]}
@@ -83,20 +126,7 @@
        {/each}
      </div>
    {:else}
-
      <Border
-
        variant="ghost"
-
        styleAlignItems="center"
-
        styleJustifyContent="center">
-
        <div
-
          class="global-flex"
-
          style:height="74px"
-
          style:justify-content="center">
-
          <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
-
            <Icon name="none" />
-
            No repositories.
-
          </div>
-
        </div>
-
      </Border>
+
      <Onboarding {reload} />
    {/if}
  </div>
</Layout>
added src/views/home/initialize.md
@@ -0,0 +1,47 @@
+
### 1. Install Radicle CLI
+

+
Run this command in your terminal to install Radicle:
+

+
```
+
$ curl -sSf https://radicle.xyz/install | sh
+
```
+

+
### 2. Verify the installation:
+

+
Check if the Radicle CLI is installed correctly:
+

+
```
+
$ rad --version
+
rad 1.1.0 (70f0cc35)
+
```
+

+
### 3. Initialize Your Repository:
+

+
Navigate to your existing Git repository and initialize it with Radicle:
+

+
```
+
$ cd path/to/your/repository
+
$ rad init
+
```
+

+
### 4. Follow the Setup Prompts:
+

+
You'll be prompted to provide:
+

+
- Repository Name - Give your repository a name.
+
- Description: - A short summary of what your repository does.
+
- Default branch: Usually main or master.
+
- Visibility: Choose public to share with others or private to keep it restricted.
+

+
### 5. Get Your Repository Identifier (RID):
+

+
After setup, your repository will be assigned a unique ID. You can view it anytime with:
+

+
To view your repository's RID at any time:
+

+
```
+
$ rad .
+
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
+
```
+

+
That's it! Your repository is now a Radicle repo. 🚀
deleted tests/e2e/authenticate.spec.ts
@@ -1,21 +0,0 @@
-
import { test, expect } from "@tests/support/fixtures.js";
-

-
test("removing identities from ssh-agent and re-adding them", async ({
-
  page,
-
  peer,
-
}) => {
-
  await page.goto("/");
-
  await expect(
-
    page.getByText("z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"),
-
  ).toBeVisible();
-

-
  await peer.logOut();
-
  await expect(
-
    page.getByText("Not able to find your keys in the ssh agent"),
-
  ).toBeVisible();
-

-
  await peer.authenticate();
-
  await expect(
-
    page.getByText("z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"),
-
  ).toBeVisible();
-
});
modified tests/support/peerManager.ts
@@ -13,7 +13,7 @@ import { defaultConfig, type Config } from "@tests/support/fixtures.js";
import { execa } from "execa";
import { logPrefix } from "@tests/support/logPrefix.js";
import { randomTag } from "@tests/support/support.js";
-
import { sleep } from "@app/lib/sleep.js";
+
import { sleep } from "@app/lib/time.js";

export type RefsUpdate =
  | { updated: { name: string; old: string; new: string } }
modified tsconfig.json
@@ -21,7 +21,6 @@
    "paths": {
      "@app/*": ["./src/*"],
      "@bindings/*": ["./crates/radicle-types/bindings/*"],
-
      "@public/*": ["./public/*"],
      "@tests/*": ["./tests/*"]
    }
  }
modified vite.config.ts
@@ -26,7 +26,6 @@ export default defineConfig({
    alias: {
      "@app": path.resolve("./src"),
      "@bindings": path.resolve("./crates/radicle-types/bindings/"),
-
      "@public": path.resolve("./public"),
    },
  },
});