lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 363++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCargo.toml | 9++++++++-
Aapp-core/Cargo.toml | 39+++++++++++++++++++++++++++++++++++++++
Aapp-core/build.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp-core/src/error.rs | 7+++++++
Aapp-core/src/lib.rs | 10++++++++++
Aapp-core/src/logging.rs | 39+++++++++++++++++++++++++++++++++++++++
Aapp-core/src/runtime/app_info.rs | 26++++++++++++++++++++++++++
Aapp-core/src/runtime/builder.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Aapp-core/src/runtime/info.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp-core/src/runtime/key_management.rs | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp-core/src/runtime/mod.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp-core/src/runtime/nostr.rs | 349+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp-core/src/runtime/trade_listing.rs | 866+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp-ffi-swift/Cargo.toml | 21+++++++++++++++++++++
Aapp-ffi-swift/bin/uniffi-bindgen.rs | 3+++
Aapp-ffi-swift/src/lib.rs | 1+
Aapp-ffi-swift/uniffi.toml | 3+++
Aapp-wasm/Cargo.toml | 14++++++++++++++
Aapp-wasm/src/lib.rs | 15+++++++++++++++
Mnet-core/src/nostr_client/manager.rs | 16+++++-----------
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() } }