commit d3912855ffe51580362696e8b76029dce3e222e5
parent 438970dc46e39cabb19d44b5cf3be2d039f951b4
Author: triesap <tyson@radroots.org>
Date: Sun, 15 Feb 2026 17:30:58 +0000
app: add shared app `radroots-core` and platform ffi crates
- add `radroots-app-core` as the shared rust backend crate for client runtimes
- add app-ffi-swift and `radroots-app-wasm` crates wired to `radroots-app-core`
- register new crates and uniffi build deps in the workspace manifest
- fix nostr post stream manager to match current stream event shape
Diffstat:
21 files changed, 2173 insertions(+), 14 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -149,6 +149,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
+name = "askama"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7"
+dependencies = [
+ "askama_derive",
+ "itoa",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac"
+dependencies = [
+ "askama_parser",
+ "basic-toml",
+ "memchr",
+ "proc-macro2",
+ "quote",
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "syn",
+]
+
+[[package]]
+name = "askama_parser"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f"
+dependencies = [
+ "memchr",
+ "serde",
+ "serde_derive",
+ "winnow",
+]
+
+[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -242,6 +284,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
+name = "basic-toml"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
+dependencies = [
+ "serde",
+]
+
+[[package]]
name = "bech32"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -331,6 +382,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
+name = "camino"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.16",
+]
+
+[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -471,7 +554,7 @@ dependencies = [
"rust-ini",
"serde",
"serde_json",
- "toml",
+ "toml 0.8.23",
"yaml-rust2",
]
@@ -683,6 +766,15 @@ dependencies = [
]
[[package]]
+name = "fs-err"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -815,6 +907,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -827,6 +925,17 @@ dependencies = [
]
[[package]]
+name = "goblin"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
+dependencies = [
+ "log",
+ "plain",
+ "scroll",
+]
+
+[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1599,6 +1708,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1713,6 +1828,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
+name = "radroots-app-core"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "radroots-core",
+ "radroots-events",
+ "radroots-events-codec",
+ "radroots-log",
+ "radroots-net-core",
+ "radroots-nostr",
+ "radroots-trade",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "tokio",
+ "tracing",
+ "uniffi",
+]
+
+[[package]]
+name = "radroots-app-ffi-swift"
+version = "0.1.0"
+dependencies = [
+ "radroots-app-core",
+ "uniffi",
+ "uniffi_build",
+]
+
+[[package]]
+name = "radroots-app-wasm"
+version = "0.1.0"
+dependencies = [
+ "radroots-app-core",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "radroots-core"
version = "0.1.0"
dependencies = [
@@ -1843,7 +1995,7 @@ dependencies = [
"tempfile",
"thiserror 1.0.69",
"tokio",
- "toml",
+ "toml 0.8.23",
"tracing",
]
@@ -2297,6 +2449,26 @@ dependencies = [
]
[[package]]
+name = "scroll"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
+dependencies = [
+ "scroll_derive",
+]
+
+[[package]]
+name = "scroll_derive"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2338,6 +2510,16 @@ dependencies = [
]
[[package]]
+name = "semver"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2458,6 +2640,12 @@ dependencies = [
]
[[package]]
+name = "siphasher"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
+
+[[package]]
name = "slab"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2470,6 +2658,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
+name = "smawk"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
+
+[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2496,6 +2690,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2561,6 +2761,15 @@ dependencies = [
]
[[package]]
+name = "textwrap"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
+dependencies = [
+ "smawk",
+]
+
+[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2744,6 +2953,15 @@ dependencies = [
[[package]]
name = "toml"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
@@ -2992,6 +3210,138 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
+name = "uniffi"
+version = "0.29.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4"
+dependencies = [
+ "anyhow",
+ "camino",
+ "cargo_metadata",
+ "clap",
+ "uniffi_bindgen",
+ "uniffi_core",
+ "uniffi_macros",
+ "uniffi_pipeline",
+]
+
+[[package]]
+name = "uniffi_bindgen"
+version = "0.29.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b39ef1acbe1467d5d210f274fae344cb6f8766339330cb4c9688752899bf6b"
+dependencies = [
+ "anyhow",
+ "askama",
+ "camino",
+ "cargo_metadata",
+ "fs-err",
+ "glob",
+ "goblin",
+ "heck",
+ "indexmap",
+ "once_cell",
+ "serde",
+ "tempfile",
+ "textwrap",
+ "toml 0.5.11",
+ "uniffi_internal_macros",
+ "uniffi_meta",
+ "uniffi_pipeline",
+ "uniffi_udl",
+]
+
+[[package]]
+name = "uniffi_build"
+version = "0.29.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6683e6b665423cddeacd89a3f97312cf400b2fb245a26f197adaf65c45d505b2"
+dependencies = [
+ "anyhow",
+ "camino",
+ "uniffi_bindgen",
+]
+
+[[package]]
+name = "uniffi_core"
+version = "0.29.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d990b553d6b9a7ee9c3ae71134674739913d52350b56152b0e613595bb5a6f"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "once_cell",
+ "static_assertions",
+]
+
+[[package]]
+name = "uniffi_internal_macros"
+version = "0.29.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04f4f224becf14885c10e6e400b95cc4d1985738140cb194ccc2044563f8a56b"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "uniffi_macros"
+version = "0.29.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b481d385af334871d70904e6a5f129be7cd38c18fcf8dd8fd1f646b426a56d58"
+dependencies = [
+ "camino",
+ "fs-err",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn",
+ "toml 0.5.11",
+ "uniffi_meta",
+]
+
+[[package]]
+name = "uniffi_meta"
+version = "0.29.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10f817868a3b171bb7bf259e882138d104deafde65684689b4694c846d322491"
+dependencies = [
+ "anyhow",
+ "siphasher",
+ "uniffi_internal_macros",
+ "uniffi_pipeline",
+]
+
+[[package]]
+name = "uniffi_pipeline"
+version = "0.29.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b147e133ad7824e32426b90bc41fda584363563f2ba747f590eca1fd6fd14e6"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "tempfile",
+ "uniffi_internal_macros",
+]
+
+[[package]]
+name = "uniffi_udl"
+version = "0.29.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caed654fb73da5abbc7a7e9c741532284532ba4762d6fe5071372df22a41730a"
+dependencies = [
+ "anyhow",
+ "textwrap",
+ "uniffi_meta",
+ "weedle2",
+]
+
+[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3235,6 +3585,15 @@ dependencies = [
]
[[package]]
+name = "weedle2"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e"
+dependencies = [
+ "nom",
+]
+
+[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -1,5 +1,8 @@
[workspace]
members = [
+ "app-core",
+ "app-ffi-swift",
+ "app-wasm",
"core",
"events",
"events-codec",
@@ -31,6 +34,9 @@ rust-version = "1.92.0"
license = "AGPL-3.0"
[workspace.dependencies]
+radroots-app-core = { path = "app-core", version = "0.1.0", default-features = false }
+radroots-app-ffi-swift = { path = "app-ffi-swift", version = "0.1.0", default-features = false }
+radroots-app-wasm = { path = "app-wasm", version = "0.1.0", default-features = false }
radroots-core = { path = "core", version = "0.1.0", default-features = false }
radroots-events = { path = "events", version = "0.1.0", default-features = false }
radroots-events-codec = { path = "events-codec", version = "0.1.0", default-features = false }
@@ -87,7 +93,8 @@ ts-rs = { version = "11.1" }
typeshare = { version = "1" }
url = { version = "2" }
uuid = { version = "1.16.0", features = ["v4", "v7"] }
-uniffi = { version = "0.29.4" }
+uniffi = { version = "=0.29.4" }
+uniffi_build = { version = "=0.29.4" }
wasm-bindgen = { version = "0.2" }
wasm-bindgen-futures = { version = "0.4" }
wasm-bindgen-test = { version = "0.3" }
diff --git a/app-core/Cargo.toml b/app-core/Cargo.toml
@@ -0,0 +1,39 @@
+[package]
+name = "radroots-app-core"
+version.workspace = true
+edition.workspace = true
+authors = ["Radroots Authors"]
+rust-version.workspace = true
+license.workspace = true
+
+[lib]
+crate-type = ["rlib"]
+
+[features]
+default = ["rt", "nostr-client"]
+rt = ["radroots-net-core/rt"]
+nostr-client = [
+ "radroots-net-core/nostr-client",
+ "dep:radroots-core",
+ "dep:radroots-events-codec",
+ "dep:radroots-trade",
+ "dep:radroots-nostr",
+]
+directories = ["radroots-net-core/directories"]
+fs-persistence = ["radroots-net-core/fs-persistence"]
+
+[dependencies]
+radroots-log = { workspace = true }
+radroots-events = { workspace = true }
+radroots-net-core = { workspace = true, features = ["std"] }
+radroots-core = { workspace = true, optional = true }
+radroots-events-codec = { workspace = true, features = ["serde_json"], optional = true }
+radroots-trade = { workspace = true, features = ["std", "serde", "serde_json"], optional = true }
+radroots-nostr = { workspace = true, features = ["events"], optional = true }
+chrono = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+tracing = { workspace = true }
+uniffi = { workspace = true }
+tokio = { workspace = true }
diff --git a/app-core/build.rs b/app-core/build.rs
@@ -0,0 +1,53 @@
+use std::{
+ env,
+ process::Command,
+ time::{SystemTime, UNIX_EPOCH},
+};
+
+fn main() {
+ println!("cargo:rerun-if-changed=build.rs");
+ println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH");
+ println!("cargo:rerun-if-env-changed=PROFILE");
+
+ let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".into());
+ if let Ok(out) = Command::new(rustc).arg("--version").output() {
+ if out.status.success() {
+ if let Ok(ver) = String::from_utf8(out.stdout) {
+ println!("cargo:rustc-env=RUSTC_VERSION={}", ver.trim());
+ }
+ }
+ }
+
+ if let Ok(out) = Command::new("git")
+ .args(["rev-parse", "--short=12", "HEAD"])
+ .output()
+ {
+ if out.status.success() {
+ let mut sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
+ let dirty = Command::new("git")
+ .args(["status", "--porcelain"])
+ .output()
+ .ok()
+ .map_or(false, |o| o.status.success() && !o.stdout.is_empty());
+ if dirty {
+ sha.push_str("-dirty");
+ }
+ println!("cargo:rustc-env=GIT_HASH={sha}");
+ }
+ }
+
+ if let Ok(profile) = env::var("PROFILE") {
+ println!("cargo:rustc-env=PROFILE={profile}");
+ }
+
+ let epoch = env::var("SOURCE_DATE_EPOCH")
+ .ok()
+ .and_then(|v| v.parse::<u64>().ok())
+ .unwrap_or_else(|| {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+ });
+ println!("cargo:rustc-env=BUILD_TIME_UNIX={epoch}");
+}
diff --git a/app-core/src/error.rs b/app-core/src/error.rs
@@ -0,0 +1,7 @@
+use thiserror::Error;
+
+#[derive(Debug, Error, uniffi::Error)]
+pub enum RadrootsAppError {
+ #[error("{0}")]
+ Msg(String),
+}
diff --git a/app-core/src/lib.rs b/app-core/src/lib.rs
@@ -0,0 +1,10 @@
+uniffi::setup_scaffolding!("radroots");
+
+pub mod error;
+pub mod logging;
+pub mod runtime;
+
+pub use error::RadrootsAppError;
+pub use radroots_net_core::net::{BuildInfo, NetInfo};
+pub use radroots_net_core::{Net, NetHandle};
+pub use runtime::RadrootsRuntime;
diff --git a/app-core/src/logging.rs b/app-core/src/logging.rs
@@ -0,0 +1,39 @@
+use std::path::PathBuf;
+
+#[uniffi::export]
+pub fn init_logging(
+ dir: Option<String>,
+ file_name: Option<String>,
+ is_stdout: Option<bool>,
+) -> Result<(), crate::RadrootsAppError> {
+ let opts = radroots_log::LoggingOptions {
+ dir: dir.map(PathBuf::from),
+ file_name: file_name.unwrap_or_else(|| "radroots.log".to_string()),
+ stdout: is_stdout.unwrap_or(true),
+ default_level: None,
+ };
+ radroots_log::init_logging(opts).map_err(|e| crate::RadrootsAppError::Msg(format!("{e}")))
+}
+
+#[uniffi::export]
+pub fn init_logging_stdout() -> Result<(), crate::RadrootsAppError> {
+ radroots_log::init_stdout().map_err(|e| crate::RadrootsAppError::Msg(format!("{e}")))
+}
+
+#[uniffi::export]
+pub fn log_info(msg: String) -> Result<(), crate::RadrootsAppError> {
+ radroots_log::log_info(msg);
+ Ok(())
+}
+
+#[uniffi::export]
+pub fn log_error(msg: String) -> Result<(), crate::RadrootsAppError> {
+ radroots_log::log_error(msg);
+ Ok(())
+}
+
+#[uniffi::export]
+pub fn log_debug(msg: String) -> Result<(), crate::RadrootsAppError> {
+ radroots_log::log_debug(msg);
+ Ok(())
+}
diff --git a/app-core/src/runtime/app_info.rs b/app-core/src/runtime/app_info.rs
@@ -0,0 +1,26 @@
+#[derive(Debug, Clone, Default, serde::Serialize, uniffi::Record)]
+pub struct AppInfoPlatform {
+ pub platform: Option<String>,
+ pub bundle_id: Option<String>,
+ pub version: Option<String>,
+ pub build_number: Option<String>,
+ pub build_sha: Option<String>,
+}
+
+impl AppInfoPlatform {
+ pub fn new(
+ platform: Option<String>,
+ bundle_id: Option<String>,
+ version: Option<String>,
+ build_number: Option<String>,
+ build_sha: Option<String>,
+ ) -> Self {
+ Self {
+ platform,
+ bundle_id,
+ version,
+ build_number,
+ build_sha,
+ }
+ }
+}
diff --git a/app-core/src/runtime/builder.rs b/app-core/src/runtime/builder.rs
@@ -0,0 +1,43 @@
+use radroots_net_core::NetHandle;
+use radroots_net_core::builder::NetBuilder;
+use radroots_net_core::config::NetConfig;
+
+use crate::RadrootsAppError;
+
+pub struct RuntimeBuilder {
+ config: NetConfig,
+ manage_runtime: bool,
+}
+
+impl Default for RuntimeBuilder {
+ fn default() -> Self {
+ Self {
+ config: NetConfig::default(),
+ manage_runtime: true,
+ }
+ }
+}
+
+impl RuntimeBuilder {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn with_config(mut self, config: NetConfig) -> Self {
+ self.config = config;
+ self
+ }
+
+ pub fn manage_runtime(mut self, manage: bool) -> Self {
+ self.manage_runtime = manage;
+ self
+ }
+
+ pub fn build(self) -> Result<NetHandle, RadrootsAppError> {
+ NetBuilder::new()
+ .config(self.config)
+ .manage_runtime(self.manage_runtime)
+ .build()
+ .map_err(|e| RadrootsAppError::Msg(format!("net build failed: {e}")))
+ }
+}
diff --git a/app-core/src/runtime/info.rs b/app-core/src/runtime/info.rs
@@ -0,0 +1,76 @@
+use super::RadrootsRuntime;
+use chrono::Utc;
+use radroots_net_core::net;
+use serde::Serialize;
+
+#[derive(Debug, Clone, Serialize, Default, uniffi::Record)]
+pub struct NetBuildInfo {
+ pub crate_name: String,
+ pub crate_version: String,
+ pub rustc: Option<String>,
+ pub profile: Option<String>,
+ pub git_sha: Option<String>,
+ pub build_time_unix: Option<u64>,
+}
+
+impl From<&net::BuildInfo> for NetBuildInfo {
+ fn from(b: &net::BuildInfo) -> Self {
+ Self {
+ crate_name: b.crate_name.to_string(),
+ crate_version: b.crate_version.to_string(),
+ rustc: b.rustc.map(|s| s.to_string()),
+ profile: b.profile.map(|s| s.to_string()),
+ git_sha: b.git_sha.map(|s| s.to_string()),
+ build_time_unix: b.build_time_unix,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, uniffi::Record)]
+pub struct AppInfo {
+ pub build: NetBuildInfo,
+ pub started_unix_ms: i64,
+ pub uptime_millis: i64,
+ pub shutting_down: bool,
+ pub platform: Option<super::app_info::AppInfoPlatform>,
+}
+
+#[derive(Debug, Clone, Serialize, uniffi::Record)]
+pub struct RuntimeInfo {
+ pub app: AppInfo,
+ pub net: NetBuildInfo,
+}
+
+pub fn gather_runtime_info(runtime: &RadrootsRuntime) -> RuntimeInfo {
+ let now_ms = Utc::now().timestamp_millis();
+ let app_info = AppInfo {
+ build: app_build_info(),
+ started_unix_ms: runtime.started_unix_ms,
+ uptime_millis: now_ms - runtime.started_unix_ms,
+ shutting_down: runtime
+ .shutting_down
+ .load(std::sync::atomic::Ordering::SeqCst),
+ platform: runtime.platform_app.read().ok().and_then(|g| (*g).clone()),
+ };
+
+ let net_info = match runtime.net.lock() {
+ Ok(guard) => NetBuildInfo::from(&guard.info.build),
+ Err(_) => NetBuildInfo::default(),
+ };
+
+ RuntimeInfo {
+ app: app_info,
+ net: net_info,
+ }
+}
+
+pub fn app_build_info() -> NetBuildInfo {
+ NetBuildInfo {
+ crate_name: env!("CARGO_PKG_NAME").to_string(),
+ crate_version: env!("CARGO_PKG_VERSION").to_string(),
+ rustc: option_env!("RUSTC_VERSION").map(|s| s.to_string()),
+ profile: option_env!("PROFILE").map(|s| s.to_string()),
+ git_sha: option_env!("GIT_HASH").map(|s| s.to_string()),
+ build_time_unix: option_env!("BUILD_TIME_UNIX").and_then(|s| s.parse().ok()),
+ }
+}
diff --git a/app-core/src/runtime/key_management.rs b/app-core/src/runtime/key_management.rs
@@ -0,0 +1,129 @@
+use super::RadrootsRuntime;
+use crate::RadrootsAppError;
+use std::path::PathBuf;
+
+#[uniffi::export]
+impl RadrootsRuntime {
+ pub fn keys_is_loaded(&self) -> bool {
+ if let Ok(guard) = self.net.lock() {
+ #[cfg(feature = "nostr-client")]
+ {
+ return guard.keys.state.loaded;
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ return false;
+ }
+ }
+ false
+ }
+
+ pub fn keys_npub(&self) -> Option<String> {
+ if let Ok(guard) = self.net.lock() {
+ #[cfg(feature = "nostr-client")]
+ {
+ return guard.keys.npub();
+ }
+ }
+ None
+ }
+
+ pub fn keys_generate_in_memory(&self) -> Result<String, RadrootsAppError> {
+ let mut guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ let k = guard.keys.generate_in_memory();
+ return Ok(k.public_key().to_string());
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn keys_export_secret_hex(&self) -> Result<String, RadrootsAppError> {
+ let guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ return guard
+ .keys
+ .export_secret_hex()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")));
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn keys_load_hex32(&self, hex: String) -> Result<(), RadrootsAppError> {
+ let mut guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ guard
+ .keys
+ .load_from_hex32(&hex)
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ Ok(())
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn keys_load_from_path_auto(&self, path: String) -> Result<(), RadrootsAppError> {
+ let mut guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ guard
+ .keys
+ .load_from_path_auto(PathBuf::from(path))
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ Ok(())
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn keys_persist_best_practice(&self) -> Result<String, RadrootsAppError> {
+ let _guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(all(
+ feature = "nostr-client",
+ feature = "directories",
+ feature = "fs-persistence"
+ ))]
+ {
+ let p = _guard
+ .keys
+ .persist_best_practice()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ Ok(p.display().to_string())
+ }
+ #[cfg(not(all(
+ feature = "nostr-client",
+ feature = "directories",
+ feature = "fs-persistence"
+ )))]
+ {
+ Err(RadrootsAppError::Msg("persistence unsupported".into()))
+ }
+ }
+}
diff --git a/app-core/src/runtime/mod.rs b/app-core/src/runtime/mod.rs
@@ -0,0 +1,105 @@
+pub mod app_info;
+pub mod builder;
+pub mod info;
+pub mod key_management;
+pub mod nostr;
+#[cfg(feature = "nostr-client")]
+pub mod trade_listing;
+
+use chrono::Utc;
+use radroots_net_core::{NetHandle, builder::NetBuilder};
+#[cfg(feature = "nostr-client")]
+use std::sync::Mutex;
+use std::sync::{
+ RwLock,
+ atomic::{AtomicBool, Ordering},
+};
+#[cfg(feature = "nostr-client")]
+use tokio::sync::broadcast::Receiver;
+use tracing::info;
+
+use self::{
+ app_info::AppInfoPlatform,
+ info::{RuntimeInfo, gather_runtime_info},
+};
+use crate::RadrootsAppError;
+
+#[derive(uniffi::Object)]
+pub struct RadrootsRuntime {
+ pub(crate) net: NetHandle,
+ pub(crate) started_unix_ms: i64,
+ pub(crate) shutting_down: AtomicBool,
+ pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>,
+ #[cfg(feature = "nostr-client")]
+ pub(crate) post_events_rx:
+ Mutex<Option<Receiver<radroots_events::post::RadrootsPostEventMetadata>>>,
+}
+
+#[uniffi::export]
+impl RadrootsRuntime {
+ #[uniffi::constructor]
+ pub fn new() -> Result<Self, RadrootsAppError> {
+ let cfg = radroots_net_core::config::NetConfig::default();
+ let handle = NetBuilder::new()
+ .config(cfg)
+ .manage_runtime(true)
+ .build()
+ .map_err(|e| RadrootsAppError::Msg(format!("net build failed: {e}")))?;
+
+ Ok(Self {
+ net: handle,
+ started_unix_ms: Utc::now().timestamp_millis(),
+ shutting_down: AtomicBool::new(false),
+ platform_app: RwLock::new(None),
+ #[cfg(feature = "nostr-client")]
+ post_events_rx: Mutex::new(None),
+ })
+ }
+
+ pub fn stop(&self) {
+ if self.shutting_down.swap(true, Ordering::SeqCst) {
+ info!("Runtime stop already in progress or completed.");
+ return;
+ }
+ if let Ok(mut net) = self.net.lock() {
+ #[cfg(feature = "rt")]
+ if let Some(_rt) = net.rt.take() {
+ info!("Runtime stopped gracefully.");
+ } else {
+ info!("No runtime was active at stop.");
+ }
+ #[cfg(not(feature = "rt"))]
+ info!("No managed runtime is available for this build.");
+ } else {
+ info!("Failed to acquire runtime lock during stop.");
+ }
+ }
+
+ pub fn uptime_millis(&self) -> i64 {
+ Utc::now().timestamp_millis() - self.started_unix_ms
+ }
+
+ pub fn info(&self) -> RuntimeInfo {
+ gather_runtime_info(self)
+ }
+
+ pub fn info_json(&self) -> String {
+ serde_json::to_string_pretty(&self.info())
+ .unwrap_or_else(|e| format!(r#"{{"error":"serialize RuntimeInfo: {e}"}}"#))
+ }
+
+ pub fn set_app_info_platform(
+ &self,
+ platform: Option<String>,
+ bundle_id: Option<String>,
+ version: Option<String>,
+ build_number: Option<String>,
+ build_sha: Option<String>,
+ ) {
+ let platform_info =
+ AppInfoPlatform::new(platform, bundle_id, version, build_number, build_sha);
+ if let Ok(mut guard) = self.platform_app.write() {
+ *guard = Some(platform_info);
+ }
+ }
+}
diff --git a/app-core/src/runtime/nostr.rs b/app-core/src/runtime/nostr.rs
@@ -0,0 +1,349 @@
+use super::RadrootsRuntime;
+use crate::RadrootsAppError;
+#[cfg(feature = "nostr-client")]
+use tokio::sync::broadcast::error::TryRecvError;
+
+#[derive(uniffi::Enum, Debug, Clone, Copy)]
+pub enum NostrLight {
+ Red,
+ Yellow,
+ Green,
+}
+
+#[derive(uniffi::Record, Debug, Clone)]
+pub struct NostrConnectionStatus {
+ pub light: NostrLight,
+ pub connected: u32,
+ pub connecting: u32,
+ pub last_error: Option<String>,
+}
+
+#[derive(uniffi::Record, Debug, Clone, Default)]
+pub struct NostrProfile {
+ pub name: Option<String>,
+ pub display_name: Option<String>,
+ pub nip05: Option<String>,
+ pub about: Option<String>,
+ pub website: Option<String>,
+ pub picture: Option<String>,
+ pub banner: Option<String>,
+ pub lud06: Option<String>,
+ pub lud16: Option<String>,
+ pub bot: Option<String>,
+}
+
+#[derive(uniffi::Record, Debug, Clone)]
+pub struct NostrProfileEventMetadata {
+ pub id: String,
+ pub author: String,
+ pub published_at: u64,
+ pub profile: NostrProfile,
+}
+
+#[derive(uniffi::Record, Debug, Clone)]
+pub struct NostrEvent {
+ pub id: String,
+ pub author: String,
+ pub created_at: u64,
+ pub kind: u32,
+ pub content: String,
+}
+
+#[derive(uniffi::Record, Debug, Clone)]
+pub struct NostrPost {
+ pub content: String,
+}
+
+#[derive(uniffi::Record, Debug, Clone)]
+pub struct NostrPostEventMetadata {
+ pub id: String,
+ pub author: String,
+ pub published_at: u64,
+ pub post: NostrPost,
+}
+
+#[cfg(feature = "nostr-client")]
+fn map_post_event_metadata(
+ event: radroots_events::post::RadrootsPostEventMetadata,
+) -> NostrPostEventMetadata {
+ NostrPostEventMetadata {
+ id: event.id,
+ author: event.author,
+ published_at: event.published_at as u64,
+ post: NostrPost {
+ content: event.post.content,
+ },
+ }
+}
+
+#[uniffi::export]
+impl RadrootsRuntime {
+ pub fn nostr_set_default_relays(&self, relays: Vec<String>) -> Result<(), RadrootsAppError> {
+ let mut guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ guard
+ .nostr_set_default_relays(&relays)
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn nostr_connect_if_key_present(&self) -> Result<(), RadrootsAppError> {
+ let mut guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ guard
+ .nostr_connect_if_key_present()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn nostr_connection_status(&self) -> NostrConnectionStatus {
+ let guard = self.net.lock();
+ if let Ok(g) = guard {
+ #[cfg(feature = "nostr-client")]
+ {
+ if let Some(s) = g.nostr_connection_snapshot() {
+ let light = match s.light {
+ radroots_net_core::nostr_client::Light::Green => NostrLight::Green,
+ radroots_net_core::nostr_client::Light::Yellow => NostrLight::Yellow,
+ radroots_net_core::nostr_client::Light::Red => NostrLight::Red,
+ };
+ return NostrConnectionStatus {
+ light,
+ connected: s.connected as u32,
+ connecting: s.connecting as u32,
+ last_error: s.last_error,
+ };
+ }
+ }
+ }
+ NostrConnectionStatus {
+ light: NostrLight::Red,
+ connected: 0,
+ connecting: 0,
+ last_error: None,
+ }
+ }
+
+ pub fn nostr_profile_for_self(&self) -> Option<NostrProfileEventMetadata> {
+ let guard = self.net.lock().ok()?;
+ #[cfg(feature = "nostr-client")]
+ {
+ let keys = guard.keys.require().ok()?;
+ let pk = keys.public_key();
+ let mgr = guard.nostr.as_ref()?;
+ let out = mgr.fetch_profile_event_blocking(pk).ok()?;
+ return out.map(|m| NostrProfileEventMetadata {
+ id: m.id,
+ author: m.author,
+ published_at: m.published_at as u64,
+ profile: NostrProfile {
+ name: m.profile.name.into(),
+ display_name: m.profile.display_name.into(),
+ nip05: m.profile.nip05.into(),
+ about: m.profile.about.into(),
+ website: m.profile.website,
+ picture: m.profile.picture,
+ banner: m.profile.banner,
+ lud06: m.profile.lud06,
+ lud16: m.profile.lud16,
+ bot: m.profile.bot,
+ },
+ });
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ None
+ }
+ }
+
+ pub fn nostr_post_profile(
+ &self,
+ name: Option<String>,
+ display_name: Option<String>,
+ nip05: Option<String>,
+ about: Option<String>,
+ ) -> Result<String, RadrootsAppError> {
+ let guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ let mgr = guard
+ .nostr
+ .as_ref()
+ .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?;
+ mgr.publish_profile_event_blocking(name, display_name, nip05, about)
+ .map_err(|e| RadrootsAppError::Msg(e.to_string()))
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn nostr_post_text_note(&self, content: String) -> Result<String, RadrootsAppError> {
+ let guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ let mgr = guard
+ .nostr
+ .as_ref()
+ .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?;
+ mgr.publish_post_event_blocking(content)
+ .map_err(|e| RadrootsAppError::Msg(e.to_string()))
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn nostr_fetch_text_notes(
+ &self,
+ limit: u16,
+ since_unix: Option<u64>,
+ ) -> Result<Vec<NostrPostEventMetadata>, RadrootsAppError> {
+ let guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ let mgr = guard
+ .nostr
+ .as_ref()
+ .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?;
+ let items = mgr
+ .fetch_post_events_blocking(limit, since_unix)
+ .map_err(|e| RadrootsAppError::Msg(e.to_string()))?;
+ Ok(items.into_iter().map(map_post_event_metadata).collect())
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn nostr_post_reply(
+ &self,
+ parent_event_id_hex: String,
+ parent_author_hex: String,
+ content: String,
+ root_event_id_hex: Option<String>,
+ ) -> Result<String, RadrootsAppError> {
+ let guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ let mgr = guard
+ .nostr
+ .as_ref()
+ .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?;
+ mgr.publish_post_reply_event_blocking(
+ parent_event_id_hex,
+ parent_author_hex,
+ content,
+ root_event_id_hex,
+ )
+ .map_err(|e| RadrootsAppError::Msg(e.to_string()))
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn nostr_start_post_event_stream(
+ &self,
+ since_unix: Option<u64>,
+ ) -> Result<(), RadrootsAppError> {
+ let guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ let mgr = guard
+ .nostr
+ .as_ref()
+ .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?;
+ mgr.start_post_event_stream(since_unix);
+ if let Ok(mut rx_guard) = self.post_events_rx.lock() {
+ if rx_guard.is_none() {
+ *rx_guard = Some(mgr.subscribe_post_events());
+ }
+ }
+ Ok(())
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn nostr_next_post_event(&self) -> Option<NostrPostEventMetadata> {
+ #[cfg(feature = "nostr-client")]
+ {
+ let mut rx_guard = self.post_events_rx.lock().ok()?;
+ let rx = rx_guard.as_mut()?;
+ match rx.try_recv() {
+ Ok(event) => Some(map_post_event_metadata(event)),
+ Err(TryRecvError::Empty) => None,
+ Err(TryRecvError::Lagged(_)) => None,
+ Err(TryRecvError::Closed) => {
+ *rx_guard = None;
+ None
+ }
+ }
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ None
+ }
+ }
+
+ pub fn nostr_stop_post_event_stream(&self) -> Result<(), RadrootsAppError> {
+ let guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ let mgr = guard
+ .nostr
+ .as_ref()
+ .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?;
+ mgr.stop_post_event_stream();
+ if let Ok(mut rx_guard) = self.post_events_rx.lock() {
+ *rx_guard = None;
+ }
+ Ok(())
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+}
diff --git a/app-core/src/runtime/trade_listing.rs b/app-core/src/runtime/trade_listing.rs
@@ -0,0 +1,866 @@
+#![forbid(unsafe_code)]
+
+use core::str::FromStr;
+
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_events::{
+ RadrootsNostrEventPtr,
+ listing::{
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
+ RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation,
+ RadrootsListingProduct, RadrootsListingStatus,
+ },
+};
+use radroots_events_codec::listing::encode::to_wire_parts as listing_to_wire_parts;
+use radroots_nostr::prelude::{
+ RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrTimestamp, radroots_event_from_nostr,
+ radroots_nostr_parse_pubkey,
+};
+use radroots_trade::listing::{
+ dvm::TradeListingAddress,
+ dvm::{
+ TradeListingEnvelope, TradeListingMessagePayload, TradeListingMessageType,
+ TradeListingValidateRequest,
+ },
+ dvm_kinds::TRADE_LISTING_DVM_KINDS,
+ order::{TradeOrder, TradeOrderItem, TradeOrderStatus},
+ tags::trade_listing_dvm_tags,
+ validation::{RadrootsTradeListing, validate_listing_event},
+};
+
+use super::RadrootsRuntime;
+use crate::RadrootsAppError;
+
+const LISTING_KIND: u32 = 30402;
+
+#[derive(uniffi::Record, Debug, Clone)]
+pub struct TradeListingDraft {
+ pub listing_id: Option<String>,
+ pub farm_pubkey: String,
+ pub farm_d_tag: String,
+ pub title: String,
+ pub description: String,
+ pub category: String,
+ pub bin_display_amount: String,
+ pub bin_display_unit: String,
+ pub unit_price: String,
+ pub currency: String,
+ pub bin_label: Option<String>,
+ pub bin_id: Option<String>,
+ pub inventory: String,
+ pub delivery_method: String,
+ pub location_primary: String,
+ pub location_city: Option<String>,
+ pub location_region: Option<String>,
+ pub location_country: Option<String>,
+}
+
+#[derive(uniffi::Record, Debug, Clone)]
+pub struct TradeListingSummary {
+ pub event_id: String,
+ pub seller_pubkey: String,
+ pub published_at: u64,
+ pub listing_id: String,
+ pub listing_addr: String,
+ pub title: String,
+ pub description: String,
+ pub product_type: String,
+ pub primary_bin_id: String,
+ pub unit_price_amount: String,
+ pub unit_price_currency: String,
+ pub unit_price_unit: String,
+ pub bin_display_amount: String,
+ pub bin_display_unit: String,
+ pub bin_display_label: Option<String>,
+ pub inventory_available: String,
+ pub availability: String,
+ pub location: String,
+ pub delivery_method: String,
+}
+
+#[derive(uniffi::Record, Debug, Clone)]
+pub struct TradeOrderDraft {
+ pub listing_addr: String,
+ pub seller_pubkey: String,
+ pub bin_id: String,
+ pub bin_count: String,
+ pub notes: Option<String>,
+ pub order_id: Option<String>,
+ pub recipient_pubkey: String,
+}
+
+#[derive(uniffi::Record, Debug, Clone)]
+pub struct TradeOrderSendResult {
+ pub event_id: String,
+ pub order_id: String,
+}
+
+#[derive(uniffi::Record, Debug, Clone)]
+pub struct TradeListingMessageSummary {
+ pub event_id: String,
+ pub author: String,
+ pub published_at: u64,
+ pub kind: u32,
+ pub message_type: String,
+ pub listing_addr: String,
+ pub order_id: Option<String>,
+ pub summary: String,
+ pub payload_json: String,
+}
+
+#[uniffi::export]
+impl RadrootsRuntime {
+ pub fn trade_listing_publish(
+ &self,
+ draft: TradeListingDraft,
+ ) -> Result<String, RadrootsAppError> {
+ let guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ let mgr = guard
+ .nostr
+ .as_ref()
+ .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?;
+ let listing = listing_from_draft(&draft)?;
+ let current_pubkey = current_pubkey_hex(self)?;
+ if listing.farm.pubkey != current_pubkey {
+ return Err(RadrootsAppError::Msg(
+ "farm_pubkey must match the active key".into(),
+ ));
+ }
+ let parts = listing_to_wire_parts(&listing)
+ .map_err(|e| RadrootsAppError::Msg(format!("listing encode failed: {e}")))?;
+ mgr.send_custom_event_blocking(parts.kind, parts.content, parts.tags)
+ .map_err(|e| RadrootsAppError::Msg(e.to_string()))
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn trade_listings_fetch(
+ &self,
+ limit: u16,
+ since_unix: Option<u64>,
+ ) -> Result<Vec<TradeListingSummary>, RadrootsAppError> {
+ let guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ let mgr = guard
+ .nostr
+ .as_ref()
+ .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?;
+ let mut filter =
+ RadrootsNostrFilter::new().kind(RadrootsNostrKind::Custom(LISTING_KIND as u16));
+ filter = filter.limit(limit.into());
+ if let Some(since) = since_unix {
+ filter = filter.since(RadrootsNostrTimestamp::from(since));
+ }
+
+ let events = mgr
+ .fetch_events_blocking(filter, core::time::Duration::from_secs(10))
+ .map_err(|e| RadrootsAppError::Msg(e.to_string()))?;
+ let mut out = Vec::new();
+ for ev in events {
+ let event = radroots_event_from_nostr(&ev);
+ if event.kind != LISTING_KIND {
+ continue;
+ }
+ match validate_listing_event(&event) {
+ Ok(listing) => {
+ out.push(listing_summary_from_trade(listing, &event));
+ }
+ Err(_) => continue,
+ }
+ }
+ out.sort_by(|a, b| b.published_at.cmp(&a.published_at));
+ Ok(out)
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn trade_listing_send_validation_request(
+ &self,
+ listing_event_id: String,
+ seller_pubkey: String,
+ listing_id: String,
+ recipient_pubkey: String,
+ ) -> Result<String, RadrootsAppError> {
+ let listing_addr = listing_addr_from_parts(&seller_pubkey, &listing_id)?;
+ let payload =
+ TradeListingMessagePayload::ListingValidateRequest(TradeListingValidateRequest {
+ listing_event: Some(RadrootsNostrEventPtr {
+ id: listing_event_id,
+ relays: None,
+ }),
+ });
+ self.send_trade_listing_message(
+ TradeListingMessageType::ListingValidateRequest,
+ listing_addr,
+ None,
+ payload,
+ recipient_pubkey,
+ )
+ }
+
+ pub fn trade_listing_send_order_request(
+ &self,
+ draft: TradeOrderDraft,
+ ) -> Result<TradeOrderSendResult, RadrootsAppError> {
+ #[cfg(feature = "nostr-client")]
+ {
+ let order_id = normalize_optional_id(draft.order_id);
+ let order_id = order_id
+ .unwrap_or_else(|| format!("order-{}", chrono::Utc::now().timestamp_millis()));
+ let buyer_pubkey = current_pubkey_hex(self)?;
+ let seller_pubkey = normalize_pubkey(&draft.seller_pubkey)?;
+
+ let bin_id = draft.bin_id.trim();
+ if bin_id.is_empty() {
+ return Err(RadrootsAppError::Msg("bin_id is required".into()));
+ }
+ let bin_count = parse_u32(&draft.bin_count, "bin_count")?;
+ if bin_count == 0 {
+ return Err(RadrootsAppError::Msg("bin_count must be > 0".into()));
+ }
+
+ let item = TradeOrderItem {
+ bin_id: bin_id.to_string(),
+ bin_count,
+ };
+
+ let notes = draft
+ .notes
+ .as_deref()
+ .map(|s| s.trim())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string());
+
+ let order = TradeOrder {
+ order_id: order_id.clone(),
+ listing_addr: draft.listing_addr.clone(),
+ buyer_pubkey,
+ seller_pubkey,
+ items: vec![item],
+ discounts: None,
+ notes,
+ status: TradeOrderStatus::Requested,
+ };
+
+ let payload = TradeListingMessagePayload::OrderRequest(order);
+ let event_id = self.send_trade_listing_message(
+ TradeListingMessageType::OrderRequest,
+ draft.listing_addr,
+ Some(order_id.clone()),
+ payload,
+ draft.recipient_pubkey,
+ )?;
+
+ Ok(TradeOrderSendResult { event_id, order_id })
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+
+ pub fn trade_listing_fetch_messages(
+ &self,
+ listing_addr: String,
+ order_id: Option<String>,
+ limit: u16,
+ since_unix: Option<u64>,
+ ) -> Result<Vec<TradeListingMessageSummary>, RadrootsAppError> {
+ let guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ let mgr = guard
+ .nostr
+ .as_ref()
+ .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?;
+
+ let kinds: Vec<RadrootsNostrKind> = TRADE_LISTING_DVM_KINDS
+ .iter()
+ .map(|k| RadrootsNostrKind::Custom(*k))
+ .collect();
+
+ let mut filter = RadrootsNostrFilter::new().kinds(kinds);
+ filter = filter.limit(limit.into());
+ if let Some(since) = since_unix {
+ filter = filter.since(RadrootsNostrTimestamp::from(since));
+ }
+
+ let events = mgr
+ .fetch_events_blocking(filter, core::time::Duration::from_secs(10))
+ .map_err(|e| RadrootsAppError::Msg(e.to_string()))?;
+
+ let mut out = Vec::new();
+ for ev in events {
+ let content = ev.content.clone();
+ let envelope: TradeListingEnvelope<TradeListingMessagePayload> =
+ match serde_json::from_str(&content) {
+ Ok(env) => env,
+ Err(_) => continue,
+ };
+ if envelope.validate().is_err() {
+ continue;
+ }
+ if envelope.listing_addr != listing_addr {
+ continue;
+ }
+ if let Some(ref oid) = order_id {
+ if envelope.order_id.as_deref() != Some(oid) {
+ continue;
+ }
+ }
+ let kind_u32 = ev.kind.as_u16() as u32;
+ if envelope.message_type.kind() as u32 != kind_u32 {
+ continue;
+ }
+
+ let summary = message_summary(&envelope.payload);
+ out.push(TradeListingMessageSummary {
+ event_id: ev.id.to_string(),
+ author: ev.pubkey.to_string(),
+ published_at: ev.created_at.as_secs(),
+ kind: kind_u32,
+ message_type: message_type_label(envelope.message_type).to_string(),
+ listing_addr: envelope.listing_addr.clone(),
+ order_id: envelope.order_id.clone(),
+ summary,
+ payload_json: content,
+ });
+ }
+ out.sort_by(|a, b| b.published_at.cmp(&a.published_at));
+ Ok(out)
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+}
+
+impl RadrootsRuntime {
+ fn send_trade_listing_message(
+ &self,
+ message_type: TradeListingMessageType,
+ listing_addr: String,
+ order_id: Option<String>,
+ payload: TradeListingMessagePayload,
+ recipient_pubkey: String,
+ ) -> Result<String, RadrootsAppError> {
+ let guard = self
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ #[cfg(feature = "nostr-client")]
+ {
+ let mgr = guard
+ .nostr
+ .as_ref()
+ .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?;
+ let recipient_hex = normalize_pubkey(&recipient_pubkey)?;
+ let envelope = TradeListingEnvelope::new(
+ message_type,
+ listing_addr.clone(),
+ order_id.clone(),
+ payload,
+ );
+ envelope
+ .validate()
+ .map_err(|e| RadrootsAppError::Msg(e.to_string()))?;
+ let content = serde_json::to_string(&envelope)
+ .map_err(|e| RadrootsAppError::Msg(format!("encode envelope failed: {e}")))?;
+ let tags = trade_listing_dvm_tags(recipient_hex, listing_addr, order_id);
+ mgr.send_custom_event_blocking(message_type.kind() as u32, content, tags)
+ .map_err(|e| RadrootsAppError::Msg(e.to_string()))
+ }
+ #[cfg(not(feature = "nostr-client"))]
+ {
+ Err(RadrootsAppError::Msg("nostr disabled".into()))
+ }
+ }
+}
+
+fn listing_from_draft(draft: &TradeListingDraft) -> Result<RadrootsListing, RadrootsAppError> {
+ let listing_id = draft
+ .listing_id
+ .as_deref()
+ .map(|s| s.trim())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| format!("listing-{}", chrono::Utc::now().timestamp_millis()));
+ let farm_pubkey = draft.farm_pubkey.trim();
+ if farm_pubkey.is_empty() {
+ return Err(RadrootsAppError::Msg("farm_pubkey is required".into()));
+ }
+ let farm_pubkey = normalize_pubkey(farm_pubkey)?;
+ let farm_d_tag = draft.farm_d_tag.trim();
+ if farm_d_tag.is_empty() {
+ return Err(RadrootsAppError::Msg("farm_d_tag is required".into()));
+ }
+
+ let title = draft.title.trim();
+ if title.is_empty() {
+ return Err(RadrootsAppError::Msg("title is required".into()));
+ }
+ let description = draft.description.trim();
+ if description.is_empty() {
+ return Err(RadrootsAppError::Msg("description is required".into()));
+ }
+ let category = draft.category.trim();
+ if category.is_empty() {
+ return Err(RadrootsAppError::Msg("category is required".into()));
+ }
+ let location_primary = draft.location_primary.trim();
+ if location_primary.is_empty() {
+ return Err(RadrootsAppError::Msg("location is required".into()));
+ }
+
+ let display_amount = parse_decimal(&draft.bin_display_amount, "bin_display_amount")?;
+ ensure_non_negative(&display_amount, "bin_display_amount")?;
+ let display_unit = parse_unit(&draft.bin_display_unit)?;
+ let unit_price_amount = parse_decimal(&draft.unit_price, "unit_price")?;
+ ensure_non_negative(&unit_price_amount, "unit_price")?;
+ let currency = parse_currency(&draft.currency)?;
+ let inventory = parse_decimal(&draft.inventory, "inventory")?;
+ ensure_non_negative(&inventory, "inventory")?;
+
+ let display_quantity = RadrootsCoreQuantity::new(display_amount, display_unit);
+ let canonical_quantity = display_quantity
+ .to_canonical()
+ .map_err(|e| RadrootsAppError::Msg(format!("invalid bin_display_unit: {e}")))?;
+ let unit_price = RadrootsCoreMoney::new(unit_price_amount, currency);
+ let price_per_display_unit = RadrootsCoreQuantityPrice::new(
+ unit_price.clone(),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, display_unit),
+ );
+ let price_per_canonical_unit = price_per_display_unit
+ .try_to_canonical_unit_price()
+ .map_err(|e| RadrootsAppError::Msg(format!("invalid unit_price: {e:?}")))?;
+ let bin_label = clean_optional(&draft.bin_label);
+ let bin_id = normalize_optional_id(draft.bin_id.clone()).unwrap_or_else(|| "bin-1".to_string());
+ let bin = RadrootsListingBin {
+ bin_id: bin_id.clone(),
+ quantity: canonical_quantity,
+ price_per_canonical_unit,
+ display_amount: Some(display_amount),
+ display_unit: Some(display_unit),
+ display_label: bin_label,
+ display_price: Some(unit_price),
+ display_price_unit: Some(display_unit),
+ };
+
+ let delivery_method = parse_delivery_method(&draft.delivery_method)?;
+
+ Ok(RadrootsListing {
+ d_tag: listing_id,
+ farm: RadrootsListingFarmRef {
+ pubkey: farm_pubkey,
+ d_tag: farm_d_tag.to_string(),
+ },
+ product: RadrootsListingProduct {
+ key: category.to_string(),
+ title: title.to_string(),
+ category: category.to_string(),
+ summary: Some(description.to_string()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: bin_id,
+ bins: vec![bin],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(inventory),
+ availability: Some(RadrootsListingAvailability::Status {
+ status: RadrootsListingStatus::Active,
+ }),
+ delivery_method: Some(delivery_method),
+ location: Some(RadrootsListingLocation {
+ primary: location_primary.to_string(),
+ city: clean_optional(&draft.location_city),
+ region: clean_optional(&draft.location_region),
+ country: clean_optional(&draft.location_country),
+ lat: None,
+ lng: None,
+ geohash: None,
+ }),
+ images: None,
+ })
+}
+
+fn listing_summary_from_trade(
+ listing: RadrootsTradeListing,
+ event: &radroots_events::RadrootsNostrEvent,
+) -> TradeListingSummary {
+ let bin = listing
+ .listing
+ .bins
+ .iter()
+ .find(|bin| bin.bin_id == listing.primary_bin_id)
+ .or_else(|| listing.listing.bins.first())
+ .expect("validated listing must include bins");
+ let (display_amount, display_unit) = match (bin.display_amount.as_ref(), bin.display_unit) {
+ (Some(amount), Some(unit)) => (amount.clone(), unit),
+ _ => (bin.quantity.amount.clone(), bin.quantity.unit),
+ };
+ let display_label = bin.display_label.clone().or(bin.quantity.label.clone());
+ let display_label = clean_optional(&display_label);
+ let (unit_price_amount, unit_price_currency, unit_price_unit) =
+ match bin.price_per_canonical_unit.try_to_unit_price(display_unit) {
+ Ok(price) => (
+ price.amount.amount.to_string(),
+ price.amount.currency.to_string(),
+ price.quantity.unit.to_string(),
+ ),
+ Err(_) => match (&bin.display_price, bin.display_price_unit) {
+ (Some(price), Some(unit)) => (
+ price.amount.to_string(),
+ price.currency.to_string(),
+ unit.to_string(),
+ ),
+ _ => (
+ bin.price_per_canonical_unit.amount.amount.to_string(),
+ bin.price_per_canonical_unit.amount.currency.to_string(),
+ bin.price_per_canonical_unit.quantity.unit.to_string(),
+ ),
+ },
+ };
+
+ TradeListingSummary {
+ event_id: event.id.clone(),
+ seller_pubkey: event.author.clone(),
+ published_at: event.created_at as u64,
+ listing_id: listing.listing_id,
+ listing_addr: listing.listing_addr,
+ title: listing.title,
+ description: listing.description,
+ product_type: listing.product_type,
+ primary_bin_id: listing.primary_bin_id,
+ unit_price_amount,
+ unit_price_currency,
+ unit_price_unit,
+ bin_display_amount: display_amount.to_string(),
+ bin_display_unit: display_unit.to_string(),
+ bin_display_label: display_label,
+ inventory_available: listing.inventory_available.to_string(),
+ availability: availability_label(&listing.availability),
+ location: format_location(&listing.location),
+ delivery_method: delivery_method_label(&listing.delivery_method).to_string(),
+ }
+}
+
+fn listing_addr_from_parts(
+ seller_pubkey: &str,
+ listing_id: &str,
+) -> Result<String, RadrootsAppError> {
+ let listing_id = listing_id.trim();
+ if listing_id.is_empty() {
+ return Err(RadrootsAppError::Msg("listing_id is required".into()));
+ }
+ let seller_hex = normalize_pubkey(seller_pubkey)?;
+ Ok(TradeListingAddress {
+ kind: LISTING_KIND as u16,
+ seller_pubkey: seller_hex,
+ listing_id: listing_id.to_string(),
+ }
+ .as_str())
+}
+
+fn normalize_pubkey(pubkey: &str) -> Result<String, RadrootsAppError> {
+ let key = radroots_nostr_parse_pubkey(pubkey.trim())
+ .map_err(|e| RadrootsAppError::Msg(e.to_string()))?;
+ Ok(key.to_hex())
+}
+
+fn normalize_optional_id(id: Option<String>) -> Option<String> {
+ id.as_deref()
+ .map(|s| s.trim())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string())
+}
+
+#[cfg(feature = "nostr-client")]
+fn current_pubkey_hex(runtime: &RadrootsRuntime) -> Result<String, RadrootsAppError> {
+ let guard = runtime
+ .net
+ .lock()
+ .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?;
+ let keys = guard
+ .keys
+ .require()
+ .map_err(|e| RadrootsAppError::Msg(e.to_string()))?;
+ Ok(keys.public_key().to_hex())
+}
+
+fn parse_decimal(value: &str, label: &str) -> Result<RadrootsCoreDecimal, RadrootsAppError> {
+ RadrootsCoreDecimal::from_str(value.trim())
+ .map_err(|e| RadrootsAppError::Msg(format!("invalid {label}: {e}")))
+}
+
+fn parse_unit(value: &str) -> Result<RadrootsCoreUnit, RadrootsAppError> {
+ RadrootsCoreUnit::from_str(value.trim())
+ .map_err(|e| RadrootsAppError::Msg(format!("invalid unit: {e}")))
+}
+
+fn parse_currency(value: &str) -> Result<RadrootsCoreCurrency, RadrootsAppError> {
+ RadrootsCoreCurrency::from_str(value.trim())
+ .map_err(|e| RadrootsAppError::Msg(format!("invalid currency: {e}")))
+}
+
+fn parse_u32(value: &str, label: &str) -> Result<u32, RadrootsAppError> {
+ value
+ .trim()
+ .parse::<u32>()
+ .map_err(|e| RadrootsAppError::Msg(format!("invalid {label}: {e}")))
+}
+
+fn ensure_non_negative(value: &RadrootsCoreDecimal, label: &str) -> Result<(), RadrootsAppError> {
+ if value.is_sign_negative() {
+ return Err(RadrootsAppError::Msg(format!(
+ "{label} must be non-negative"
+ )));
+ }
+ Ok(())
+}
+
+fn parse_delivery_method(value: &str) -> Result<RadrootsListingDeliveryMethod, RadrootsAppError> {
+ let raw = value.trim();
+ if raw.is_empty() {
+ return Err(RadrootsAppError::Msg("delivery_method is required".into()));
+ }
+ let lowered = raw.to_ascii_lowercase();
+ Ok(match lowered.as_str() {
+ "pickup" => RadrootsListingDeliveryMethod::Pickup,
+ "local_delivery" | "local delivery" => RadrootsListingDeliveryMethod::LocalDelivery,
+ "shipping" => RadrootsListingDeliveryMethod::Shipping,
+ _ => RadrootsListingDeliveryMethod::Other {
+ method: raw.to_string(),
+ },
+ })
+}
+
+fn availability_label(availability: &RadrootsListingAvailability) -> String {
+ match availability {
+ RadrootsListingAvailability::Status { status } => match status {
+ RadrootsListingStatus::Active => "active".to_string(),
+ RadrootsListingStatus::Sold => "sold".to_string(),
+ RadrootsListingStatus::Other { value } => value.clone(),
+ },
+ RadrootsListingAvailability::Window { start, end } => {
+ let start = start
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| "unknown".into());
+ let end = end
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| "unknown".into());
+ format!("{start} - {end}")
+ }
+ }
+}
+
+fn delivery_method_label(method: &RadrootsListingDeliveryMethod) -> &'static str {
+ match method {
+ RadrootsListingDeliveryMethod::Pickup => "pickup",
+ RadrootsListingDeliveryMethod::LocalDelivery => "local delivery",
+ RadrootsListingDeliveryMethod::Shipping => "shipping",
+ RadrootsListingDeliveryMethod::Other { .. } => "other",
+ }
+}
+
+fn format_location(location: &RadrootsListingLocation) -> String {
+ let mut parts = Vec::with_capacity(4);
+ if !location.primary.trim().is_empty() {
+ parts.push(location.primary.trim());
+ }
+ if let Some(city) = location.city.as_deref() {
+ if !city.trim().is_empty() {
+ parts.push(city.trim());
+ }
+ }
+ if let Some(region) = location.region.as_deref() {
+ if !region.trim().is_empty() {
+ parts.push(region.trim());
+ }
+ }
+ if let Some(country) = location.country.as_deref() {
+ if !country.trim().is_empty() {
+ parts.push(country.trim());
+ }
+ }
+ if parts.is_empty() {
+ "n/a".to_string()
+ } else {
+ parts.join(", ")
+ }
+}
+
+fn clean_optional(value: &Option<String>) -> Option<String> {
+ value
+ .as_deref()
+ .map(|s| s.trim())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string())
+}
+
+fn message_type_label(message_type: TradeListingMessageType) -> &'static str {
+ match message_type {
+ TradeListingMessageType::ListingValidateRequest => "listing_validate_request",
+ TradeListingMessageType::ListingValidateResult => "listing_validate_result",
+ TradeListingMessageType::OrderRequest => "order_request",
+ TradeListingMessageType::OrderResponse => "order_response",
+ TradeListingMessageType::OrderRevision => "order_revision",
+ TradeListingMessageType::OrderRevisionAccept => "order_revision_accept",
+ TradeListingMessageType::OrderRevisionDecline => "order_revision_decline",
+ TradeListingMessageType::Question => "question",
+ TradeListingMessageType::Answer => "answer",
+ TradeListingMessageType::DiscountRequest => "discount_request",
+ TradeListingMessageType::DiscountOffer => "discount_offer",
+ TradeListingMessageType::DiscountAccept => "discount_accept",
+ TradeListingMessageType::DiscountDecline => "discount_decline",
+ TradeListingMessageType::Cancel => "cancel",
+ TradeListingMessageType::FulfillmentUpdate => "fulfillment_update",
+ TradeListingMessageType::Receipt => "receipt",
+ }
+}
+
+fn message_summary(payload: &TradeListingMessagePayload) -> String {
+ match payload {
+ TradeListingMessagePayload::ListingValidateRequest(_) => {
+ "Listing validation requested".to_string()
+ }
+ TradeListingMessagePayload::ListingValidateResult(result) => {
+ if result.valid {
+ "Listing validated".to_string()
+ } else if let Some(first) = result.errors.first() {
+ format!("Listing invalid: {first}")
+ } else {
+ "Listing invalid".to_string()
+ }
+ }
+ TradeListingMessagePayload::OrderRequest(order) => {
+ let item = order.items.first();
+ match item {
+ Some(i) => format!("Order requested: {}x {}", i.bin_count, i.bin_id),
+ None => "Order requested".to_string(),
+ }
+ }
+ TradeListingMessagePayload::OrderResponse(res) => {
+ if res.accepted {
+ "Order accepted".to_string()
+ } else if let Some(reason) = res.reason.as_deref() {
+ format!("Order declined: {reason}")
+ } else {
+ "Order declined".to_string()
+ }
+ }
+ TradeListingMessagePayload::OrderRevision(_) => "Order revision proposed".to_string(),
+ TradeListingMessagePayload::OrderRevisionAccept(_) => "Order revision accepted".to_string(),
+ TradeListingMessagePayload::OrderRevisionDecline(_) => {
+ "Order revision declined".to_string()
+ }
+ TradeListingMessagePayload::Question(q) => format!("Question: {}", q.question_text),
+ TradeListingMessagePayload::Answer(a) => format!("Answer: {}", a.answer_text),
+ TradeListingMessagePayload::DiscountRequest(_) => "Discount requested".to_string(),
+ TradeListingMessagePayload::DiscountOffer(_) => "Discount offered".to_string(),
+ TradeListingMessagePayload::DiscountAccept(_) => "Discount accepted".to_string(),
+ TradeListingMessagePayload::DiscountDecline(_) => "Discount declined".to_string(),
+ TradeListingMessagePayload::Cancel(c) => {
+ if let Some(reason) = c.reason.as_deref() {
+ format!("Order cancelled: {reason}")
+ } else {
+ "Order cancelled".to_string()
+ }
+ }
+ TradeListingMessagePayload::FulfillmentUpdate(update) => {
+ format!("Fulfillment update: {:?}", update.status)
+ }
+ TradeListingMessagePayload::Receipt(receipt) => {
+ if receipt.acknowledged {
+ "Receipt acknowledged".to_string()
+ } else {
+ "Receipt update".to_string()
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn listing_from_draft_requires_fields() {
+ let draft = TradeListingDraft {
+ listing_id: None,
+ farm_pubkey: "".into(),
+ farm_d_tag: "".into(),
+ title: "".into(),
+ description: "Desc".into(),
+ category: "Coffee".into(),
+ bin_display_amount: "1".into(),
+ bin_display_unit: "lb".into(),
+ unit_price: "10.00".into(),
+ currency: "USD".into(),
+ bin_label: None,
+ bin_id: None,
+ inventory: "10".into(),
+ delivery_method: "shipping".into(),
+ location_primary: "Farm".into(),
+ location_city: None,
+ location_region: None,
+ location_country: None,
+ };
+ assert!(listing_from_draft(&draft).is_err());
+ }
+
+ #[test]
+ fn listing_from_draft_builds_listing() {
+ let draft = TradeListingDraft {
+ listing_id: Some("AAAAAAAAAAAAAAAAAAAAAg".into()),
+ farm_pubkey: "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6".into(),
+ farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ title: "Coffee".into(),
+ description: "Washed".into(),
+ category: "coffee".into(),
+ bin_display_amount: "1".into(),
+ bin_display_unit: "lb".into(),
+ unit_price: "12.50".into(),
+ currency: "USD".into(),
+ bin_label: Some("bag".into()),
+ bin_id: None,
+ inventory: "5".into(),
+ delivery_method: "shipping".into(),
+ location_primary: "Farm".into(),
+ location_city: Some("Town".into()),
+ location_region: Some("Region".into()),
+ location_country: Some("US".into()),
+ };
+ let listing = listing_from_draft(&draft).expect("listing builds");
+ assert_eq!(listing.d_tag, "AAAAAAAAAAAAAAAAAAAAAg");
+ assert_eq!(listing.product.title, "Coffee");
+ assert!(listing.delivery_method.is_some());
+ assert!(listing.location.is_some());
+ }
+}
diff --git a/app-ffi-swift/Cargo.toml b/app-ffi-swift/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "radroots-app-ffi-swift"
+version.workspace = true
+edition.workspace = true
+authors = ["Radroots Authors"]
+rust-version.workspace = true
+license.workspace = true
+
+[lib]
+crate-type = ["staticlib", "cdylib"]
+
+[[bin]]
+name = "uniffi-bindgen"
+path = "bin/uniffi-bindgen.rs"
+
+[build-dependencies]
+uniffi_build = { workspace = true }
+
+[dependencies]
+radroots-app-core = { workspace = true }
+uniffi = { workspace = true, features = ["cli"] }
diff --git a/app-ffi-swift/bin/uniffi-bindgen.rs b/app-ffi-swift/bin/uniffi-bindgen.rs
@@ -0,0 +1,3 @@
+fn main() {
+ uniffi::uniffi_bindgen_main()
+}
diff --git a/app-ffi-swift/src/lib.rs b/app-ffi-swift/src/lib.rs
@@ -0,0 +1 @@
+radroots_app_core::uniffi_reexport_scaffolding!();
diff --git a/app-ffi-swift/uniffi.toml b/app-ffi-swift/uniffi.toml
@@ -0,0 +1,3 @@
+[bindings.swift]
+module_name = "RadrootsKitBindings"
+ffi_module_name = "RadrootsFFI"
diff --git a/app-wasm/Cargo.toml b/app-wasm/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "radroots-app-wasm"
+version.workspace = true
+edition.workspace = true
+authors = ["Radroots Authors"]
+rust-version.workspace = true
+license.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+radroots-app-core = { workspace = true, default-features = false }
+wasm-bindgen = { workspace = true }
diff --git a/app-wasm/src/lib.rs b/app-wasm/src/lib.rs
@@ -0,0 +1,15 @@
+#![forbid(unsafe_code)]
+
+use wasm_bindgen::prelude::wasm_bindgen;
+
+#[wasm_bindgen]
+pub fn app_wasm_build_info_json() -> String {
+ let runtime = match radroots_app_core::RadrootsRuntime::new() {
+ Ok(runtime) => runtime,
+ Err(err) => {
+ return format!(r#"{{\"error\":\"runtime init failed: {}\"}}"#, err);
+ }
+ };
+
+ runtime.info_json()
+}
diff --git a/net-core/src/nostr_client/manager.rs b/net-core/src/nostr_client/manager.rs
@@ -3,9 +3,7 @@ use tokio::runtime::Handle;
use super::inner::Inner;
use radroots_nostr::prelude::{
- RadrootsNostrKeys,
- RadrootsNostrTimestamp,
- radroots_nostr_post_events_filter,
+ RadrootsNostrKeys, RadrootsNostrTimestamp, radroots_nostr_post_events_filter,
};
#[derive(Clone)]
@@ -43,7 +41,8 @@ impl NostrClientManager {
async move {
use futures::StreamExt;
- let mut since = since_unix.unwrap_or_else(|| RadrootsNostrTimestamp::now().as_secs());
+ let mut since =
+ since_unix.unwrap_or_else(|| RadrootsNostrTimestamp::now().as_secs());
loop {
let filter = radroots_nostr_post_events_filter(None, Some(since));
@@ -59,11 +58,7 @@ impl NostrClientManager {
}
};
- while let Some((_, event)) = stream.next().await {
- let event = match event {
- Ok(event) => event,
- Err(_) => continue,
- };
+ while let Some(event) = stream.next().await {
let meta = radroots_nostr::event_adapters::to_post_event_metadata(&event);
let ts = event.created_at.as_secs();
since = ts.saturating_add(1);
@@ -88,8 +83,7 @@ impl NostrClientManager {
pub fn subscribe_post_events(
&self,
- ) -> tokio::sync::broadcast::Receiver<radroots_events::post::RadrootsPostEventMetadata>
- {
+ ) -> tokio::sync::broadcast::Receiver<radroots_events::post::RadrootsPostEventMetadata> {
self.inner.post_events_tx.subscribe()
}
}