forked from Keelrleek/SoulStream
the freenet layer: adapter, persona and profile services, the contracts bundled as commitments
This commit is contained in:
1 parent
63a0357651
commit
6dce4e2ac7
16 files changed
+3337
-10
No files matched your search
Generated
+461
-10
@@ -165,13 +165,17 @@ version = "1.5.0-alpha"
|
||||
dependencies = [
|
||||
"audio-formats",
|
||||
"base64 0.22.1",
|
||||
"bincode",
|
||||
"bs58",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"freenet-adapter",
|
||||
"image",
|
||||
"keyring",
|
||||
"library",
|
||||
"log",
|
||||
"metadata",
|
||||
"persona-state",
|
||||
"player",
|
||||
"quick-xml 0.37.5",
|
||||
"realfft",
|
||||
@@ -259,6 +263,21 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -301,6 +320,20 @@ dependencies = [
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -310,6 +343,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
@@ -568,6 +610,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -584,6 +632,18 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -693,6 +753,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -746,6 +815,15 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.36.0"
|
||||
@@ -785,6 +863,42 @@ version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.10.7",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -873,6 +987,16 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -909,8 +1033,19 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1046,6 +1181,31 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
@@ -1183,6 +1343,12 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1199,6 +1365,16 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flatbuffers"
|
||||
version = "24.12.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
@@ -1272,6 +1448,60 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freenet-adapter"
|
||||
version = "1.5.0-alpha"
|
||||
dependencies = [
|
||||
"bs58",
|
||||
"freenet-stdlib",
|
||||
"log",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freenet-macros"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26e5c6bee9ffa0e2683bc8b41a7bd9f1e57e7fd49ac4f6ae433e5a82e9006df"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freenet-stdlib"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b62aeba7634d1ae5c2d02f78db33ee40442511858275be5be2099d24a13b2559"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"blake3",
|
||||
"bs58",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"flatbuffers",
|
||||
"freenet-macros",
|
||||
"futures",
|
||||
"js-sys",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -1793,6 +2023,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
@@ -2021,7 +2260,7 @@ checksum = "76b0d7d4541def58a37bf8efc559683f21edce7c82f0d866c93ac21f7e098f93"
|
||||
dependencies = [
|
||||
"attohttpc",
|
||||
"log",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"url",
|
||||
"xmltree",
|
||||
]
|
||||
@@ -2412,6 +2651,15 @@ dependencies = [
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
@@ -2419,7 +2667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2572,6 +2820,15 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
@@ -3031,6 +3288,16 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "persona-state"
|
||||
version = "1.5.0-alpha"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"blake3",
|
||||
"ed25519-dalek",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
@@ -3090,6 +3357,16 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
@@ -3348,8 +3625,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3359,7 +3646,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3371,6 +3668,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3661,7 +3967,7 @@ dependencies = [
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3910,6 +4216,17 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-wasm-bindgen"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
@@ -4058,6 +4375,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -4065,8 +4393,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4075,6 +4412,15 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -4188,6 +4534,16 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -4959,6 +5315,15 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.53"
|
||||
@@ -5025,6 +5390,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
@@ -5062,6 +5428,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -5247,9 +5625,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -5257,6 +5647,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5297,6 +5717,23 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.4.2",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -5393,6 +5830,12 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-width"
|
||||
version = "0.1.9"
|
||||
@@ -5423,6 +5866,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "value-bag"
|
||||
version = "1.12.0"
|
||||
@@ -5510,6 +5959,8 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -9,6 +9,8 @@ members = [
|
||||
"crates/metadata",
|
||||
"crates/library",
|
||||
"crates/audio-formats",
|
||||
"crates/freenet-adapter",
|
||||
"crates/persona-state",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "freenet-adapter"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
# The narrow, versioned boundary around a LOCAL Freenet node
|
||||
# (TODO-Feature-Docs/soulseek-social-chat-and-sharing/, supplement design
|
||||
# §5.3), hardened by the F0–F7+F2b spike records in research/freenet-*/.
|
||||
#
|
||||
# Pins: freenet-stdlib =0.8.5 is what node 0.2.132 speaks (bincode wire);
|
||||
# tokio-tungstenite must match the stdlib's minor or the stream type the
|
||||
# WebApi takes will not unify. Bump BOTH together, against a tested node.
|
||||
|
||||
[dependencies]
|
||||
freenet-stdlib = { version = "=0.8.5", features = ["net"] }
|
||||
tokio-tungstenite = "0.27.0"
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
log.workspace = true
|
||||
thiserror.workspace = true
|
||||
bs58 = "0.5"
|
||||
@@ -0,0 +1,789 @@
|
||||
//! The FreenetAdapter — SoulStream's ONLY door to a local Freenet node.
|
||||
//!
|
||||
//! Everything here follows the supplement design (§5.3) and the rules the
|
||||
//! F0–F7+F2b spikes recorded (research/freenet-*/RUN-RECORD.md):
|
||||
//!
|
||||
//! - **Off by default, kill-switchable.** `enable()` is the only thing that
|
||||
//! spawns work; `disable()` stops the supervisor, drops the connection,
|
||||
//! and ends every retry. A missing/stopped/incompatible node must never
|
||||
//! affect Soulseek or playback — nothing in here is on any audio path.
|
||||
//! - **The privilege boundary is this crate.** A loopback Freenet API is
|
||||
//! fully privileged and unauthenticated (F0): whatever can reach it owns
|
||||
//! the node. The webview therefore gets STATUS ONLY through the Tauri
|
||||
//! layer — never a node handle, never raw state bytes.
|
||||
//! - **Detection is cheap and three-valued** (absent / occupied-not-api /
|
||||
//! compatible), with its own short timeouts: a refused loopback connect
|
||||
//! costs ~2 s on Windows (F0), so the TCP probe gets 600 ms. A TYPED host
|
||||
//! error counts as compatible — local mode answers `ConnectedPeers` with
|
||||
//! "not supported" (F0), so refusals prove the protocol.
|
||||
//! - **One request in flight per connection.** The stdlib's `WebApi`
|
||||
//! correlates responses FIFO; the session serializes ops and routes
|
||||
//! unsolicited `UpdateNotification`s to a bounded event bus (lagging
|
||||
//! subscribers lose oldest events, honestly, rather than growing queues).
|
||||
//! - **Write rules from the spikes:** a node refuses the FIRST write to a
|
||||
//! contract it never held ("auto-fetch triggered", F5) — `update` retries
|
||||
//! over ~18 s and callers should prime with a GET (`get_then_update`);
|
||||
//! sequence-bearing states must be read-and-bumped by the caller (F5);
|
||||
//! a restarted node drops out of update propagation (F2) — services
|
||||
//! reconcile by relaying their own state after reconnect.
|
||||
//! - **Redacted diagnostics.** Logs carry ids, lengths and outcomes; never
|
||||
//! state bytes, never key material.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use freenet_stdlib::client_api::{
|
||||
ClientRequest, ContractRequest, ContractResponse, HostResponse, NodeQuery, WebApi,
|
||||
};
|
||||
use freenet_stdlib::prelude::*;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{broadcast, mpsc, oneshot, watch};
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub const DEFAULT_WS_PORT: u16 = 7509;
|
||||
|
||||
/// TCP probe budget — see the F0 note about Windows' 2 s refusal cost.
|
||||
const DETECT_TCP_TIMEOUT: Duration = Duration::from_millis(600);
|
||||
const DETECT_WS_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const DETECT_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const OP_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const PUT_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
/// "auto-fetch triggered" retry schedule (F5): 6 attempts, 3 s apart.
|
||||
const AUTOFETCH_RETRIES: u32 = 6;
|
||||
const AUTOFETCH_BACKOFF: Duration = Duration::from_secs(3);
|
||||
/// Reconnect backoff while enabled but the node is missing/lost.
|
||||
const SUPERVISOR_BACKOFF: Duration = Duration::from_secs(5);
|
||||
const CMD_QUEUE: usize = 32;
|
||||
const EVENT_QUEUE: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||
pub enum AdapterStatus {
|
||||
/// The kill switch position. Nothing runs, nothing retries.
|
||||
Disabled,
|
||||
Detecting,
|
||||
/// Nothing listens on the port. NOT an error — Freenet is optional.
|
||||
NodeAbsent,
|
||||
/// Something listens but does not speak the client API.
|
||||
NodeOccupied,
|
||||
/// Speaks WebSocket but the protocol exchange failed undecipherably.
|
||||
NodeIncompatible { detail: String },
|
||||
Connected,
|
||||
/// Connection lost while enabled; the supervisor is retrying.
|
||||
Lost { detail: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||
pub enum AdapterEvent {
|
||||
Status(AdapterStatus),
|
||||
/// A subscribed contract changed. Ids and lengths only — the webview
|
||||
/// never receives replicated bytes through the event path.
|
||||
ContractUpdate { instance: String, update_len: usize },
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AdapterError {
|
||||
#[error("freenet layer is disabled")]
|
||||
Disabled,
|
||||
#[error("freenet node is not connected ({0:?})")]
|
||||
NotConnected(AdapterStatus),
|
||||
#[error("request timed out")]
|
||||
Timeout,
|
||||
#[error("adapter is shutting down")]
|
||||
ShuttingDown,
|
||||
#[error("node refused: {0}")]
|
||||
Node(String),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, AdapterError>;
|
||||
|
||||
/// What `contract_put`/`update` return so callers can address the instance
|
||||
/// later without recomputing.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ContractIds {
|
||||
pub instance: String,
|
||||
pub code_hash: String,
|
||||
}
|
||||
|
||||
struct Cmd {
|
||||
req: ClientRequest<'static>,
|
||||
reply: oneshot::Sender<Result<HostResponse>>,
|
||||
op_timeout: Duration,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
ws_port: StdMutex<u16>,
|
||||
status: StdMutex<AdapterStatus>,
|
||||
events: broadcast::Sender<AdapterEvent>,
|
||||
/// Sender into the LIVE session, if any.
|
||||
session: StdMutex<Option<mpsc::Sender<Cmd>>>,
|
||||
/// Kill switch: flipping to false ends the supervisor and its session.
|
||||
enabled: watch::Sender<bool>,
|
||||
/// Distinguishes supervisor generations so a stale task can't clobber
|
||||
/// the status of a newer enable().
|
||||
generation: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FreenetAdapter {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl Default for FreenetAdapter {
|
||||
fn default() -> Self {
|
||||
let (enabled, _) = watch::channel(false);
|
||||
let (events, _) = broadcast::channel(EVENT_QUEUE);
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
ws_port: StdMutex::new(DEFAULT_WS_PORT),
|
||||
status: StdMutex::new(AdapterStatus::Disabled),
|
||||
events,
|
||||
session: StdMutex::new(None),
|
||||
enabled,
|
||||
generation: AtomicU64::new(0),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn on the caller's tokio runtime when there is one (async commands,
|
||||
/// tests), else on a runtime the adapter owns. `enable()` is reached from
|
||||
/// Tauri's event-loop thread — the setup hook, sync commands — where a bare
|
||||
/// `tokio::spawn` is a non-unwinding panic that takes the whole process down
|
||||
/// (0xc0000409, seen live 2026-09-02 on the first toggle of the switch).
|
||||
fn spawn_anywhere<F>(fut: F)
|
||||
where
|
||||
F: std::future::Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
match tokio::runtime::Handle::try_current() {
|
||||
Ok(handle) => {
|
||||
handle.spawn(fut);
|
||||
}
|
||||
Err(_) => {
|
||||
static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
|
||||
let rt = RT.get_or_init(|| {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_name("freenet-adapter")
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("freenet adapter runtime")
|
||||
});
|
||||
rt.spawn(fut);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl FreenetAdapter {
|
||||
pub fn set_ws_port(&self, port: u16) {
|
||||
*self.inner.ws_port.lock().unwrap() = port;
|
||||
}
|
||||
|
||||
pub fn ws_port(&self) -> u16 {
|
||||
*self.inner.ws_port.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn status(&self) -> AdapterStatus {
|
||||
self.inner.status.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
*self.inner.enabled.borrow()
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<AdapterEvent> {
|
||||
self.inner.events.subscribe()
|
||||
}
|
||||
|
||||
fn set_status(&self, generation: u64, s: AdapterStatus) {
|
||||
if self.inner.generation.load(Ordering::SeqCst) != generation {
|
||||
return; // a newer enable()/disable() owns the status now
|
||||
}
|
||||
*self.inner.status.lock().unwrap() = s.clone();
|
||||
let _ = self.inner.events.send(AdapterEvent::Status(s));
|
||||
}
|
||||
|
||||
/// Turn the layer on: spawns the supervisor, which detects, connects,
|
||||
/// and reconnects with backoff until `disable()`.
|
||||
pub fn enable(&self) {
|
||||
if self.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let generation = self.inner.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.inner.enabled.send_replace(true);
|
||||
let this = self.clone();
|
||||
spawn_anywhere(async move {
|
||||
this.supervisor(generation).await;
|
||||
});
|
||||
log::info!("freenet adapter enabled (gen {generation})");
|
||||
}
|
||||
|
||||
/// The kill switch. Ends the supervisor, drops the session, stops every
|
||||
/// retry. Callers see `Disabled` errors immediately.
|
||||
pub fn disable(&self) {
|
||||
let generation = self.inner.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.inner.enabled.send_replace(false);
|
||||
*self.inner.session.lock().unwrap() = None;
|
||||
*self.inner.status.lock().unwrap() = AdapterStatus::Disabled;
|
||||
let _ = self.inner.events.send(AdapterEvent::Status(AdapterStatus::Disabled));
|
||||
log::info!("freenet adapter disabled (gen {generation})");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ detect
|
||||
|
||||
/// Three-valued node detection. Usable while disabled (it opens its own
|
||||
/// short-lived connection and touches no adapter state).
|
||||
pub async fn detect(&self) -> AdapterStatus {
|
||||
let port = self.ws_port();
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
match timeout(DETECT_TCP_TIMEOUT, tokio::net::TcpStream::connect(&addr)).await {
|
||||
Err(_) | Ok(Err(_)) => return AdapterStatus::NodeAbsent,
|
||||
Ok(Ok(stream)) => drop(stream),
|
||||
}
|
||||
let mut api = match timeout(DETECT_WS_TIMEOUT, connect_ws(port)).await {
|
||||
Err(_) => return AdapterStatus::NodeOccupied,
|
||||
Ok(Err(_)) => return AdapterStatus::NodeOccupied,
|
||||
Ok(Ok(api)) => api,
|
||||
};
|
||||
if api
|
||||
.send(ClientRequest::NodeQueries(NodeQuery::ConnectedPeers))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return AdapterStatus::NodeIncompatible { detail: "send failed".into() };
|
||||
}
|
||||
match timeout(DETECT_QUERY_TIMEOUT, api.recv()).await {
|
||||
Ok(Ok(_)) => AdapterStatus::Connected,
|
||||
// A decoded, typed refusal IS protocol proof (local mode answers
|
||||
// ConnectedPeers with "not supported" — F0).
|
||||
Ok(Err(e)) if e.to_string().contains("not supported") => AdapterStatus::Connected,
|
||||
Ok(Err(e)) => AdapterStatus::NodeIncompatible { detail: truncate(&e.to_string(), 120) },
|
||||
Err(_) => AdapterStatus::NodeIncompatible { detail: "no reply to health query".into() },
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ supervisor
|
||||
|
||||
async fn supervisor(&self, generation: u64) {
|
||||
let mut enabled_rx = self.inner.enabled.subscribe();
|
||||
loop {
|
||||
if self.inner.generation.load(Ordering::SeqCst) != generation || !*enabled_rx.borrow() {
|
||||
return;
|
||||
}
|
||||
self.set_status(generation, AdapterStatus::Detecting);
|
||||
let detected = self.detect().await;
|
||||
match detected {
|
||||
AdapterStatus::Connected => {}
|
||||
other => {
|
||||
self.set_status(generation, other);
|
||||
if wait_or_disabled(&mut enabled_rx, SUPERVISOR_BACKOFF).await {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let port = self.ws_port();
|
||||
let api = match timeout(DETECT_WS_TIMEOUT, connect_ws(port)).await {
|
||||
Ok(Ok(api)) => api,
|
||||
_ => {
|
||||
self.set_status(generation, AdapterStatus::Lost { detail: "connect failed".into() });
|
||||
if wait_or_disabled(&mut enabled_rx, SUPERVISOR_BACKOFF).await {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (tx, rx) = mpsc::channel::<Cmd>(CMD_QUEUE);
|
||||
*self.inner.session.lock().unwrap() = Some(tx);
|
||||
self.set_status(generation, AdapterStatus::Connected);
|
||||
log::info!("freenet adapter connected to 127.0.0.1:{port}");
|
||||
let reason = self.run_session(api, rx, &mut enabled_rx).await;
|
||||
*self.inner.session.lock().unwrap() = None;
|
||||
if self.inner.generation.load(Ordering::SeqCst) != generation || !*enabled_rx.borrow() {
|
||||
return;
|
||||
}
|
||||
self.set_status(generation, AdapterStatus::Lost { detail: truncate(&reason, 120) });
|
||||
log::warn!("freenet session ended: {}", truncate(&reason, 120));
|
||||
if wait_or_disabled(&mut enabled_rx, SUPERVISOR_BACKOFF).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection's lifetime. STRICTLY one request in flight (the wire
|
||||
/// correlates FIFO); notifications stream out between and during ops.
|
||||
async fn run_session(
|
||||
&self,
|
||||
mut api: WebApi,
|
||||
mut rx: mpsc::Receiver<Cmd>,
|
||||
enabled_rx: &mut watch::Receiver<bool>,
|
||||
) -> String {
|
||||
loop {
|
||||
tokio::select! {
|
||||
changed = enabled_rx.changed() => {
|
||||
if changed.is_err() || !*enabled_rx.borrow() {
|
||||
return "disabled".into();
|
||||
}
|
||||
}
|
||||
maybe_cmd = rx.recv() => {
|
||||
let Some(cmd) = maybe_cmd else { return "handle dropped".into() };
|
||||
if api.send(cmd.req).await.is_err() {
|
||||
let _ = cmd.reply.send(Err(AdapterError::Node("send failed".into())));
|
||||
return "send failed".into();
|
||||
}
|
||||
// Await THIS op's response; unsolicited notifications
|
||||
// arriving in between are forwarded, not misdelivered.
|
||||
let deadline = tokio::time::Instant::now() + cmd.op_timeout;
|
||||
loop {
|
||||
let left = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if left.is_zero() {
|
||||
let _ = cmd.reply.send(Err(AdapterError::Timeout));
|
||||
break;
|
||||
}
|
||||
match timeout(left, api.recv()).await {
|
||||
Err(_) => {
|
||||
let _ = cmd.reply.send(Err(AdapterError::Timeout));
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
let _ = cmd.reply.send(Err(AdapterError::Node(e.to_string())));
|
||||
break;
|
||||
}
|
||||
Ok(Ok(HostResponse::ContractResponse(
|
||||
ContractResponse::UpdateNotification { key, update },
|
||||
))) => {
|
||||
self.forward_notification(&key, &update);
|
||||
continue;
|
||||
}
|
||||
Ok(Ok(resp)) => {
|
||||
let _ = cmd.reply.send(Ok(resp));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
streamed = api.recv() => {
|
||||
// Idle traffic: subscription notifications, or the end
|
||||
// of the connection.
|
||||
match streamed {
|
||||
Ok(HostResponse::ContractResponse(
|
||||
ContractResponse::UpdateNotification { key, update },
|
||||
)) => self.forward_notification(&key, &update),
|
||||
Ok(_) => { /* stray late response; correlation is FIFO, drop */ }
|
||||
Err(e) => return e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn forward_notification(&self, key: &ContractKey, update: &UpdateData<'static>) {
|
||||
let len = match update {
|
||||
UpdateData::State(s) => s.as_ref().len(),
|
||||
UpdateData::Delta(d) => d.as_ref().len(),
|
||||
UpdateData::StateAndDelta { state, delta } => state.as_ref().len() + delta.as_ref().len(),
|
||||
_ => 0,
|
||||
};
|
||||
// Bounded broadcast: lagging receivers lose oldest events. Honest.
|
||||
let _ = self.inner.events.send(AdapterEvent::ContractUpdate {
|
||||
instance: key.id().encode(),
|
||||
update_len: len,
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ ops
|
||||
|
||||
async fn request(&self, req: ClientRequest<'static>, op_timeout: Duration) -> Result<HostResponse> {
|
||||
if !self.is_enabled() {
|
||||
return Err(AdapterError::Disabled);
|
||||
}
|
||||
let tx = {
|
||||
let guard = self.inner.session.lock().unwrap();
|
||||
guard.clone()
|
||||
};
|
||||
let Some(tx) = tx else {
|
||||
return Err(AdapterError::NotConnected(self.status()));
|
||||
};
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
tx.send(Cmd { req, reply: reply_tx, op_timeout })
|
||||
.await
|
||||
.map_err(|_| AdapterError::ShuttingDown)?;
|
||||
reply_rx.await.map_err(|_| AdapterError::ShuttingDown)?
|
||||
}
|
||||
|
||||
fn container(code: Vec<u8>, params: &[u8]) -> (ContractContainer, ContractKey) {
|
||||
let params = Parameters::from(params.to_vec()).into_owned();
|
||||
let c = ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new(
|
||||
Arc::new(ContractCode::from(code)),
|
||||
params,
|
||||
)));
|
||||
let k = c.key();
|
||||
(c, k)
|
||||
}
|
||||
|
||||
/// Compute the ids a (code, params) pair addresses — no network.
|
||||
pub fn contract_ids(code: Vec<u8>, params: &[u8]) -> ContractIds {
|
||||
let (_, key) = Self::container(code, params);
|
||||
ContractIds {
|
||||
instance: key.id().encode(),
|
||||
code_hash: key.encoded_code_hash(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn contract_put(
|
||||
&self,
|
||||
code: Vec<u8>,
|
||||
params: &[u8],
|
||||
state: Vec<u8>,
|
||||
subscribe: bool,
|
||||
) -> Result<ContractIds> {
|
||||
let (container, key) = Self::container(code, params);
|
||||
let ids = ContractIds {
|
||||
instance: key.id().encode(),
|
||||
code_hash: key.encoded_code_hash(),
|
||||
};
|
||||
let resp = self
|
||||
.request(
|
||||
ContractRequest::Put {
|
||||
contract: container,
|
||||
state: WrappedState::new(state),
|
||||
related_contracts: RelatedContracts::new(),
|
||||
subscribe,
|
||||
blocking_subscribe: false,
|
||||
}
|
||||
.into(),
|
||||
PUT_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
HostResponse::ContractResponse(ContractResponse::PutResponse { .. }) => Ok(ids),
|
||||
other => Err(unexpected("PutResponse", &other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn contract_get(
|
||||
&self,
|
||||
instance_b58: &str,
|
||||
subscribe: bool,
|
||||
) -> Result<Vec<u8>> {
|
||||
let id = parse_instance(instance_b58)?;
|
||||
let resp = self
|
||||
.request(
|
||||
ContractRequest::Get {
|
||||
key: id,
|
||||
return_contract_code: false,
|
||||
subscribe,
|
||||
blocking_subscribe: false,
|
||||
}
|
||||
.into(),
|
||||
OP_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
// Depending on version, a never-put contract comes back either
|
||||
// as a typed NotFound or as an error frame "missing contract:
|
||||
// <id>" (observed on 0.2.132 local mode). Fold both into one
|
||||
// shape so services can match on "contract not found" alone.
|
||||
.map_err(|e| match e {
|
||||
AdapterError::Node(msg) if msg.contains("missing contract") => {
|
||||
AdapterError::Node("contract not found".into())
|
||||
}
|
||||
e => e,
|
||||
})?;
|
||||
match resp {
|
||||
HostResponse::ContractResponse(ContractResponse::GetResponse { state, .. }) => {
|
||||
Ok(state.to_vec())
|
||||
}
|
||||
HostResponse::ContractResponse(ContractResponse::NotFound { .. }) => {
|
||||
Err(AdapterError::Node("contract not found".into()))
|
||||
}
|
||||
other => Err(unexpected("GetResponse", &other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn contract_subscribe(&self, instance_b58: &str) -> Result<bool> {
|
||||
let id = parse_instance(instance_b58)?;
|
||||
let resp = self
|
||||
.request(
|
||||
ContractRequest::Subscribe { key: id, summary: None }.into(),
|
||||
OP_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
HostResponse::ContractResponse(ContractResponse::SubscribeResponse {
|
||||
subscribed,
|
||||
..
|
||||
}) => Ok(subscribed),
|
||||
other => Err(unexpected("SubscribeResponse", &other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// UPDATE with the recorded first-write behavior handled: a node that
|
||||
/// never held the contract answers "auto-fetch triggered" and asks the
|
||||
/// client to retry; we do, on the spike's schedule. Returns the
|
||||
/// post-merge summary bytes.
|
||||
pub async fn contract_update(
|
||||
&self,
|
||||
code: Vec<u8>,
|
||||
params: &[u8],
|
||||
delta: Vec<u8>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (_, key) = Self::container(code, params);
|
||||
let mut last: Option<AdapterError> = None;
|
||||
for attempt in 0..AUTOFETCH_RETRIES {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(AUTOFETCH_BACKOFF).await;
|
||||
if !self.is_enabled() {
|
||||
return Err(AdapterError::Disabled); // kill switch ends retries
|
||||
}
|
||||
}
|
||||
let resp = self
|
||||
.request(
|
||||
ContractRequest::Update {
|
||||
key,
|
||||
data: UpdateData::Delta(StateDelta::from(delta.clone())),
|
||||
}
|
||||
.into(),
|
||||
OP_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
match resp {
|
||||
Ok(HostResponse::ContractResponse(ContractResponse::UpdateResponse {
|
||||
summary,
|
||||
..
|
||||
})) => return Ok(summary.as_ref().to_vec()),
|
||||
Ok(other) => return Err(unexpected("UpdateResponse", &other)),
|
||||
Err(AdapterError::Node(msg)) if msg.contains("auto-fetch triggered") => {
|
||||
log::info!(
|
||||
"freenet: first-write priming on attempt {attempt} (auto-fetch in flight)"
|
||||
);
|
||||
last = Some(AdapterError::Node(msg));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last.unwrap_or(AdapterError::Timeout))
|
||||
}
|
||||
|
||||
/// The reliable first-write recipe from F5: one GET primes code+state on
|
||||
/// this node, then the update applies. Use for a contract this node may
|
||||
/// never have held.
|
||||
pub async fn get_then_update(
|
||||
&self,
|
||||
code: Vec<u8>,
|
||||
params: &[u8],
|
||||
delta: Vec<u8>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let ids = Self::contract_ids(code.clone(), params);
|
||||
let _ = self.contract_get(&ids.instance, false).await; // prime; absence is fine
|
||||
self.contract_update(code, params, delta).await
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ delegates
|
||||
|
||||
fn delegate_container(code: Vec<u8>, params: &[u8]) -> (DelegateContainer, DelegateKey) {
|
||||
let params = Parameters::from(params.to_vec()).into_owned();
|
||||
let dc = DelegateCode::from(code);
|
||||
let d = Delegate::from((&dc, ¶ms));
|
||||
let c = DelegateContainer::Wasm(DelegateWasmAPIVersion::V1(d));
|
||||
let k = c.key().clone();
|
||||
(c, k)
|
||||
}
|
||||
|
||||
/// Compute the delegate key a (code, params) pair addresses — no
|
||||
/// network. Lets services pin and log their delegate generation.
|
||||
pub fn delegate_key_b58(code: Vec<u8>, params: &[u8]) -> String {
|
||||
let (_, key) = Self::delegate_container(code, params);
|
||||
bs58::encode(key.bytes()).into_string()
|
||||
}
|
||||
|
||||
/// Register (idempotent per node) and return the delegate key (b58).
|
||||
/// `cipher`/`nonce` are legacy fields the node ignores (core#4140).
|
||||
pub async fn delegate_register(&self, code: Vec<u8>, params: &[u8]) -> Result<String> {
|
||||
use freenet_stdlib::client_api::DelegateRequest;
|
||||
let (container, key) = Self::delegate_container(code, params);
|
||||
let resp = self
|
||||
.request(
|
||||
ClientRequest::DelegateOp(DelegateRequest::RegisterDelegate {
|
||||
delegate: container,
|
||||
cipher: [0u8; 32],
|
||||
nonce: [0u8; 24],
|
||||
}),
|
||||
OP_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
HostResponse::DelegateResponse { .. } => Ok(bs58::encode(key.bytes()).into_string()),
|
||||
other => Err(unexpected("DelegateResponse", &other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// One application message in, the delegate's app-message reply out.
|
||||
/// NOTE (F1): migration across delegate code generations must be
|
||||
/// verified by reading state back — `RegisterDelegateWithPredecessors`
|
||||
/// silently copies nothing in 0.2.132 (GHSA-824h-7x5x-wfmf).
|
||||
pub async fn delegate_message(
|
||||
&self,
|
||||
code: Vec<u8>,
|
||||
params: &[u8],
|
||||
payload: Vec<u8>,
|
||||
) -> Result<Vec<u8>> {
|
||||
use freenet_stdlib::client_api::DelegateRequest;
|
||||
let (_, key) = Self::delegate_container(code.clone(), params);
|
||||
let params_owned = Parameters::from(params.to_vec()).into_owned();
|
||||
let resp = self
|
||||
.request(
|
||||
ClientRequest::DelegateOp(DelegateRequest::ApplicationMessages {
|
||||
key,
|
||||
params: params_owned,
|
||||
inbound: vec![InboundDelegateMsg::ApplicationMessage(
|
||||
ApplicationMessage::new(payload),
|
||||
)],
|
||||
}),
|
||||
OP_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
HostResponse::DelegateResponse { values, .. } => values
|
||||
.into_iter()
|
||||
.find_map(|v| match v {
|
||||
OutboundDelegateMsg::ApplicationMessage(m) => Some(m.payload),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| AdapterError::Node("no app message in delegate reply".into())),
|
||||
other => Err(unexpected("DelegateResponse", &other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
async fn connect_ws(port: u16) -> std::result::Result<WebApi, String> {
|
||||
let uri = format!("ws://127.0.0.1:{port}/v1/contract/command?encodingProtocol=native");
|
||||
let cfg = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
|
||||
.max_message_size(Some(100 * 1024 * 1024))
|
||||
.max_frame_size(Some(16 * 1024 * 1024));
|
||||
let (stream, _) = tokio_tungstenite::connect_async_with_config(&uri, Some(cfg), false)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(WebApi::start(stream))
|
||||
}
|
||||
|
||||
/// Wait out a backoff, returning true if the adapter was disabled meanwhile.
|
||||
async fn wait_or_disabled(enabled_rx: &mut watch::Receiver<bool>, dur: Duration) -> bool {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(dur) => !*enabled_rx.borrow(),
|
||||
changed = enabled_rx.changed() => changed.is_err() || !*enabled_rx.borrow(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_instance(b58: &str) -> Result<ContractInstanceId> {
|
||||
ContractInstanceId::try_from(b58.to_string())
|
||||
.map_err(|e| AdapterError::Other(format!("bad instance id: {e}")))
|
||||
}
|
||||
|
||||
fn unexpected(wanted: &str, got: &HostResponse) -> AdapterError {
|
||||
// Display, not Debug: HostResponse Display is summary-shaped and does
|
||||
// not dump state bytes.
|
||||
AdapterError::Node(format!("expected {wanted}, got {}", truncate(&got.to_string(), 120)))
|
||||
}
|
||||
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
if s.len() <= n {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut cut = n;
|
||||
while cut > 0 && !s.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
format!("{}…", &s[..cut])
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The event-loop path: no tokio context at all. Must not panic, must
|
||||
/// flip enabled, and disable() must still end it.
|
||||
#[test]
|
||||
fn enable_works_outside_any_runtime() {
|
||||
std::thread::spawn(|| {
|
||||
assert!(tokio::runtime::Handle::try_current().is_err());
|
||||
let a = FreenetAdapter::default();
|
||||
a.enable();
|
||||
assert!(a.is_enabled());
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
a.disable();
|
||||
assert!(!a.is_enabled());
|
||||
assert_eq!(a.status(), AdapterStatus::Disabled);
|
||||
})
|
||||
.join()
|
||||
.expect("no panic on the runtime-less path");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_is_the_default_and_ops_refuse() {
|
||||
let a = FreenetAdapter::default();
|
||||
assert_eq!(a.status(), AdapterStatus::Disabled);
|
||||
assert!(!a.is_enabled());
|
||||
let err = a.contract_get("11111111111111111111111111111111", false).await;
|
||||
assert!(matches!(err, Err(AdapterError::Disabled)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_absent_on_closed_port() {
|
||||
let a = FreenetAdapter::default();
|
||||
a.set_ws_port(1); // nothing listens on port 1
|
||||
assert_eq!(a.detect().await, AdapterStatus::NodeAbsent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_occupied_on_non_api_listener() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { break };
|
||||
use tokio::io::AsyncWriteExt;
|
||||
// Answer like a confused HTTP server, then hang up.
|
||||
let _ = sock.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
|
||||
}
|
||||
});
|
||||
let a = FreenetAdapter::default();
|
||||
a.set_ws_port(port);
|
||||
assert_eq!(a.detect().await, AdapterStatus::NodeOccupied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kill_switch_ends_everything() {
|
||||
let a = FreenetAdapter::default();
|
||||
a.set_ws_port(1);
|
||||
a.enable();
|
||||
assert!(a.is_enabled());
|
||||
// Give the supervisor a moment to enter its loop, then kill it.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
a.disable();
|
||||
assert_eq!(a.status(), AdapterStatus::Disabled);
|
||||
assert!(!a.is_enabled());
|
||||
let err = a.contract_get("11111111111111111111111111111111", false).await;
|
||||
assert!(matches!(err, Err(AdapterError::Disabled)));
|
||||
// A stale supervisor tick must not resurrect a status.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(a.status(), AdapterStatus::Disabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contract_ids_are_deterministic_offline() {
|
||||
let code = vec![0u8, 1, 2, 3];
|
||||
let a = FreenetAdapter::contract_ids(code.clone(), b"params");
|
||||
let b = FreenetAdapter::contract_ids(code, b"params");
|
||||
assert_eq!(a.instance, b.instance);
|
||||
assert_eq!(a.code_hash, b.code_hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Live integration against a REAL local Freenet node — the house pattern
|
||||
//! (`#[ignore]`d corpus tests inside the crate, run on demand):
|
||||
//!
|
||||
//! 1. start a node: freenet local local --config-dir <dir> --data-dir <dir>
|
||||
//! 2. set SOULSTREAM_F0_WASM to a contract wasm (the F0 spike's
|
||||
//! research/freenet-f0/f0-contract build works verbatim)
|
||||
//! 3. cargo test -p freenet-adapter --release -- --ignored
|
||||
//!
|
||||
//! Exercises the full adapter loop: detect → enable → put → get →
|
||||
//! subscribe → update (with the first-write priming path) → notification
|
||||
//! event → kill switch mid-life.
|
||||
|
||||
use freenet_adapter::{AdapterEvent, AdapterStatus, FreenetAdapter};
|
||||
use std::time::Duration;
|
||||
|
||||
fn wasm() -> Option<Vec<u8>> {
|
||||
let path = std::env::var("SOULSTREAM_F0_WASM").ok()?;
|
||||
std::fs::read(path).ok()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a local freenet node on 7509 and SOULSTREAM_F0_WASM"]
|
||||
async fn full_loop_against_live_node() {
|
||||
let Some(code) = wasm() else {
|
||||
panic!("set SOULSTREAM_F0_WASM to a contract wasm path");
|
||||
};
|
||||
let a = FreenetAdapter::default();
|
||||
|
||||
// Detection first — a wrong environment should fail here, loudly.
|
||||
let detected = a.detect().await;
|
||||
assert_eq!(detected, AdapterStatus::Connected, "node not reachable: {detected:?}");
|
||||
|
||||
a.enable();
|
||||
// Wait for the supervisor to connect.
|
||||
let mut tries = 0;
|
||||
while a.status() != AdapterStatus::Connected {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
tries += 1;
|
||||
assert!(tries < 50, "supervisor never connected: {:?}", a.status());
|
||||
}
|
||||
|
||||
// A fresh instance per run: params carry a nonce.
|
||||
let params = format!("adapter-live-{}", std::process::id()).into_bytes();
|
||||
let ids = FreenetAdapter::contract_ids(code.clone(), ¶ms);
|
||||
|
||||
// PUT initial state.
|
||||
let put_ids = a
|
||||
.contract_put(code.clone(), ¶ms, br#"["alpha"]"#.to_vec(), false)
|
||||
.await
|
||||
.expect("put");
|
||||
assert_eq!(put_ids.instance, ids.instance);
|
||||
|
||||
// GET it back.
|
||||
let state = a.contract_get(&ids.instance, false).await.expect("get");
|
||||
assert_eq!(state, br#"["alpha"]"#);
|
||||
|
||||
// SUBSCRIBE, then watch for the update notification through the
|
||||
// adapter's bounded event bus.
|
||||
let mut events = a.subscribe();
|
||||
assert!(a.contract_subscribe(&ids.instance).await.expect("subscribe"));
|
||||
|
||||
// UPDATE via the primed path (exercises get_then_update's shape even
|
||||
// though this node already holds the contract).
|
||||
let summary = a
|
||||
.get_then_update(code.clone(), ¶ms, br#"["beta"]"#.to_vec())
|
||||
.await
|
||||
.expect("update");
|
||||
assert!(!summary.is_empty(), "update summary should carry bytes");
|
||||
|
||||
// The merged state must be the union.
|
||||
let state = a.contract_get(&ids.instance, false).await.expect("get2");
|
||||
assert_eq!(state, br#"["alpha","beta"]"#);
|
||||
|
||||
// The notification should have crossed the event bus (ids only).
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
let mut saw_update = false;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_secs(1), events.recv()).await {
|
||||
Ok(Ok(AdapterEvent::ContractUpdate { instance, .. })) if instance == ids.instance => {
|
||||
saw_update = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
assert!(saw_update, "no ContractUpdate event reached the bus");
|
||||
|
||||
// Kill switch mid-life: immediate, and ops refuse afterwards.
|
||||
a.disable();
|
||||
assert_eq!(a.status(), AdapterStatus::Disabled);
|
||||
let err = a.contract_get(&ids.instance, false).await;
|
||||
assert!(matches!(err, Err(freenet_adapter::AdapterError::Disabled)));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "persona-state"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
# The persona profile + avatar-asset state model — the byte-exact shapes
|
||||
# the bundled persona/media contract wasms validate (their sources are the
|
||||
# F2/F2b spike crates; these definitions must stay bincode-identical to
|
||||
# them, field by field, or published state stops verifying).
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
bincode = "1"
|
||||
ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] }
|
||||
blake3 = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Persona profile + avatar-asset state — the shapes the bundled Freenet
|
||||
//! contracts validate.
|
||||
//!
|
||||
//! BYTE-COMPATIBILITY IS THE CONTRACT. `ProfileState` and `MediaAssetV1`
|
||||
//! must stay bincode-identical (field order, types, sig-body layouts) to
|
||||
//! the wasm bundled in `src-tauri/assets/` (built from the F2/F2b spike
|
||||
//! sources, keys pinned by tests there). Change anything here and every
|
||||
//! published profile stops verifying — that is a migration event.
|
||||
//!
|
||||
//! Two signing conventions coexist under `soulstream.profile/v1` (an F2b
|
||||
//! record): PROFILES sign their RAW sig-body; ASSETS sign blake3(sig-body).
|
||||
//! `profile_sign_payload` / `asset_sign_payload` return exactly what the
|
||||
//! identity delegate must be handed for each.
|
||||
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
|
||||
|
||||
pub const PROFILE_PARAMS_FAMILY: &[u8] = b"soulstream.persona.v1";
|
||||
pub const MEDIA_PARAMS_FAMILY: &[u8] = b"soulstream.media.v1";
|
||||
|
||||
pub const MAX_PROFILE_BYTES: usize = 4 * 1024;
|
||||
pub const MAX_NAME_BYTES: usize = 64;
|
||||
pub const MAX_BIO_BYTES: usize = 280;
|
||||
|
||||
/// v1 avatar rendition: one square PNG.
|
||||
pub const V1_MEDIA_TYPE: &str = "image/png";
|
||||
pub const V1_DIMENSION: u32 = 256;
|
||||
pub const MAX_MEDIA_BYTES: usize = 96 * 1024;
|
||||
pub const MAX_MEDIA_STATE_BYTES: usize = 128 * 1024;
|
||||
|
||||
// ------------------------------------------------------------------ profile
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MediaRefV1 {
|
||||
pub content_hash: [u8; 32],
|
||||
pub media_type: String,
|
||||
pub byte_length: u32,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProfileState {
|
||||
pub schema_version: u32,
|
||||
pub owner: [u8; 32],
|
||||
pub sequence: u64,
|
||||
pub display_name: String,
|
||||
pub bio: String,
|
||||
pub avatar: Option<MediaRefV1>,
|
||||
pub sig: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProfileSigBody<'a> {
|
||||
schema_version: u32,
|
||||
owner: &'a [u8; 32],
|
||||
sequence: u64,
|
||||
display_name: &'a str,
|
||||
bio: &'a str,
|
||||
avatar: &'a Option<MediaRefV1>,
|
||||
}
|
||||
|
||||
/// What the delegate signs for a profile: the RAW sig-body (F2 convention).
|
||||
pub fn profile_sign_payload(p: &ProfileState) -> Vec<u8> {
|
||||
bincode::serialize(&ProfileSigBody {
|
||||
schema_version: p.schema_version,
|
||||
owner: &p.owner,
|
||||
sequence: p.sequence,
|
||||
display_name: &p.display_name,
|
||||
bio: &p.bio,
|
||||
avatar: &p.avatar,
|
||||
})
|
||||
.expect("serializes")
|
||||
}
|
||||
|
||||
fn signable(domain: &str, payload: &[u8]) -> Vec<u8> {
|
||||
let mut msg = Vec::new();
|
||||
msg.extend_from_slice(b"soulstream.sig.v1");
|
||||
msg.push(0);
|
||||
msg.extend_from_slice(domain.as_bytes());
|
||||
msg.push(0);
|
||||
msg.extend_from_slice(blake3::hash(payload).as_bytes());
|
||||
msg
|
||||
}
|
||||
|
||||
pub fn verify_sig(public: &[u8; 32], payload: &[u8], sig: &[u8]) -> bool {
|
||||
let Ok(vk) = VerifyingKey::from_bytes(public) else { return false };
|
||||
let Ok(sig) = Signature::from_slice(sig) else { return false };
|
||||
vk.verify(&signable(SIGN_DOMAIN, payload), &sig).is_ok()
|
||||
}
|
||||
|
||||
pub fn make_profile_params(owner: &[u8; 32]) -> Vec<u8> {
|
||||
let mut p = Vec::with_capacity(PROFILE_PARAMS_FAMILY.len() + 32);
|
||||
p.extend_from_slice(PROFILE_PARAMS_FAMILY);
|
||||
p.extend_from_slice(owner);
|
||||
p
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ProfileInvalid {
|
||||
Oversized(usize),
|
||||
Malformed,
|
||||
WrongSchema(u32),
|
||||
WrongOwner,
|
||||
NameTooLong(usize),
|
||||
BioTooLong(usize),
|
||||
AvatarUnbounded,
|
||||
BadSignature,
|
||||
}
|
||||
|
||||
impl core::fmt::Display for ProfileInvalid {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match self {
|
||||
ProfileInvalid::Oversized(n) => write!(f, "profile {n} bytes over cap {MAX_PROFILE_BYTES}"),
|
||||
ProfileInvalid::Malformed => write!(f, "bytes do not parse as a profile"),
|
||||
ProfileInvalid::WrongSchema(v) => write!(f, "unknown profile schema {v}"),
|
||||
ProfileInvalid::WrongOwner => write!(f, "profile owner differs from the address owner"),
|
||||
ProfileInvalid::NameTooLong(n) => write!(f, "display name {n} bytes over cap {MAX_NAME_BYTES}"),
|
||||
ProfileInvalid::BioTooLong(n) => write!(f, "bio {n} bytes over cap {MAX_BIO_BYTES}"),
|
||||
ProfileInvalid::AvatarUnbounded => write!(f, "avatar reference outside the v1 bounds"),
|
||||
ProfileInvalid::BadSignature => write!(f, "owner signature does not verify"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn avatar_bounded(r: &MediaRefV1) -> bool {
|
||||
r.media_type == V1_MEDIA_TYPE
|
||||
&& r.byte_length as usize <= MAX_MEDIA_BYTES
|
||||
&& r.width as u32 == V1_DIMENSION
|
||||
&& r.height as u32 == V1_DIMENSION
|
||||
}
|
||||
|
||||
/// Gen-2 validation (avatar-accepting) — the generation the bundled
|
||||
/// contract enforces. Gen-1 states (avatar=None) validate identically.
|
||||
pub fn validate_profile(owner: &[u8; 32], bytes: &[u8]) -> Result<ProfileState, ProfileInvalid> {
|
||||
if bytes.len() > MAX_PROFILE_BYTES {
|
||||
return Err(ProfileInvalid::Oversized(bytes.len()));
|
||||
}
|
||||
let p: ProfileState = bincode::deserialize(bytes).map_err(|_| ProfileInvalid::Malformed)?;
|
||||
if p.schema_version != SCHEMA_VERSION {
|
||||
return Err(ProfileInvalid::WrongSchema(p.schema_version));
|
||||
}
|
||||
if &p.owner != owner {
|
||||
return Err(ProfileInvalid::WrongOwner);
|
||||
}
|
||||
if p.display_name.len() > MAX_NAME_BYTES {
|
||||
return Err(ProfileInvalid::NameTooLong(p.display_name.len()));
|
||||
}
|
||||
if p.bio.len() > MAX_BIO_BYTES {
|
||||
return Err(ProfileInvalid::BioTooLong(p.bio.len()));
|
||||
}
|
||||
if let Some(r) = &p.avatar {
|
||||
if !avatar_bounded(r) {
|
||||
return Err(ProfileInvalid::AvatarUnbounded);
|
||||
}
|
||||
}
|
||||
if !verify_sig(&p.owner, &profile_sign_payload(&p), &p.sig) {
|
||||
return Err(ProfileInvalid::BadSignature);
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ asset
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MediaAssetV1 {
|
||||
pub schema_version: u32,
|
||||
pub owner: [u8; 32],
|
||||
pub asset_id: [u8; 16],
|
||||
pub revision: u32,
|
||||
pub media_type: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub sanitized_static_bytes: Vec<u8>,
|
||||
pub sig: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AssetSigBody<'a> {
|
||||
schema_version: u32,
|
||||
owner: &'a [u8; 32],
|
||||
asset_id: &'a [u8; 16],
|
||||
revision: u32,
|
||||
media_type: &'a str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
content_hash: &'a [u8; 32],
|
||||
}
|
||||
|
||||
fn asset_sig_body(a: &MediaAssetV1) -> Vec<u8> {
|
||||
let content_hash = *blake3::hash(&a.sanitized_static_bytes).as_bytes();
|
||||
bincode::serialize(&AssetSigBody {
|
||||
schema_version: a.schema_version,
|
||||
owner: &a.owner,
|
||||
asset_id: &a.asset_id,
|
||||
revision: a.revision,
|
||||
media_type: &a.media_type,
|
||||
width: a.width,
|
||||
height: a.height,
|
||||
content_hash: &content_hash,
|
||||
})
|
||||
.expect("serializes")
|
||||
}
|
||||
|
||||
/// What the delegate signs for an asset: blake3(sig-body) — 32 bytes.
|
||||
pub fn asset_sign_payload(a: &MediaAssetV1) -> Vec<u8> {
|
||||
blake3::hash(&asset_sig_body(a)).as_bytes().to_vec()
|
||||
}
|
||||
|
||||
pub fn make_media_params(owner: &[u8; 32], content_hash: &[u8; 32]) -> Vec<u8> {
|
||||
let mut p = Vec::with_capacity(MEDIA_PARAMS_FAMILY.len() + 64);
|
||||
p.extend_from_slice(MEDIA_PARAMS_FAMILY);
|
||||
p.extend_from_slice(owner);
|
||||
p.extend_from_slice(content_hash);
|
||||
p
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum AssetInvalid {
|
||||
Oversized(usize),
|
||||
HashMismatch,
|
||||
Malformed,
|
||||
WrongSchema(u32),
|
||||
WrongOwner,
|
||||
WrongType(String),
|
||||
WrongDimensions(u32, u32),
|
||||
BytesOverBudget(usize),
|
||||
BadSignature,
|
||||
}
|
||||
|
||||
impl core::fmt::Display for AssetInvalid {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match self {
|
||||
AssetInvalid::Oversized(n) => write!(f, "asset state {n} bytes over gate {MAX_MEDIA_STATE_BYTES}"),
|
||||
AssetInvalid::HashMismatch => write!(f, "bytes do not hash to the referenced content hash"),
|
||||
AssetInvalid::Malformed => write!(f, "bytes do not parse as a media asset"),
|
||||
AssetInvalid::WrongSchema(v) => write!(f, "unknown media schema {v}"),
|
||||
AssetInvalid::WrongOwner => write!(f, "asset owner differs from the reference owner"),
|
||||
AssetInvalid::WrongType(t) => write!(f, "media type {t:?} not accepted (v1: image/png)"),
|
||||
AssetInvalid::WrongDimensions(w, h) => write!(f, "declared {w}x{h} is not the 256x256 rendition"),
|
||||
AssetInvalid::BytesOverBudget(n) => write!(f, "{n} media bytes over the {MAX_MEDIA_BYTES} budget"),
|
||||
AssetInvalid::BadSignature => write!(f, "owner signature does not verify"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structural verification of a fetched asset against a signed reference —
|
||||
/// everything that must pass BEFORE any image decoding happens.
|
||||
pub fn validate_asset(
|
||||
owner: &[u8; 32],
|
||||
content_hash: &[u8; 32],
|
||||
state_bytes: &[u8],
|
||||
) -> Result<MediaAssetV1, AssetInvalid> {
|
||||
if state_bytes.len() > MAX_MEDIA_STATE_BYTES {
|
||||
return Err(AssetInvalid::Oversized(state_bytes.len()));
|
||||
}
|
||||
let a: MediaAssetV1 = bincode::deserialize(state_bytes).map_err(|_| AssetInvalid::Malformed)?;
|
||||
if a.schema_version != SCHEMA_VERSION {
|
||||
return Err(AssetInvalid::WrongSchema(a.schema_version));
|
||||
}
|
||||
if &a.owner != owner {
|
||||
return Err(AssetInvalid::WrongOwner);
|
||||
}
|
||||
if a.sanitized_static_bytes.len() > MAX_MEDIA_BYTES {
|
||||
return Err(AssetInvalid::BytesOverBudget(a.sanitized_static_bytes.len()));
|
||||
}
|
||||
if blake3::hash(&a.sanitized_static_bytes).as_bytes() != content_hash {
|
||||
return Err(AssetInvalid::HashMismatch);
|
||||
}
|
||||
if a.media_type != V1_MEDIA_TYPE {
|
||||
return Err(AssetInvalid::WrongType(a.media_type.clone()));
|
||||
}
|
||||
if a.width != V1_DIMENSION || a.height != V1_DIMENSION {
|
||||
return Err(AssetInvalid::WrongDimensions(a.width, a.height));
|
||||
}
|
||||
if !verify_sig(owner, &asset_sign_payload(&a), &a.sig) {
|
||||
return Err(AssetInvalid::BadSignature);
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
fn kp(seed: u8) -> SigningKey {
|
||||
SigningKey::from_bytes(&[seed; 32])
|
||||
}
|
||||
|
||||
fn sign_raw(key: &SigningKey, payload: &[u8]) -> Vec<u8> {
|
||||
key.sign(&signable(SIGN_DOMAIN, payload)).to_bytes().to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_roundtrip_both_generations() {
|
||||
let k = kp(1);
|
||||
let owner = k.verifying_key().to_bytes();
|
||||
let mut p = ProfileState {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
owner,
|
||||
sequence: 1,
|
||||
display_name: "Kyle".into(),
|
||||
bio: String::new(),
|
||||
avatar: None,
|
||||
sig: vec![],
|
||||
};
|
||||
p.sig = sign_raw(&k, &profile_sign_payload(&p));
|
||||
let b = bincode::serialize(&p).unwrap();
|
||||
assert!(validate_profile(&owner, &b).is_ok());
|
||||
// With a bounded avatar ref.
|
||||
let mut p2 = p.clone();
|
||||
p2.sequence = 2;
|
||||
p2.avatar = Some(MediaRefV1 {
|
||||
content_hash: [9u8; 32],
|
||||
media_type: V1_MEDIA_TYPE.into(),
|
||||
byte_length: 50_000,
|
||||
width: 256,
|
||||
height: 256,
|
||||
});
|
||||
p2.sig = sign_raw(&k, &profile_sign_payload(&p2));
|
||||
assert!(validate_profile(&owner, &bincode::serialize(&p2).unwrap()).is_ok());
|
||||
// Unbounded avatar refused; tamper refused.
|
||||
let mut p3 = p2.clone();
|
||||
p3.avatar.as_mut().unwrap().width = 4096;
|
||||
p3.sig = sign_raw(&k, &profile_sign_payload(&p3));
|
||||
assert_eq!(
|
||||
validate_profile(&owner, &bincode::serialize(&p3).unwrap()),
|
||||
Err(ProfileInvalid::AvatarUnbounded)
|
||||
);
|
||||
let mut p4 = p2.clone();
|
||||
p4.display_name = "Mallory".into();
|
||||
assert_eq!(
|
||||
validate_profile(&owner, &bincode::serialize(&p4).unwrap()),
|
||||
Err(ProfileInvalid::BadSignature)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_roundtrip_and_hash_binding() {
|
||||
let k = kp(1);
|
||||
let owner = k.verifying_key().to_bytes();
|
||||
let mut a = MediaAssetV1 {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
owner,
|
||||
asset_id: [4u8; 16],
|
||||
revision: 1,
|
||||
media_type: V1_MEDIA_TYPE.into(),
|
||||
width: V1_DIMENSION,
|
||||
height: V1_DIMENSION,
|
||||
sanitized_static_bytes: vec![7u8; 1000],
|
||||
sig: vec![],
|
||||
};
|
||||
a.sig = sign_raw(&k, &asset_sign_payload(&a));
|
||||
let hash = *blake3::hash(&a.sanitized_static_bytes).as_bytes();
|
||||
let b = bincode::serialize(&a).unwrap();
|
||||
assert!(validate_asset(&owner, &hash, &b).is_ok());
|
||||
assert_eq!(validate_asset(&owner, &[0u8; 32], &b), Err(AssetInvalid::HashMismatch));
|
||||
}
|
||||
}
|
||||
|
||||
/// blake3 of raw bytes — the content-addressing hash used across the
|
||||
/// persona/media contract families.
|
||||
pub fn content_hash(bytes: &[u8]) -> [u8; 32] {
|
||||
*blake3::hash(bytes).as_bytes()
|
||||
}
|
||||
@@ -29,6 +29,8 @@ image.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
base64 = "0.22"
|
||||
bincode = "1"
|
||||
bs58 = "0.5"
|
||||
# Shazam signature packets (shazam.rs) carry a CRC-32 of their body.
|
||||
crc32fast = "1"
|
||||
# Inflate for the deno release zip (ytdlp.rs) — already in the lock via the
|
||||
@@ -46,6 +48,8 @@ keyring = { version = "3", features = ["windows-native"] }
|
||||
sysinfo = "0.33"
|
||||
soulseek-proto = { path = "../crates/soulseek-proto" }
|
||||
soulseek-client = { path = "../crates/soulseek-client" }
|
||||
freenet-adapter = { path = "../crates/freenet-adapter" }
|
||||
persona-state = { path = "../crates/persona-state" }
|
||||
player = { path = "../crates/player" }
|
||||
tagger = { path = "../crates/tagger" }
|
||||
metadata = { path = "../crates/metadata" }
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -148,6 +148,40 @@ pub struct AppConfig {
|
||||
/// their status and stats stream in while connected.
|
||||
#[serde(default)]
|
||||
pub buddies: Vec<String>,
|
||||
/// The experimental Freenet social layer (crates/freenet-adapter).
|
||||
/// None → false: strictly opt-in, and flipping it off is the kill
|
||||
/// switch — the adapter drops its connection and stops retrying. A
|
||||
/// missing or broken node never affects Soulseek or playback.
|
||||
#[serde(default)]
|
||||
pub freenet_enabled: Option<bool>,
|
||||
/// WebSocket API port of the EXTERNAL local Freenet node. None → 7509.
|
||||
#[serde(default)]
|
||||
pub freenet_ws_port: Option<u16>,
|
||||
/// Whether profile avatars fetched over Freenet may be decoded and
|
||||
/// shown. None → true (the pipeline sanitizes them regardless); false
|
||||
/// degrades every remote avatar to the identicon, no fetch at all.
|
||||
#[serde(default)]
|
||||
pub freenet_load_remote_images: Option<bool>,
|
||||
/// The Community area's local view of a Soulseek contact: petname, group,
|
||||
/// note and the two local marks. Private to this device by design — none
|
||||
/// of it is ever published or sent to the person it describes.
|
||||
#[serde(default)]
|
||||
pub contacts: Vec<ContactMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ContactMeta {
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub petname: Option<String>,
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
#[serde(default)]
|
||||
pub trusted: bool,
|
||||
#[serde(default)]
|
||||
pub favorite: bool,
|
||||
}
|
||||
|
||||
/// Default longest edge for embedded covers when the user hasn't chosen.
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! The app's Freenet surface — deliberately tiny.
|
||||
//!
|
||||
//! The webview gets STATUS ONLY: whether the experimental layer is enabled,
|
||||
//! and what the adapter sees (absent / occupied / incompatible / connected).
|
||||
//! The privileged node API stays entirely inside the Rust boundary
|
||||
//! (crates/freenet-adapter); no command here moves contract state, keys, or
|
||||
//! node operations across the bridge. Future Community services call the
|
||||
//! adapter crate directly from Rust.
|
||||
//!
|
||||
//! Off by default. `freenet_set_enabled(true)` is the explicit opt-in and
|
||||
//! persists to config.json; the kill switch is the same call with `false`
|
||||
//! and takes effect immediately (drops the connection, ends every retry).
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use crate::config;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FreenetStatus {
|
||||
pub enabled: bool,
|
||||
pub ws_port: u16,
|
||||
pub status: freenet_adapter::AdapterStatus,
|
||||
/// Whether remote profile avatars may be fetched and decoded at all.
|
||||
pub load_remote_images: bool,
|
||||
}
|
||||
|
||||
/// Wire the adapter at startup: apply config (port + opt-in) and forward
|
||||
/// adapter events to the webview as `freenet:status` — status frames only.
|
||||
pub fn init(app: &tauri::AppHandle) {
|
||||
let cfg = config::load(app);
|
||||
let state = app.state::<AppState>();
|
||||
let adapter = state.freenet.clone();
|
||||
adapter.set_ws_port(cfg.freenet_ws_port.unwrap_or(freenet_adapter::DEFAULT_WS_PORT));
|
||||
|
||||
let handle = app.clone();
|
||||
let mut events = adapter.subscribe();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
match events.recv().await {
|
||||
Ok(freenet_adapter::AdapterEvent::Status(s)) => {
|
||||
let _ = handle.emit("freenet:status", &s);
|
||||
}
|
||||
// Contract updates carry ids/lengths only, and nothing in
|
||||
// the UI consumes them yet — swallow rather than emit noise.
|
||||
Ok(freenet_adapter::AdapterEvent::ContractUpdate { .. }) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if cfg.freenet_enabled.unwrap_or(false) {
|
||||
adapter.enable();
|
||||
// The peer the app set up follows the switch (see ensure_node).
|
||||
tauri::async_runtime::spawn(ensure_node(app.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn freenet_status(app: tauri::AppHandle, state: tauri::State<'_, AppState>) -> FreenetStatus {
|
||||
FreenetStatus {
|
||||
enabled: state.freenet.is_enabled(),
|
||||
ws_port: state.freenet.ws_port(),
|
||||
status: state.freenet.status(),
|
||||
load_remote_images: config::load(&app).freenet_load_remote_images.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reader-side policy gate for avatars (profile.rs checks it BEFORE any
|
||||
/// fetch). Off degrades every remote avatar to the identicon.
|
||||
#[tauri::command]
|
||||
pub fn freenet_set_load_remote_images(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
on: bool,
|
||||
) -> Result<FreenetStatus, String> {
|
||||
let mut cfg = config::load(&app);
|
||||
cfg.freenet_load_remote_images = Some(on);
|
||||
config::save(&app, &cfg);
|
||||
Ok(freenet_status(app, state))
|
||||
}
|
||||
|
||||
/// The explicit opt-in / kill switch. Persisted, then applied immediately.
|
||||
#[tauri::command]
|
||||
pub fn freenet_set_enabled(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
enabled: bool,
|
||||
) -> Result<FreenetStatus, String> {
|
||||
let mut cfg = config::load(&app);
|
||||
cfg.freenet_enabled = Some(enabled);
|
||||
config::save(&app, &cfg);
|
||||
if enabled {
|
||||
state.freenet.enable();
|
||||
tauri::async_runtime::spawn(ensure_node(app.clone()));
|
||||
} else {
|
||||
state.freenet.disable();
|
||||
}
|
||||
Ok(freenet_status(app, state))
|
||||
}
|
||||
|
||||
/// One-shot detection — works while disabled, touches no adapter state.
|
||||
#[tauri::command]
|
||||
pub async fn freenet_detect(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<freenet_adapter::AdapterStatus, String> {
|
||||
Ok(state.freenet.detect().await)
|
||||
}
|
||||
|
||||
// ---- the local peer -----------------------------------------------------------
|
||||
// Starting the installed node from the app, the way the research did it by
|
||||
// hand (research/freenet-f0, -f2): the peer writes its config.toml on first
|
||||
// boot with telemetry ON (the alpha default, even in local mode), so a first
|
||||
// start runs it only until that file exists, stops it, pins
|
||||
// `telemetry-enabled = false`, and only then launches the real session. The
|
||||
// peer is a detached background process — it outlives the app on purpose
|
||||
// (the design's "desktop background peer") and stops with its own button.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NodeInfo {
|
||||
/// Where the peer binary was found, if anywhere.
|
||||
pub binary: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub config_dir: String,
|
||||
pub data_dir: String,
|
||||
pub config_exists: bool,
|
||||
pub telemetry_off: bool,
|
||||
/// True while a node THIS app launched is alive.
|
||||
pub started_by_app: bool,
|
||||
}
|
||||
|
||||
const TELEMETRY_ON: &str = "telemetry-enabled = true";
|
||||
const TELEMETRY_OFF: &str = "telemetry-enabled = false";
|
||||
const FIRST_BOOT_WAIT: Duration = Duration::from_secs(20);
|
||||
|
||||
struct NodeDirs {
|
||||
base: PathBuf,
|
||||
config: PathBuf,
|
||||
data: PathBuf,
|
||||
}
|
||||
|
||||
fn node_dirs(app: &tauri::AppHandle) -> Result<NodeDirs, String> {
|
||||
let base = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("freenet");
|
||||
Ok(NodeDirs { config: base.join("config"), data: base.join("data"), base })
|
||||
}
|
||||
|
||||
/// No console window for child processes on Windows.
|
||||
fn quiet(mut c: Command) -> Command {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
c.creation_flags(0x0800_0000);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn find_binary(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||
let name = if cfg!(windows) { "freenet.exe" } else { "freenet" };
|
||||
if let Ok(home) = app.path().home_dir() {
|
||||
let p = home.join(".cargo").join("bin").join(name);
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let on_path = quiet(Command::new("freenet"))
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
on_path.then(|| PathBuf::from("freenet"))
|
||||
}
|
||||
|
||||
fn version_of(bin: &Path) -> Option<String> {
|
||||
let out = quiet(Command::new(bin)).arg("--version").output().ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let line = text.lines().next()?.trim();
|
||||
let v = line
|
||||
.trim_start_matches("Freenet version:")
|
||||
.trim()
|
||||
.trim_end_matches("()")
|
||||
.trim();
|
||||
(!v.is_empty()).then(|| v.to_string())
|
||||
}
|
||||
|
||||
fn telemetry_off(config: &Path) -> bool {
|
||||
std::fs::read_to_string(config)
|
||||
.map(|s| s.contains(TELEMETRY_OFF))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn pin_telemetry_off(config: &Path) -> Result<(), String> {
|
||||
let s = std::fs::read_to_string(config).map_err(|e| format!("read config.toml: {e}"))?;
|
||||
if s.contains(TELEMETRY_OFF) {
|
||||
return Ok(());
|
||||
}
|
||||
if !s.contains(TELEMETRY_ON) {
|
||||
return Err("config.toml has no telemetry-enabled line to pin".into());
|
||||
}
|
||||
std::fs::write(config, s.replace(TELEMETRY_ON, TELEMETRY_OFF))
|
||||
.map_err(|e| format!("write config.toml: {e}"))
|
||||
}
|
||||
|
||||
fn spawn_node(bin: &Path, dirs: &NodeDirs, log: &Path) -> Result<Child, String> {
|
||||
let out = std::fs::File::create(log).map_err(|e| format!("node log: {e}"))?;
|
||||
let err = out.try_clone().map_err(|e| e.to_string())?;
|
||||
quiet(Command::new(bin))
|
||||
.arg("network")
|
||||
.arg("--config-dir")
|
||||
.arg(&dirs.config)
|
||||
.arg("--data-dir")
|
||||
.arg(&dirs.data)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(out))
|
||||
.stderr(Stdio::from(err))
|
||||
.spawn()
|
||||
.map_err(|e| format!("start the peer: {e}"))
|
||||
}
|
||||
|
||||
fn node_info(app: &tauri::AppHandle, state: &AppState) -> Result<NodeInfo, String> {
|
||||
let dirs = node_dirs(app)?;
|
||||
let bin = find_binary(app);
|
||||
let config = dirs.config.join("config.toml");
|
||||
let started_by_app = {
|
||||
let mut guard = state.freenet_node.lock().unwrap();
|
||||
match guard.as_mut().map(|c| c.try_wait()) {
|
||||
Some(Ok(None)) => true,
|
||||
_ => {
|
||||
*guard = None;
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(NodeInfo {
|
||||
version: bin.as_deref().and_then(version_of),
|
||||
binary: bin.map(|p| p.display().to_string()),
|
||||
config_dir: dirs.config.display().to_string(),
|
||||
data_dir: dirs.data.display().to_string(),
|
||||
config_exists: config.is_file(),
|
||||
telemetry_off: telemetry_off(&config),
|
||||
started_by_app,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn freenet_node_info(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<NodeInfo, String> {
|
||||
node_info(&app, &state)
|
||||
}
|
||||
|
||||
/// Start the peer under the app's folder (the first-boot recipe when its
|
||||
/// config does not exist yet). Returns the running child.
|
||||
async fn start_node_inner(app: &tauri::AppHandle) -> Result<Child, String> {
|
||||
let handle = app.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<Child, String> {
|
||||
let bin = find_binary(&handle)
|
||||
.ok_or("no Freenet peer found on this machine — install it (cargo install freenet) and try again")?;
|
||||
let dirs = node_dirs(&handle)?;
|
||||
std::fs::create_dir_all(&dirs.config).map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&dirs.data).map_err(|e| e.to_string())?;
|
||||
let config = dirs.config.join("config.toml");
|
||||
if !config.is_file() {
|
||||
let first_log = dirs.base.join("first-boot.log");
|
||||
let mut first = spawn_node(&bin, &dirs, &first_log)?;
|
||||
let deadline = Instant::now() + FIRST_BOOT_WAIT;
|
||||
while !config.is_file() && Instant::now() < deadline {
|
||||
if let Ok(Some(status)) = first.try_wait() {
|
||||
return Err(format!(
|
||||
"the peer exited during its first boot ({status}) — see {}",
|
||||
first_log.display()
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
let _ = first.kill();
|
||||
let _ = first.wait();
|
||||
if !config.is_file() {
|
||||
return Err("the peer wrote no config.toml within 20s".into());
|
||||
}
|
||||
log::info!("freenet node: first boot wrote {}", config.display());
|
||||
}
|
||||
pin_telemetry_off(&config)?;
|
||||
let child = spawn_node(&bin, &dirs, &dirs.base.join("node.log"))?;
|
||||
log::info!("freenet node started (pid {}) from {}", child.id(), bin.display());
|
||||
Ok(child)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// A peer the app set up follows the switch: whenever the layer is on and
|
||||
/// the peer is absent, start it — at launch, and when the switch goes on.
|
||||
/// First-time setup stays explicit (the Start button), so a user who never
|
||||
/// asked for a peer never gets one; after that it is simply kept running.
|
||||
/// (A dev-harness restart takes the child down with the app — observed
|
||||
/// 2026-09-02 — and a reboot does the same; this is what brings it back.)
|
||||
pub async fn ensure_node(app: tauri::AppHandle) {
|
||||
let state = app.state::<AppState>();
|
||||
if !state.freenet.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let Ok(dirs) = node_dirs(&app) else { return };
|
||||
if !dirs.config.join("config.toml").is_file() {
|
||||
return;
|
||||
}
|
||||
if state.freenet_node.lock().unwrap().is_some() {
|
||||
return;
|
||||
}
|
||||
if !matches!(state.freenet.detect().await, freenet_adapter::AdapterStatus::NodeAbsent) {
|
||||
return;
|
||||
}
|
||||
match start_node_inner(&app).await {
|
||||
Ok(child) => *state.freenet_node.lock().unwrap() = Some(child),
|
||||
Err(e) => log::warn!("freenet node: could not start the peer: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn freenet_node_start(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<NodeInfo, String> {
|
||||
if node_info(&app, &state)?.started_by_app {
|
||||
return node_info(&app, &state);
|
||||
}
|
||||
let child = start_node_inner(&app).await?;
|
||||
*state.freenet_node.lock().unwrap() = Some(child);
|
||||
node_info(&app, &state)
|
||||
}
|
||||
|
||||
/// Stops only a node this app started; a hand-started peer is left alone.
|
||||
#[tauri::command]
|
||||
pub async fn freenet_node_stop(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<NodeInfo, String> {
|
||||
let child = state.freenet_node.lock().unwrap().take();
|
||||
if let Some(mut c) = child {
|
||||
let _ = c.kill();
|
||||
let _ = c.wait();
|
||||
log::info!("freenet node stopped");
|
||||
}
|
||||
node_info(&app, &state)
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//! PersonaService — the SoulStream identity, held by the bundled Freenet
|
||||
//! identity delegate and reached only through the FreenetAdapter.
|
||||
//!
|
||||
//! The delegate wasm ships as an app asset (`assets/identity_delegate.wasm`,
|
||||
//! sha256 84dd8dbb…, the generation every F1–F2b spike validated). Its key
|
||||
//! namespace is (code hash, params), so THESE BYTES ARE A COMMITMENT:
|
||||
//! changing them re-keys the delegate and strands every user's persona —
|
||||
//! and the platform's copy-forward is disabled (GHSA-824h-7x5x-wfmf), so
|
||||
//! the only migration is export-artifact → new generation → import,
|
||||
//! verified by reading the identity back (F1's record). Treat any change
|
||||
//! as a migration event, never a routine rebuild.
|
||||
//!
|
||||
//! Privilege rules, same as everywhere on this boundary:
|
||||
//! - the PRIVATE key never leaves the delegate; what crosses to the webview
|
||||
//! is the public key, the key version, and status strings;
|
||||
//! - the passphrase-sealed recovery artifact is written/read by RUST at a
|
||||
//! caller-chosen path — its bytes never transit the webview;
|
||||
//! - signing stays Rust-internal (`sign` is pub(crate) for future
|
||||
//! Community services, not a Tauri command).
|
||||
//!
|
||||
//! Wire pin: `InMsg`/`OutMsg` are bincode enums whose VARIANT ORDER must
|
||||
//! match research/freenet-f1/identity-delegate/src/lib.rs exactly — bincode
|
||||
//! encodes the variant index, so reordering is a silent protocol break.
|
||||
|
||||
use freenet_adapter::FreenetAdapter;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
const DELEGATE_WASM: &[u8] = include_bytes!("../assets/identity_delegate.wasm");
|
||||
const DELEGATE_PARAMS: &[u8] = b"soulstream-identity-v1";
|
||||
|
||||
/// Recovery-artifact KDF defaults: Argon2id inside the delegate's wasm.
|
||||
/// 64 MiB / 3 passes measured comfortably inside the delegate's caps
|
||||
/// (8–64 MiB, t ≤ 8 — F1). The artifact itself is 93 bytes.
|
||||
const EXPORT_M_COST_KIB: u32 = 64 * 1024;
|
||||
const EXPORT_T_COST: u32 = 3;
|
||||
|
||||
// ---- delegate protocol (bincode; variant order is the wire) ----
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
enum InMsg {
|
||||
CreateIdentity { force: bool },
|
||||
GetPublicKey,
|
||||
Sign { domain: String, payload: Vec<u8> },
|
||||
ExportIdentity { passphrase: String, m_cost_kib: u32, t_cost: u32 },
|
||||
ImportIdentity { artifact: Vec<u8>, passphrase: String, force: bool },
|
||||
RotateIdentity,
|
||||
GetTransition,
|
||||
RemoveIdentity,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
enum OutMsg {
|
||||
Identity { public_key: [u8; 32], key_version: u32, created: bool },
|
||||
NotInitialized,
|
||||
Signature { public_key: [u8; 32], key_version: u32, sig: Vec<u8> },
|
||||
Artifact { bytes: Vec<u8> },
|
||||
Rotated { old_public: [u8; 32], new_public: [u8; 32], key_version: u32, transition_sig: Vec<u8> },
|
||||
Transition { old_public: [u8; 32], new_public: [u8; 32], key_version: u32, transition_sig: Vec<u8> },
|
||||
NoTransition,
|
||||
Removed,
|
||||
Refused { reason: String },
|
||||
}
|
||||
|
||||
// ---- public shapes (webview-safe: public data only) ----
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PersonaInfo {
|
||||
pub public_key: String,
|
||||
pub key_version: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PersonaStatus {
|
||||
/// Adapter reachable and enabled — persona ops can run at all.
|
||||
pub available: bool,
|
||||
pub adapter: freenet_adapter::AdapterStatus,
|
||||
/// None when available but no identity exists yet.
|
||||
pub persona: Option<PersonaInfo>,
|
||||
/// This build's delegate generation (b58 key) — diagnostics, and the
|
||||
/// value that must not drift between releases.
|
||||
pub delegate_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RotationInfo {
|
||||
pub old_public: String,
|
||||
pub new_public: String,
|
||||
pub key_version: u32,
|
||||
}
|
||||
|
||||
// ---- core (adapter-facing, testable without Tauri) ----
|
||||
|
||||
async fn call(adapter: &FreenetAdapter, msg: InMsg) -> Result<OutMsg, String> {
|
||||
// Registration is idempotent and cheap; doing it per-op means the
|
||||
// service self-heals across node restarts and adapter reconnects.
|
||||
adapter
|
||||
.delegate_register(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let payload = bincode::serialize(&msg).map_err(|e| e.to_string())?;
|
||||
let reply = adapter
|
||||
.delegate_message(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS, payload)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
bincode::deserialize(&reply).map_err(|e| format!("delegate reply did not decode: {e}"))
|
||||
}
|
||||
|
||||
fn info(public_key: [u8; 32], key_version: u32) -> PersonaInfo {
|
||||
PersonaInfo {
|
||||
public_key: bs58::encode(public_key).into_string(),
|
||||
key_version,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn current(adapter: &FreenetAdapter) -> Result<Option<PersonaInfo>, String> {
|
||||
match call(adapter, InMsg::GetPublicKey).await? {
|
||||
OutMsg::Identity { public_key, key_version, .. } => Ok(Some(info(public_key, key_version))),
|
||||
OutMsg::NotInitialized => Ok(None),
|
||||
OutMsg::Refused { reason } => Err(reason),
|
||||
other => Err(format!("unexpected delegate reply: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create(adapter: &FreenetAdapter) -> Result<PersonaInfo, String> {
|
||||
match call(adapter, InMsg::CreateIdentity { force: false }).await? {
|
||||
OutMsg::Identity { public_key, key_version, .. } => Ok(info(public_key, key_version)),
|
||||
OutMsg::Refused { reason } => Err(reason),
|
||||
other => Err(format!("unexpected delegate reply: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seal the identity to a passphrase and write the artifact at `path`.
|
||||
/// The bytes go disk-direct from Rust; nothing crosses the webview.
|
||||
pub(crate) async fn export_to(
|
||||
adapter: &FreenetAdapter,
|
||||
passphrase: &str,
|
||||
path: &str,
|
||||
) -> Result<usize, String> {
|
||||
match call(
|
||||
adapter,
|
||||
InMsg::ExportIdentity {
|
||||
passphrase: passphrase.to_string(),
|
||||
m_cost_kib: EXPORT_M_COST_KIB,
|
||||
t_cost: EXPORT_T_COST,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
OutMsg::Artifact { bytes } => {
|
||||
let n = bytes.len();
|
||||
std::fs::write(path, bytes).map_err(|e| format!("writing artifact: {e}"))?;
|
||||
Ok(n)
|
||||
}
|
||||
OutMsg::NotInitialized => Err("no persona exists yet".into()),
|
||||
OutMsg::Refused { reason } => Err(reason),
|
||||
other => Err(format!("unexpected delegate reply: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore from a recovery artifact file. Refuses to overwrite an existing
|
||||
/// persona unless `force` — same trust-reset semantics the F1 record set.
|
||||
pub(crate) async fn import_from(
|
||||
adapter: &FreenetAdapter,
|
||||
passphrase: &str,
|
||||
path: &str,
|
||||
force: bool,
|
||||
) -> Result<PersonaInfo, String> {
|
||||
let artifact = std::fs::read(path).map_err(|e| format!("reading artifact: {e}"))?;
|
||||
match call(
|
||||
adapter,
|
||||
InMsg::ImportIdentity { artifact, passphrase: passphrase.to_string(), force },
|
||||
)
|
||||
.await?
|
||||
{
|
||||
OutMsg::Identity { public_key, key_version, .. } => Ok(info(public_key, key_version)),
|
||||
OutMsg::Refused { reason } => Err(reason),
|
||||
other => Err(format!("unexpected delegate reply: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn rotate(adapter: &FreenetAdapter) -> Result<RotationInfo, String> {
|
||||
match call(adapter, InMsg::RotateIdentity).await? {
|
||||
OutMsg::Rotated { old_public, new_public, key_version, .. } => Ok(RotationInfo {
|
||||
old_public: bs58::encode(old_public).into_string(),
|
||||
new_public: bs58::encode(new_public).into_string(),
|
||||
key_version,
|
||||
}),
|
||||
OutMsg::NotInitialized => Err("no persona exists yet".into()),
|
||||
OutMsg::Refused { reason } => Err(reason),
|
||||
other => Err(format!("unexpected delegate reply: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn remove(adapter: &FreenetAdapter) -> Result<(), String> {
|
||||
match call(adapter, InMsg::RemoveIdentity).await? {
|
||||
OutMsg::Removed => Ok(()),
|
||||
OutMsg::Refused { reason } => Err(reason),
|
||||
other => Err(format!("unexpected delegate reply: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Domain-separated signing for the Rust services (profiles today;
|
||||
/// playlists, links next). Deliberately not a Tauri command.
|
||||
pub(crate) async fn sign(
|
||||
adapter: &FreenetAdapter,
|
||||
domain: &str,
|
||||
payload: Vec<u8>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
match call(adapter, InMsg::Sign { domain: domain.to_string(), payload }).await? {
|
||||
OutMsg::Signature { sig, .. } => Ok(sig),
|
||||
OutMsg::NotInitialized => Err("no persona exists yet".into()),
|
||||
OutMsg::Refused { reason } => Err(reason),
|
||||
other => Err(format!("unexpected delegate reply: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tauri surface ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn persona_status(state: tauri::State<'_, AppState>) -> Result<PersonaStatus, String> {
|
||||
let adapter = state.freenet.clone();
|
||||
let delegate_key = FreenetAdapter::delegate_key_b58(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS);
|
||||
let adapter_status = adapter.status();
|
||||
let available = adapter.is_enabled()
|
||||
&& adapter_status == freenet_adapter::AdapterStatus::Connected;
|
||||
let persona = if available { current(&adapter).await.ok().flatten() } else { None };
|
||||
Ok(PersonaStatus { available, adapter: adapter_status, persona, delegate_key })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn persona_create(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<PersonaInfo, String> {
|
||||
let info = create(&state.freenet.clone()).await?;
|
||||
// Minted here: the profile ledger knows nothing can exist on the
|
||||
// network yet, so no search is ever spent looking for it.
|
||||
crate::profile::ledger_note_persona(&app, &info.public_key, true);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn persona_export(
|
||||
state: tauri::State<'_, AppState>,
|
||||
passphrase: String,
|
||||
path: String,
|
||||
) -> Result<usize, String> {
|
||||
export_to(&state.freenet.clone(), &passphrase, &path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn persona_import(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
passphrase: String,
|
||||
path: String,
|
||||
force: bool,
|
||||
) -> Result<PersonaInfo, String> {
|
||||
let info = import_from(&state.freenet.clone(), &passphrase, &path, force).await?;
|
||||
// Imported: another device may have published — the first read asks.
|
||||
crate::profile::ledger_note_persona(&app, &info.public_key, false);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn persona_rotate(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<RotationInfo, String> {
|
||||
let info = rotate(&state.freenet.clone()).await?;
|
||||
// A new key addresses a new profile contract: nothing published there yet.
|
||||
crate::profile::ledger_note_persona(&app, &info.new_public, true);
|
||||
crate::profile::ledger_forget_persona(&app, &info.old_public);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn persona_remove(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let adapter = state.freenet.clone();
|
||||
let gone = current(&adapter).await.ok().flatten();
|
||||
remove(&adapter).await?;
|
||||
if let Some(info) = gone {
|
||||
crate::profile::ledger_forget_persona(&app, &info.public_key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- tests ----
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wire_shapes_roundtrip() {
|
||||
// The variant ORDER is the wire; this pins encode/decode symmetry
|
||||
// for the message we depend on most.
|
||||
let msg = InMsg::ImportIdentity {
|
||||
artifact: vec![1, 2, 3],
|
||||
passphrase: "pw".into(),
|
||||
force: true,
|
||||
};
|
||||
let bytes = bincode::serialize(&msg).unwrap();
|
||||
// Variant index 4 (0-based) leads the encoding as a u32.
|
||||
assert_eq!(&bytes[..4], &4u32.to_le_bytes());
|
||||
let out = OutMsg::Identity { public_key: [7; 32], key_version: 3, created: false };
|
||||
let bytes = bincode::serialize(&out).unwrap();
|
||||
let back: OutMsg = bincode::deserialize(&bytes).unwrap();
|
||||
assert!(matches!(back, OutMsg::Identity { key_version: 3, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_delegate_generation_is_pinned() {
|
||||
// If this changes, a migration event is happening — see the module
|
||||
// docs. Update deliberately, never in passing.
|
||||
let key = FreenetAdapter::delegate_key_b58(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS);
|
||||
// The gen-2 identity delegate — the generation every F2→F2b spike
|
||||
// validated (research/freenet-f1's upgrade probe minted it).
|
||||
assert_eq!(key, "EtHTvPUtxbZ8TXGZJrmkj21xjQNqVmWdWAohVgydBgst");
|
||||
assert_eq!(DELEGATE_WASM.len(), 202026);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ops_refuse_while_disabled() {
|
||||
let adapter = FreenetAdapter::default();
|
||||
let err = current(&adapter).await.unwrap_err();
|
||||
assert!(err.contains("disabled"), "{err}");
|
||||
}
|
||||
|
||||
/// Live: needs a local freenet node on 7509.
|
||||
/// cargo test -p app --release persona_live -- --ignored
|
||||
/// Mutates the node's persona namespace (create/remove/rotate) — run
|
||||
/// against a throwaway node, not one holding an identity you keep.
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a local freenet node on 7509; mutates its persona"]
|
||||
async fn persona_live_full_loop() {
|
||||
let adapter = FreenetAdapter::default();
|
||||
adapter.enable();
|
||||
let mut tries = 0;
|
||||
while adapter.status() != freenet_adapter::AdapterStatus::Connected {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
tries += 1;
|
||||
assert!(tries < 50, "adapter never connected: {:?}", adapter.status());
|
||||
}
|
||||
|
||||
// Clean slate, then the full lifecycle.
|
||||
let _ = remove(&adapter).await;
|
||||
assert!(current(&adapter).await.unwrap().is_none());
|
||||
let created = create(&adapter).await.expect("create");
|
||||
assert_eq!(created.key_version, 1);
|
||||
// No silent overwrite.
|
||||
let dup = create(&adapter).await;
|
||||
assert!(dup.is_err());
|
||||
|
||||
// Export → remove → import reproduces the fingerprint.
|
||||
let dir = std::env::temp_dir().join("soulstream-persona-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("recovery.ssid");
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let n = export_to(&adapter, "correct horse battery", &path_str).await.expect("export");
|
||||
assert_eq!(n, 93, "the artifact is 93 bytes by construction");
|
||||
remove(&adapter).await.expect("remove");
|
||||
let restored = import_from(&adapter, "correct horse battery", &path_str, false)
|
||||
.await
|
||||
.expect("import");
|
||||
assert_eq!(restored.public_key, created.public_key);
|
||||
// Wrong passphrase refuses.
|
||||
let bad = import_from(&adapter, "wrong passphrase!", &path_str, true).await;
|
||||
assert!(bad.is_err());
|
||||
|
||||
// Rotation bumps the version and changes the key.
|
||||
let rot = rotate(&adapter).await.expect("rotate");
|
||||
assert_eq!(rot.old_public, created.public_key);
|
||||
assert_eq!(rot.key_version, 2);
|
||||
let now = current(&adapter).await.unwrap().unwrap();
|
||||
assert_eq!(now.public_key, rot.new_public);
|
||||
|
||||
// Signing works for Rust callers under an allowed domain.
|
||||
let sig = sign(&adapter, "soulstream.profile/v1", b"payload".to_vec())
|
||||
.await
|
||||
.expect("sign");
|
||||
assert_eq!(sig.len(), 64);
|
||||
// ...and refuses an unapproved domain with a typed reason.
|
||||
let refused = sign(&adapter, "evil.domain/v1", b"payload".to_vec()).await;
|
||||
assert!(refused.is_err());
|
||||
|
||||
adapter.disable();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
//! ProfileService — the persona's publishable face: display name, bio, and
|
||||
//! a sanitized avatar, published through the bundled persona/media
|
||||
//! contracts and signed by the identity delegate (persona.rs).
|
||||
//!
|
||||
//! Contract bytes are commitments like the delegate's (see persona.rs):
|
||||
//! `assets/persona_contract.wasm` (gen-2, avatar-accepting) and
|
||||
//! `assets/media_contract.wasm` (immutable content-addressed images), both
|
||||
//! straight from the F2/F2b validation runs, code hashes pinned by tests.
|
||||
//!
|
||||
//! The avatar pipeline is F2b's, verbatim in spirit:
|
||||
//! - PUBLISH: bounded decode → center-crop → 256×256 CatmullRom → RGB →
|
||||
//! deterministic PNG → budget gate. Metadata cannot survive (pixels in,
|
||||
//! critical chunks out).
|
||||
//! - VIEW: policy gates BEFORE any fetch → structural verification (hash,
|
||||
//! signature, declared bounds vs the signed reference) BEFORE decode →
|
||||
//! bounded decode (sniff, header-dims refusal pre-allocation) → only
|
||||
//! then do the sanitized bytes go to the webview. Any failure degrades
|
||||
//! to "no avatar" with a reason, never an error surface.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use freenet_adapter::FreenetAdapter;
|
||||
use image::{imageops::FilterType, ImageEncoder, ImageReader, Limits};
|
||||
use persona_state as ps;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::{config, persona};
|
||||
|
||||
const PROFILE_CONTRACT_WASM: &[u8] = include_bytes!("../assets/persona_contract.wasm");
|
||||
const MEDIA_CONTRACT_WASM: &[u8] = include_bytes!("../assets/media_contract.wasm");
|
||||
|
||||
const SOURCE_MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
const SOURCE_MAX_DIM: u32 = 8192;
|
||||
const READER_MAX_DIM: u32 = 512;
|
||||
const READER_MAX_ALLOC: u64 = 32 * 1024 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------- pipeline
|
||||
|
||||
fn limits(max_dim: u32) -> Limits {
|
||||
let mut l = Limits::default();
|
||||
l.max_image_width = Some(max_dim);
|
||||
l.max_image_height = Some(max_dim);
|
||||
l.max_alloc = Some(READER_MAX_ALLOC);
|
||||
l
|
||||
}
|
||||
|
||||
fn decode_bounded(
|
||||
bytes: &[u8],
|
||||
max_dim: u32,
|
||||
expect_png: bool,
|
||||
) -> Result<image::DynamicImage, String> {
|
||||
let mut reader = ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| format!("format sniff failed: {e}"))?;
|
||||
let fmt = reader
|
||||
.format()
|
||||
.ok_or_else(|| "unrecognized image format".to_string())?;
|
||||
if expect_png && fmt != image::ImageFormat::Png {
|
||||
return Err(format!("declared image/png but bytes are {fmt:?}"));
|
||||
}
|
||||
reader.limits(limits(max_dim));
|
||||
reader.decode().map_err(|e| format!("bounded decode refused: {e}"))
|
||||
}
|
||||
|
||||
/// Publisher normalization — F2b's recipe. Deterministic for a given input.
|
||||
pub(crate) fn normalize_avatar(source: &[u8]) -> Result<Vec<u8>, String> {
|
||||
if source.len() > SOURCE_MAX_BYTES {
|
||||
return Err(format!("source over {SOURCE_MAX_BYTES} bytes"));
|
||||
}
|
||||
let img = decode_bounded(source, SOURCE_MAX_DIM, false)?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let side = w.min(h);
|
||||
let cropped = img.crop_imm((w - side) / 2, (h - side) / 2, side, side);
|
||||
let resized = cropped.resize_exact(ps::V1_DIMENSION, ps::V1_DIMENSION, FilterType::CatmullRom);
|
||||
let rgb = resized.to_rgb8();
|
||||
let mut out = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut out)
|
||||
.write_image(rgb.as_raw(), ps::V1_DIMENSION, ps::V1_DIMENSION, image::ExtendedColorType::Rgb8)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if out.len() > ps::MAX_MEDIA_BYTES {
|
||||
return Err(format!(
|
||||
"normalized avatar is {} bytes, over the {} budget — this image compresses poorly; pick another",
|
||||
out.len(),
|
||||
ps::MAX_MEDIA_BYTES
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reader decode — the LAST gate before bytes reach the webview. Runs only
|
||||
/// after structural verification against the signed reference.
|
||||
fn reader_verify_decode(asset: &ps::MediaAssetV1) -> Result<(), String> {
|
||||
let img = decode_bounded(&asset.sanitized_static_bytes, READER_MAX_DIM, true)?;
|
||||
if img.width() != asset.width || img.height() != asset.height {
|
||||
return Err(format!(
|
||||
"decoded {}x{} contradicts declared {}x{}",
|
||||
img.width(),
|
||||
img.height(),
|
||||
asset.width,
|
||||
asset.height
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- service
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProfileView {
|
||||
pub owner: String,
|
||||
pub sequence: u64,
|
||||
pub display_name: String,
|
||||
pub bio: String,
|
||||
/// Base64 PNG of the VERIFIED, sanitized avatar — present only when the
|
||||
/// whole gauntlet passed.
|
||||
pub avatar_png_base64: Option<String>,
|
||||
/// Why there is no avatar image ("none published", policy, or the
|
||||
/// failure reason) — the UI renders its identicon on any of these.
|
||||
pub avatar_note: Option<String>,
|
||||
/// True when this came from the device's own ledger (what it last
|
||||
/// published) rather than a network read.
|
||||
pub from_ledger: bool,
|
||||
}
|
||||
|
||||
fn decode_owner(b58: &str) -> Result<[u8; 32], String> {
|
||||
bs58::decode(b58)
|
||||
.into_vec()
|
||||
.map_err(|e| format!("bad persona key: {e}"))?
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| "persona key is not 32 bytes".to_string())
|
||||
}
|
||||
|
||||
async fn fetch_profile(
|
||||
adapter: &FreenetAdapter,
|
||||
owner: &[u8; 32],
|
||||
) -> Result<Option<ps::ProfileState>, String> {
|
||||
let ids = FreenetAdapter::contract_ids(
|
||||
PROFILE_CONTRACT_WASM.to_vec(),
|
||||
&ps::make_profile_params(owner),
|
||||
);
|
||||
match adapter.contract_get(&ids.instance, false).await {
|
||||
Ok(bytes) if bytes.is_empty() => Ok(None),
|
||||
Ok(bytes) => ps::validate_profile(owner, &bytes)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("published profile failed verification: {e}")),
|
||||
Err(e) if e.to_string().contains("not found") => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the ledger
|
||||
//
|
||||
// What THIS device knows about its own persona's profile, kept beside the
|
||||
// node as <app_data>/freenet/profile-ledger.json, with verified avatar bytes
|
||||
// cached by content hash next to it. Reading our own profile from the ledger
|
||||
// is instant and works offline. Asking the network for a profile that was
|
||||
// never published is not: every such GET is a 12–30 s search of the ring that
|
||||
// ends in "retry loop exhausted" (observed live 2026-09-02 — three of them
|
||||
// behind one avatar change, a minute of nothing happening). The network is
|
||||
// asked only when it might know more than this device: an imported key never
|
||||
// searched, an UPDATE the network refuses, or an explicit refresh.
|
||||
|
||||
const ABSENT_RECHECK_SECS: u64 = 3600;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct Ledger {
|
||||
#[serde(default)]
|
||||
personas: BTreeMap<String, LedgerEntry>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct LedgerEntry {
|
||||
/// Minted on this device: nothing can exist on the network until this
|
||||
/// device publishes it.
|
||||
#[serde(default)]
|
||||
created_here: bool,
|
||||
/// The last state this device published (bincode, base64) — the truth
|
||||
/// for the next read-and-bump while this device is the only writer.
|
||||
#[serde(default)]
|
||||
published: Option<String>,
|
||||
/// Unix seconds of the last network search that found nothing.
|
||||
#[serde(default)]
|
||||
absent_checked_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// Where this device keeps what it knows. `None` in tests: network only.
|
||||
pub struct Local {
|
||||
ledger: PathBuf,
|
||||
avatars: PathBuf,
|
||||
}
|
||||
|
||||
impl Local {
|
||||
pub fn for_app(app: &tauri::AppHandle) -> Result<Self, String> {
|
||||
let base = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("freenet");
|
||||
Ok(Self { ledger: base.join("profile-ledger.json"), avatars: base.join("avatars") })
|
||||
}
|
||||
fn load(&self) -> Ledger {
|
||||
std::fs::read(&self.ledger)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn save(&self, ledger: &Ledger) {
|
||||
if let Some(dir) = self.ledger.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
match serde_json::to_vec_pretty(ledger) {
|
||||
Ok(b) => {
|
||||
if let Err(e) = std::fs::write(&self.ledger, b) {
|
||||
log::warn!("profile ledger not saved: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("profile ledger not encoded: {e}"),
|
||||
}
|
||||
}
|
||||
fn note_published(&self, owner: &[u8; 32], bytes: &[u8]) {
|
||||
use base64::Engine;
|
||||
let mut l = self.load();
|
||||
let e = l.personas.entry(owner_b58(owner)).or_default();
|
||||
e.published = Some(base64::engine::general_purpose::STANDARD.encode(bytes));
|
||||
e.absent_checked_at = None;
|
||||
self.save(&l);
|
||||
}
|
||||
fn note_absent(&self, owner: &[u8; 32]) {
|
||||
let mut l = self.load();
|
||||
l.personas.entry(owner_b58(owner)).or_default().absent_checked_at = Some(now_secs());
|
||||
self.save(&l);
|
||||
}
|
||||
fn avatar_path(&self, hash: &[u8; 32]) -> PathBuf {
|
||||
self.avatars.join(format!("{}.png", hex(hash)))
|
||||
}
|
||||
fn cache_avatar(&self, hash: &[u8; 32], png: &[u8]) {
|
||||
let _ = std::fs::create_dir_all(&self.avatars);
|
||||
let _ = std::fs::write(self.avatar_path(hash), png);
|
||||
}
|
||||
/// Cached bytes only while they still hash to what the signed
|
||||
/// reference says; anything else is simply not used.
|
||||
fn cached_avatar(&self, hash: &[u8; 32]) -> Option<Vec<u8>> {
|
||||
let bytes = std::fs::read(self.avatar_path(hash)).ok()?;
|
||||
(blake3_hash(&bytes) == *hash).then_some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_b58(owner: &[u8; 32]) -> String {
|
||||
bs58::encode(owner).into_string()
|
||||
}
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// persona.rs tells the ledger when a key is minted here or imported.
|
||||
pub(crate) fn ledger_note_persona(app: &tauri::AppHandle, public_key_b58: &str, created_here: bool) {
|
||||
let Ok(local) = Local::for_app(app) else { return };
|
||||
let mut l = local.load();
|
||||
l.personas.insert(
|
||||
public_key_b58.to_string(),
|
||||
LedgerEntry { created_here, published: None, absent_checked_at: None },
|
||||
);
|
||||
local.save(&l);
|
||||
}
|
||||
/// ...and when it is gone.
|
||||
pub(crate) fn ledger_forget_persona(app: &tauri::AppHandle, public_key_b58: &str) {
|
||||
let Ok(local) = Local::for_app(app) else { return };
|
||||
let mut l = local.load();
|
||||
if l.personas.remove(public_key_b58).is_some() {
|
||||
local.save(&l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Our own profile from what this device knows; the network only when it
|
||||
/// might know more (an imported key never searched, or `force`). The bool
|
||||
/// says which one answered.
|
||||
async fn fetch_own_profile(
|
||||
adapter: &FreenetAdapter,
|
||||
owner: &[u8; 32],
|
||||
local: Option<&Local>,
|
||||
force: bool,
|
||||
) -> Result<(Option<ps::ProfileState>, bool), String> {
|
||||
if let (Some(l), false) = (local, force) {
|
||||
let ledger = l.load();
|
||||
if let Some(e) = ledger.personas.get(&owner_b58(owner)) {
|
||||
if let Some(b64) = &e.published {
|
||||
use base64::Engine;
|
||||
let known = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64)
|
||||
.ok()
|
||||
.and_then(|b| ps::validate_profile(owner, &b).ok());
|
||||
if let Some(p) = known {
|
||||
return Ok((Some(p), true));
|
||||
}
|
||||
} else if e.created_here {
|
||||
return Ok((None, true));
|
||||
} else if e
|
||||
.absent_checked_at
|
||||
.is_some_and(|t| now_secs().saturating_sub(t) < ABSENT_RECHECK_SECS)
|
||||
{
|
||||
return Ok((None, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
let found = fetch_profile(adapter, owner).await?;
|
||||
if let Some(l) = local {
|
||||
match &found {
|
||||
Some(p) => {
|
||||
if let Ok(b) = bincode::serialize(p) {
|
||||
l.note_published(owner, &b);
|
||||
}
|
||||
}
|
||||
None => l.note_absent(owner),
|
||||
}
|
||||
}
|
||||
Ok((found, false))
|
||||
}
|
||||
|
||||
/// Publish a signed profile revision. Read-and-bump (F5's rule) against what
|
||||
/// this device knows; if the network refuses the update because another
|
||||
/// writer moved first, read it for real once and bump past it.
|
||||
async fn publish_profile(
|
||||
adapter: &FreenetAdapter,
|
||||
owner: [u8; 32],
|
||||
display_name: String,
|
||||
bio: String,
|
||||
avatar: Option<ps::MediaRefV1>,
|
||||
local: Option<&Local>,
|
||||
) -> Result<ps::ProfileState, String> {
|
||||
if display_name.len() > ps::MAX_NAME_BYTES {
|
||||
return Err(format!("display name over {} bytes", ps::MAX_NAME_BYTES));
|
||||
}
|
||||
if bio.len() > ps::MAX_BIO_BYTES {
|
||||
return Err(format!("bio over {} bytes", ps::MAX_BIO_BYTES));
|
||||
}
|
||||
let (existing, _) = fetch_own_profile(adapter, &owner, local, false).await?;
|
||||
let mut sequence = existing.as_ref().map(|p| p.sequence + 1).unwrap_or(1);
|
||||
let params = ps::make_profile_params(&owner);
|
||||
let mut retried = false;
|
||||
loop {
|
||||
let mut p = ps::ProfileState {
|
||||
schema_version: ps::SCHEMA_VERSION,
|
||||
owner,
|
||||
sequence,
|
||||
display_name: display_name.clone(),
|
||||
bio: bio.clone(),
|
||||
avatar: avatar.clone(),
|
||||
sig: vec![],
|
||||
};
|
||||
p.sig = persona::sign(adapter, ps::SIGN_DOMAIN, ps::profile_sign_payload(&p)).await?;
|
||||
let bytes = bincode::serialize(&p).map_err(|e| e.to_string())?;
|
||||
let result = if existing.is_some() {
|
||||
adapter
|
||||
.contract_update(PROFILE_CONTRACT_WASM.to_vec(), ¶ms, bytes.clone())
|
||||
.await
|
||||
.map(|_| ())
|
||||
} else {
|
||||
adapter
|
||||
.contract_put(PROFILE_CONTRACT_WASM.to_vec(), ¶ms, bytes.clone(), false)
|
||||
.await
|
||||
.map(|_| ())
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
if let Some(l) = local {
|
||||
l.note_published(&owner, &bytes);
|
||||
}
|
||||
return Ok(p);
|
||||
}
|
||||
Err(e) if existing.is_some() && local.is_some() && !retried => {
|
||||
retried = true;
|
||||
match fetch_profile(adapter, &owner).await? {
|
||||
Some(fresh) if fresh.sequence >= sequence => {
|
||||
log::info!(
|
||||
"profile: network is at rev {}, ledger said {} — bumping past it",
|
||||
fresh.sequence,
|
||||
sequence - 1
|
||||
);
|
||||
sequence = fresh.sequence + 1;
|
||||
}
|
||||
_ => return Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn own_key(adapter: &FreenetAdapter) -> Result<[u8; 32], String> {
|
||||
let info = persona::current(adapter)
|
||||
.await?
|
||||
.ok_or("no persona exists yet — create one first")?;
|
||||
decode_owner(&info.public_key)
|
||||
}
|
||||
|
||||
fn verify_png_dims(bytes: &[u8], w: u32, h: u32) -> Result<(), String> {
|
||||
let img = decode_bounded(bytes, READER_MAX_DIM, true)?;
|
||||
if img.width() != w || img.height() != h {
|
||||
return Err(format!("decoded {}x{} contradicts declared {w}x{h}", img.width(), img.height()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The avatar half of the read gauntlet, in the design's order: policy →
|
||||
/// cache by hash → single fetch → verify chain → bounded decode → note.
|
||||
async fn render_view(
|
||||
adapter: &FreenetAdapter,
|
||||
owner: [u8; 32],
|
||||
p: ps::ProfileState,
|
||||
load_remote_images: bool,
|
||||
local: Option<&Local>,
|
||||
from_ledger: bool,
|
||||
) -> ProfileView {
|
||||
use base64::Engine;
|
||||
let mut v = ProfileView {
|
||||
owner: owner_b58(&owner),
|
||||
sequence: p.sequence,
|
||||
display_name: p.display_name.clone(),
|
||||
bio: p.bio.clone(),
|
||||
avatar_png_base64: None,
|
||||
avatar_note: None,
|
||||
from_ledger,
|
||||
};
|
||||
let Some(r) = &p.avatar else {
|
||||
v.avatar_note = Some("none published".into());
|
||||
return v;
|
||||
};
|
||||
if !load_remote_images {
|
||||
v.avatar_note = Some("remote profile images disabled".into());
|
||||
return v;
|
||||
}
|
||||
if let Some(png) = local.and_then(|l| l.cached_avatar(&r.content_hash)) {
|
||||
if png.len() as u32 == r.byte_length
|
||||
&& verify_png_dims(&png, r.width as u32, r.height as u32).is_ok()
|
||||
{
|
||||
v.avatar_png_base64 = Some(base64::engine::general_purpose::STANDARD.encode(&png));
|
||||
return v;
|
||||
}
|
||||
}
|
||||
let ids = FreenetAdapter::contract_ids(
|
||||
MEDIA_CONTRACT_WASM.to_vec(),
|
||||
&ps::make_media_params(&owner, &r.content_hash),
|
||||
);
|
||||
// Single attempt (no retry storm — F2b): failure degrades to a note.
|
||||
let state_bytes = match adapter.contract_get(&ids.instance, false).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
v.avatar_note = Some(format!("avatar unavailable: {e}"));
|
||||
return v;
|
||||
}
|
||||
};
|
||||
match ps::validate_asset(&owner, &r.content_hash, &state_bytes) {
|
||||
Ok(asset) => {
|
||||
let matches_ref = asset.media_type == r.media_type
|
||||
&& asset.sanitized_static_bytes.len() as u32 == r.byte_length
|
||||
&& asset.width == r.width as u32
|
||||
&& asset.height == r.height as u32;
|
||||
if !matches_ref {
|
||||
v.avatar_note = Some("asset contradicts the signed reference".into());
|
||||
} else if let Err(e) = reader_verify_decode(&asset) {
|
||||
v.avatar_note = Some(format!("decode refused: {e}"));
|
||||
} else {
|
||||
if let Some(l) = local {
|
||||
l.cache_avatar(&r.content_hash, &asset.sanitized_static_bytes);
|
||||
}
|
||||
v.avatar_png_base64 = Some(
|
||||
base64::engine::general_purpose::STANDARD.encode(&asset.sanitized_static_bytes),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => v.avatar_note = Some(format!("verification failed: {e}")),
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The full read gauntlet for anyone's profile — always the network.
|
||||
async fn view(
|
||||
adapter: &FreenetAdapter,
|
||||
owner: [u8; 32],
|
||||
load_remote_images: bool,
|
||||
local: Option<&Local>,
|
||||
) -> Result<Option<ProfileView>, String> {
|
||||
let Some(p) = fetch_profile(adapter, &owner).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(render_view(adapter, owner, p, load_remote_images, local, false).await))
|
||||
}
|
||||
|
||||
/// Our own profile — what this device knows, unless `force` asks the network.
|
||||
async fn view_own(
|
||||
adapter: &FreenetAdapter,
|
||||
owner: [u8; 32],
|
||||
load_remote_images: bool,
|
||||
local: Option<&Local>,
|
||||
force: bool,
|
||||
) -> Result<Option<ProfileView>, String> {
|
||||
let (p, from_ledger) = fetch_own_profile(adapter, &owner, local, force).await?;
|
||||
let Some(p) = p else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(render_view(adapter, owner, p, load_remote_images, local, from_ledger).await))
|
||||
}
|
||||
|
||||
async fn set_avatar(
|
||||
adapter: &FreenetAdapter,
|
||||
image_path: &str,
|
||||
local: Option<&Local>,
|
||||
) -> Result<ProfileView, String> {
|
||||
let owner = own_key(adapter).await?;
|
||||
let source = std::fs::read(image_path).map_err(|e| format!("reading image: {e}"))?;
|
||||
let png = normalize_avatar(&source)?;
|
||||
let content_hash = blake3_hash(&png);
|
||||
let (current, _) = fetch_own_profile(adapter, &owner, local, false).await?;
|
||||
let revision = current.as_ref().map(|p| p.sequence + 1).unwrap_or(1) as u32;
|
||||
let mut asset_id = [0u8; 16];
|
||||
asset_id.copy_from_slice(&blake3_hash(&[&owner[..], b"avatar"].concat())[..16]);
|
||||
let mut asset = ps::MediaAssetV1 {
|
||||
schema_version: ps::SCHEMA_VERSION,
|
||||
owner,
|
||||
asset_id,
|
||||
revision,
|
||||
media_type: ps::V1_MEDIA_TYPE.into(),
|
||||
width: ps::V1_DIMENSION,
|
||||
height: ps::V1_DIMENSION,
|
||||
sanitized_static_bytes: png.clone(),
|
||||
sig: vec![],
|
||||
};
|
||||
asset.sig = persona::sign(adapter, ps::SIGN_DOMAIN, ps::asset_sign_payload(&asset)).await?;
|
||||
let asset_bytes = bincode::serialize(&asset).map_err(|e| e.to_string())?;
|
||||
// Media object FIRST (design order), then the profile that points at it.
|
||||
adapter
|
||||
.contract_put(
|
||||
MEDIA_CONTRACT_WASM.to_vec(),
|
||||
&ps::make_media_params(&owner, &content_hash),
|
||||
asset_bytes,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Ours by construction — cache it so the view never has to fetch it back.
|
||||
if let Some(l) = local {
|
||||
l.cache_avatar(&content_hash, &png);
|
||||
}
|
||||
let avatar_ref = ps::MediaRefV1 {
|
||||
content_hash,
|
||||
media_type: ps::V1_MEDIA_TYPE.into(),
|
||||
byte_length: png.len() as u32,
|
||||
width: ps::V1_DIMENSION as u16,
|
||||
height: ps::V1_DIMENSION as u16,
|
||||
};
|
||||
let (name, bio) = current
|
||||
.map(|p| (p.display_name, p.bio))
|
||||
.unwrap_or_else(|| (String::new(), String::new()));
|
||||
publish_profile(adapter, owner, name, bio, Some(avatar_ref), local).await?;
|
||||
view_own(adapter, owner, true, local, false)
|
||||
.await?
|
||||
.ok_or("profile vanished after publish".into())
|
||||
}
|
||||
|
||||
fn blake3_hash(bytes: &[u8]) -> [u8; 32] {
|
||||
// Route through persona-state's blake3 so src-tauri needs no direct dep.
|
||||
ps::content_hash(bytes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- commands
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn profile_get(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
persona: Option<String>,
|
||||
force: Option<bool>,
|
||||
) -> Result<Option<ProfileView>, String> {
|
||||
let adapter = state.freenet.clone();
|
||||
let load = config::load(&app).freenet_load_remote_images.unwrap_or(true);
|
||||
let local = Local::for_app(&app)?;
|
||||
match persona {
|
||||
Some(b58) => view(&adapter, decode_owner(&b58)?, load, Some(&local)).await,
|
||||
None => {
|
||||
let owner = own_key(&adapter).await?;
|
||||
view_own(&adapter, owner, load, Some(&local), force.unwrap_or(false)).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn profile_publish(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
display_name: String,
|
||||
bio: String,
|
||||
) -> Result<ProfileView, String> {
|
||||
let adapter = state.freenet.clone();
|
||||
let local = Local::for_app(&app)?;
|
||||
let owner = own_key(&adapter).await?;
|
||||
let (current, _) = fetch_own_profile(&adapter, &owner, Some(&local), false).await?;
|
||||
let avatar = current.and_then(|p| p.avatar);
|
||||
publish_profile(&adapter, owner, display_name, bio, avatar, Some(&local)).await?;
|
||||
view_own(&adapter, owner, true, Some(&local), false)
|
||||
.await?
|
||||
.ok_or("profile vanished after publish".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn profile_set_avatar(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
image_path: String,
|
||||
) -> Result<ProfileView, String> {
|
||||
let local = Local::for_app(&app)?;
|
||||
set_avatar(&state.freenet.clone(), &image_path, Some(&local)).await
|
||||
}
|
||||
|
||||
/// Withdraw the avatar pointer. Truthful semantics (F2b): previously
|
||||
/// published copies of the old image remain fetchable on the network —
|
||||
/// this changes what the profile points at, it does not erase.
|
||||
#[tauri::command]
|
||||
pub async fn profile_clear_avatar(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<ProfileView, String> {
|
||||
let adapter = state.freenet.clone();
|
||||
let local = Local::for_app(&app)?;
|
||||
let owner = own_key(&adapter).await?;
|
||||
let (current, _) = fetch_own_profile(&adapter, &owner, Some(&local), false).await?;
|
||||
let current = current.ok_or("no profile published yet")?;
|
||||
publish_profile(&adapter, owner, current.display_name, current.bio, None, Some(&local)).await?;
|
||||
view_own(&adapter, owner, true, Some(&local), false)
|
||||
.await?
|
||||
.ok_or("profile vanished after publish".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn photo_jpeg() -> Vec<u8> {
|
||||
let w = 900u32;
|
||||
let h = 600u32;
|
||||
let mut rgb = image::RgbImage::new(w, h);
|
||||
for (x, y, p) in rgb.enumerate_pixels_mut() {
|
||||
*p = image::Rgb([(x * 255 / w) as u8, (y * 255 / h) as u8, (((x + y) * 255) / (w + h)) as u8]);
|
||||
}
|
||||
let mut jpeg = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 85)
|
||||
.encode_image(&image::DynamicImage::ImageRgb8(rgb))
|
||||
.unwrap();
|
||||
// Splice an EXIF APP1 + COM segment after SOI — must not survive.
|
||||
let exif = b"Exif\0\0SPIKEGPS-LAT51-LON7";
|
||||
let com = b"SECRETCOMMENT-nope";
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&jpeg[..2]);
|
||||
out.extend_from_slice(&[0xFF, 0xE1]);
|
||||
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(exif);
|
||||
out.extend_from_slice(&[0xFF, 0xFE]);
|
||||
out.extend_from_slice(&((com.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(com);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contract_generations_are_pinned() {
|
||||
// Commitment bytes, like the delegate's — changing either is a
|
||||
// migration event (copy-forward is disabled upstream).
|
||||
let p = FreenetAdapter::contract_ids(PROFILE_CONTRACT_WASM.to_vec(), b"x");
|
||||
assert_eq!(p.code_hash, "EeQoECY38qpiHSqMwd4W1KHQMDyRqKqVsq3fV56Me7g4");
|
||||
let m = FreenetAdapter::contract_ids(MEDIA_CONTRACT_WASM.to_vec(), b"x");
|
||||
assert_eq!(m.code_hash, "6miQr6YTLEzjnoTCr9kFuapQLMq4z2PVqK6QSQzJpotT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_is_deterministic_and_strips_metadata() {
|
||||
let src = photo_jpeg();
|
||||
let a = normalize_avatar(&src).unwrap();
|
||||
let b = normalize_avatar(&src).unwrap();
|
||||
assert_eq!(a, b, "same input must produce identical bytes");
|
||||
assert!(a.len() <= ps::MAX_MEDIA_BYTES);
|
||||
for pat in [&b"SPIKEGPS"[..], &b"SECRETCOMMENT"[..], &b"Exif"[..]] {
|
||||
assert!(!a.windows(pat.len()).any(|w| w == pat));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reader_refuses_lies_and_bombs() {
|
||||
let png = normalize_avatar(&photo_jpeg()).unwrap();
|
||||
let asset = |bytes: Vec<u8>, w: u32, h: u32| ps::MediaAssetV1 {
|
||||
schema_version: ps::SCHEMA_VERSION,
|
||||
owner: [1; 32],
|
||||
asset_id: [2; 16],
|
||||
revision: 1,
|
||||
media_type: ps::V1_MEDIA_TYPE.into(),
|
||||
width: w,
|
||||
height: h,
|
||||
sanitized_static_bytes: bytes,
|
||||
sig: vec![0; 64],
|
||||
};
|
||||
// dimension lie
|
||||
assert!(reader_verify_decode(&asset(png.clone(), 64, 64)).is_err());
|
||||
// SVG declared png → sniff refusal
|
||||
let svg = b"<svg xmlns='x'><script>1</script></svg>".to_vec();
|
||||
assert!(reader_verify_decode(&asset(svg, 256, 256)).is_err());
|
||||
// IHDR bomb: tiny bytes claiming 60000x60000 — refused pre-allocation
|
||||
let mut bomb = png.clone();
|
||||
bomb[16..20].copy_from_slice(&60000u32.to_be_bytes());
|
||||
bomb[20..24].copy_from_slice(&60000u32.to_be_bytes());
|
||||
let mut h = crc32fast::Hasher::new();
|
||||
h.update(&bomb[12..29]);
|
||||
let crc = h.finalize();
|
||||
bomb[29..33].copy_from_slice(&crc.to_be_bytes());
|
||||
let t0 = std::time::Instant::now();
|
||||
assert!(reader_verify_decode(&asset(bomb, 256, 256)).is_err());
|
||||
assert!(t0.elapsed().as_millis() < 500, "bomb refusal must be immediate");
|
||||
}
|
||||
|
||||
/// Live: needs a local freenet node on 7509. Mutates its persona and
|
||||
/// profile namespaces — throwaway node only.
|
||||
/// cargo test -p app --release profile_live -- --ignored
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a local freenet node on 7509; mutates persona+profile"]
|
||||
async fn profile_live_full_loop() {
|
||||
let adapter = FreenetAdapter::default();
|
||||
adapter.enable();
|
||||
let mut tries = 0;
|
||||
while adapter.status() != freenet_adapter::AdapterStatus::Connected {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
tries += 1;
|
||||
assert!(tries < 50, "adapter never connected: {:?}", adapter.status());
|
||||
}
|
||||
// Fresh persona for the run.
|
||||
let _ = persona::remove(&adapter).await;
|
||||
let created = persona::create(&adapter).await.expect("create persona");
|
||||
let owner = decode_owner(&created.public_key).unwrap();
|
||||
|
||||
// Publish name + bio; read back through the network.
|
||||
publish_profile(&adapter, owner, "Kyle".into(), "first face".into(), None, None)
|
||||
.await
|
||||
.expect("publish");
|
||||
let v = view(&adapter, owner, true, None).await.unwrap().expect("view");
|
||||
assert_eq!(v.display_name, "Kyle");
|
||||
assert_eq!(v.sequence, 1);
|
||||
assert_eq!(v.avatar_note.as_deref(), Some("none published"));
|
||||
|
||||
// Avatar: temp jpeg → normalize → publish → view carries the PNG.
|
||||
let dir = std::env::temp_dir().join("soulstream-profile-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let img_path = dir.join("source.jpg");
|
||||
std::fs::write(&img_path, photo_jpeg()).unwrap();
|
||||
let v = set_avatar(&adapter, &img_path.to_string_lossy(), None)
|
||||
.await
|
||||
.expect("set avatar");
|
||||
assert!(v.avatar_png_base64.is_some(), "note: {:?}", v.avatar_note);
|
||||
assert_eq!(v.sequence, 2);
|
||||
|
||||
// Policy gate: remote images off → no avatar bytes, honest note.
|
||||
let v = view(&adapter, owner, false, None).await.unwrap().unwrap();
|
||||
assert!(v.avatar_png_base64.is_none());
|
||||
assert_eq!(v.avatar_note.as_deref(), Some("remote profile images disabled"));
|
||||
|
||||
// Withdraw: pointer clears; sequence bumps.
|
||||
let owner_key = owner;
|
||||
let cur = fetch_profile(&adapter, &owner_key).await.unwrap().unwrap();
|
||||
publish_profile(&adapter, owner_key, cur.display_name, cur.bio, None, None)
|
||||
.await
|
||||
.expect("clear");
|
||||
let v = view(&adapter, owner, true, None).await.unwrap().unwrap();
|
||||
assert!(v.avatar_png_base64.is_none());
|
||||
assert_eq!(v.sequence, 3);
|
||||
|
||||
adapter.disable();
|
||||
let _ = std::fs::remove_file(&img_path);
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,10 @@ pub struct AppState {
|
||||
/// never routes it — so the resolver strips these terms up front instead
|
||||
/// of discovering the silence by timeout. Lowercased on receipt.
|
||||
pub excluded_search_phrases: Mutex<Vec<String>>,
|
||||
/// The experimental Freenet layer's one handle (default: disabled, inert).
|
||||
/// The privilege boundary: node operations never leave the Rust side.
|
||||
pub freenet: freenet_adapter::FreenetAdapter,
|
||||
/// The local Freenet peer THIS app launched (freenet.rs), if any. A node
|
||||
/// started by hand is not here — the adapter finds it all the same.
|
||||
pub freenet_node: std::sync::Mutex<Option<std::process::Child>>,
|
||||
}
|
||||
Reference in new issue
Block a user