commit 37323ca235197d6c2a142e5c8d1cfe2086d92ff8
parent fcb0415c4bc3ebace1339d1df6bfbd6c2795928a
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 19:29:40 +0000
ffi: scaffold safe application DTOs
- pin UniFFI 0.32 and initialize the proc-macro boundary
- map revisioned Rust snapshots into public Kotlin-safe records
- keep credentials and generic server fields out of normal DTOs
- define the generated Kotlin namespace and native library name
Diffstat:
5 files changed, 278 insertions(+), 7 deletions(-)
diff --git a/crates/studio_ffi/Cargo.toml b/crates/studio_ffi/Cargo.toml
@@ -6,5 +6,13 @@ rust-version.workspace = true
license.workspace = true
repository.workspace = true
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+radroots-studio-application = { path = "../application" }
+radroots-studio-domain = { path = "../domain" }
+uniffi.workspace = true
+
[lints]
workspace = true
diff --git a/crates/studio_ffi/src/dto.rs b/crates/studio_ffi/src/dto.rs
@@ -0,0 +1,258 @@
+use radroots_studio_application::{
+ ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState,
+ SessionState,
+};
+use radroots_studio_domain::{
+ AccountSummary, KeyAvailability, ProfileMetadata, SafeError, SignerKind,
+};
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct SafeErrorDto {
+ pub code: String,
+ pub message: String,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum AppLifecycleDto {
+ Booting,
+ Ready,
+ Fatal,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum SessionStateDto {
+ SignedOut,
+ Activating,
+ Active,
+ SigningOut,
+ Failed,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum RelayConnectionStateDto {
+ Disconnected,
+ Connecting,
+ Connected,
+ Degraded,
+ Error,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum ProfileLoadStateDto {
+ Empty,
+ Loading,
+ Cached,
+ Fresh,
+ Error,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum SignerKindDto {
+ LocalSecret,
+ WatchOnly,
+ RemoteNip46,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum KeyAvailabilityDto {
+ Available,
+ CredentialMissing,
+ StoreUnavailable,
+ NotRequired,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct ProfileDto {
+ pub name: Option<String>,
+ pub display_name: Option<String>,
+ pub nip05: Option<String>,
+ pub about: Option<String>,
+ pub picture: Option<String>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct AccountDto {
+ pub public_key_hex: String,
+ pub npub: String,
+ pub display_label: String,
+ pub signer_kind: SignerKindDto,
+ pub key_availability: KeyAvailabilityDto,
+ pub created_at_seconds: i64,
+ pub last_used_at_seconds: Option<i64>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct ActiveAccountDto {
+ pub account: AccountDto,
+ pub relay_state: RelayConnectionStateDto,
+ pub profile_state: ProfileLoadStateDto,
+ pub profile: Option<ProfileDto>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct AppSnapshotDto {
+ pub revision: u64,
+ pub lifecycle: AppLifecycleDto,
+ pub lifecycle_error: Option<SafeErrorDto>,
+ pub configured_relays: Vec<String>,
+ pub accounts: Vec<AccountDto>,
+ pub selected_public_key_hex: Option<String>,
+ pub session: SessionStateDto,
+ pub session_subject_public_key_hex: Option<String>,
+ pub session_error: Option<SafeErrorDto>,
+ pub active_account: Option<ActiveAccountDto>,
+ pub recoverable_problem: Option<SafeErrorDto>,
+}
+
+impl From<&AppSnapshot> for AppSnapshotDto {
+ fn from(snapshot: &AppSnapshot) -> Self {
+ let (lifecycle, lifecycle_error) = match snapshot.lifecycle() {
+ AppLifecycle::Booting => (AppLifecycleDto::Booting, None),
+ AppLifecycle::Ready => (AppLifecycleDto::Ready, None),
+ AppLifecycle::Fatal(error) => (AppLifecycleDto::Fatal, Some(error.into())),
+ };
+ let (session, session_subject_public_key_hex, session_error) = match snapshot.session() {
+ SessionState::SignedOut => (SessionStateDto::SignedOut, None, None),
+ SessionState::Activating(public_key) => {
+ (SessionStateDto::Activating, Some(public_key.to_hex()), None)
+ }
+ SessionState::Active => (SessionStateDto::Active, None, None),
+ SessionState::SigningOut => (SessionStateDto::SigningOut, None, None),
+ SessionState::Failed(error) => (SessionStateDto::Failed, None, Some(error.into())),
+ };
+ Self {
+ revision: snapshot.revision().value(),
+ lifecycle,
+ lifecycle_error,
+ configured_relays: snapshot
+ .relay_configuration()
+ .relays()
+ .iter()
+ .map(|relay| relay.as_str().to_owned())
+ .collect(),
+ accounts: snapshot.accounts().iter().map(AccountDto::from).collect(),
+ selected_public_key_hex: snapshot
+ .selected_account()
+ .map(radroots_studio_domain::PublicKey::to_hex),
+ session,
+ session_subject_public_key_hex,
+ session_error,
+ active_account: snapshot.active_account().map(ActiveAccountDto::from),
+ recoverable_problem: snapshot.recoverable_problem().map(SafeErrorDto::from),
+ }
+ }
+}
+
+impl From<&AccountSummary> for AccountDto {
+ fn from(account: &AccountSummary) -> Self {
+ Self {
+ public_key_hex: account.public_key().to_hex(),
+ npub: account.npub().as_str().to_owned(),
+ display_label: account.display_label(),
+ signer_kind: account.signer_kind().into(),
+ key_availability: account.key_availability().into(),
+ created_at_seconds: account.created_at().timestamp().as_seconds(),
+ last_used_at_seconds: account
+ .last_used_at()
+ .map(radroots_studio_domain::UnixTimestamp::as_seconds),
+ }
+ }
+}
+
+impl From<&ActiveAccountSnapshot> for ActiveAccountDto {
+ fn from(active: &ActiveAccountSnapshot) -> Self {
+ Self {
+ account: active.account().into(),
+ relay_state: active.relay_state().into(),
+ profile_state: active.profile_state().into(),
+ profile: active.profile().map(ProfileDto::from),
+ }
+ }
+}
+
+impl From<&ProfileMetadata> for ProfileDto {
+ fn from(profile: &ProfileMetadata) -> Self {
+ Self {
+ name: profile.name().map(str::to_owned),
+ display_name: profile.display_name().map(str::to_owned),
+ nip05: profile.nip05().map(str::to_owned),
+ about: profile.about().map(str::to_owned),
+ picture: profile.picture().map(str::to_owned),
+ }
+ }
+}
+
+impl From<SafeError> for SafeErrorDto {
+ fn from(error: SafeError) -> Self {
+ Self {
+ code: format!("{:?}", error.code()),
+ message: error.message().as_str().to_owned(),
+ }
+ }
+}
+
+impl From<SignerKind> for SignerKindDto {
+ fn from(value: SignerKind) -> Self {
+ match value {
+ SignerKind::LocalSecret => Self::LocalSecret,
+ SignerKind::WatchOnly => Self::WatchOnly,
+ SignerKind::RemoteNip46 => Self::RemoteNip46,
+ }
+ }
+}
+
+impl From<KeyAvailability> for KeyAvailabilityDto {
+ fn from(value: KeyAvailability) -> Self {
+ match value {
+ KeyAvailability::Available => Self::Available,
+ KeyAvailability::CredentialMissing => Self::CredentialMissing,
+ KeyAvailability::StoreUnavailable => Self::StoreUnavailable,
+ KeyAvailability::NotRequired => Self::NotRequired,
+ }
+ }
+}
+
+impl From<RelayConnectionState> for RelayConnectionStateDto {
+ fn from(value: RelayConnectionState) -> Self {
+ match value {
+ RelayConnectionState::Disconnected => Self::Disconnected,
+ RelayConnectionState::Connecting => Self::Connecting,
+ RelayConnectionState::Connected => Self::Connected,
+ RelayConnectionState::Degraded => Self::Degraded,
+ RelayConnectionState::Error(_) => Self::Error,
+ }
+ }
+}
+
+impl From<ProfileLoadState> for ProfileLoadStateDto {
+ fn from(value: ProfileLoadState) -> Self {
+ match value {
+ ProfileLoadState::Empty => Self::Empty,
+ ProfileLoadState::Loading => Self::Loading,
+ ProfileLoadState::Cached => Self::Cached,
+ ProfileLoadState::Fresh => Self::Fresh,
+ ProfileLoadState::Error(_) => Self::Error,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_application::{AppCore, RelayConfiguration};
+
+ use super::AppSnapshotDto;
+
+ #[test]
+ fn snapshot_dto_is_revisioned_public_and_secret_free() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let snapshot = core.bootstrap().expect("bootstrap");
+ let dto = AppSnapshotDto::from(&snapshot);
+ let debug = format!("{dto:?}");
+
+ assert_eq!(dto.revision, 1);
+ assert!(dto.accounts.is_empty());
+ assert!(!debug.contains("nsec"));
+ assert!(!debug.contains("secret_key"));
+ assert!(!debug.contains("server_url"));
+ }
+}
diff --git a/crates/studio_ffi/src/lib.rs b/crates/studio_ffi/src/lib.rs
@@ -1,9 +1,10 @@
#![doc = "Radroots Studio `UniFFI` boundary."]
-#[cfg(test)]
-mod tests {
- #[test]
- fn crate_is_available_to_the_workspace() {
- assert_eq!(env!("CARGO_PKG_NAME"), "radroots-studio-ffi");
- }
-}
+mod dto;
+
+pub use dto::{
+ AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto,
+ ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto,
+};
+
+uniffi::setup_scaffolding!();
diff --git a/crates/studio_ffi/uniffi.toml b/crates/studio_ffi/uniffi.toml
@@ -0,0 +1,3 @@
+[bindings.kotlin]
+package_name = "org.radroots.studio.ffi"
+cdylib_name = "radroots_studio_ffi"
diff --git a/imports/studio_workspace/Cargo.toml b/imports/studio_workspace/Cargo.toml
@@ -34,6 +34,7 @@ secrecy = "=0.10.3"
url = "=2.5.8"
zeroize = "=1.9.0"
tokio = "=1.47.1"
+uniffi = "=0.32.0"
[patch.crates-io]
nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219" }