From a057545847ba5ce5b56cf589487a20fe8eddcc2c Mon Sep 17 00:00:00 2001 From: deymosh Date: Thu, 10 Sep 2026 16:54:45 +0200 Subject: [PATCH 001/155] chore: add throwaway F0 UniFFI binding probe (verdict: GO) F0 probe-1 from the native-android migration plan. Answers, with running code, whether a UniFFI-generated API for the future Rust client-core is ergonomic from Kotlin/Compose and usable from Tauri. Verdict: GO. Exercised through the real FFI (JNA against the cdylib): - foreign-implemented callback trait (CoreListener) - async fn command -> Kotlin suspend fun (Core.dispatch) - typed errors -> sealed class CoreException subclasses - object lifecycle with a tokio background task, no thread leak - 50 concurrent suspend dispatches during background emission - real NIP-44 v2 (nostr crate) crossing the boundary as String The same Rust API also compiles behind a tauri command (tauri-consumer). Findings, now conventions for the real client-core: - a fielded uniffi error variant must not name a field "message" (collides with Kotlin Throwable.message; 0.28 emits no override) - a tauri command macro must live in a submodule, not a library crate root Data: nostr 0.44 with default-features=false plus nip44 is a tight dep tree; leaning on it for NIP-44 in F1 is fine. Reproduce: ./spike/uniffi-binding-probe/run.sh (Docker only). Delete spike/ once the verdict is recorded in the plan or an ADR. Co-Authored-By: Claude Sonnet 5 --- spike/uniffi-binding-probe/.gitignore | 12 ++ spike/uniffi-binding-probe/README.md | 103 ++++++++++ .../client-core-probe/Cargo.toml | 35 ++++ .../client-core-probe/src/core.rs | 166 ++++++++++++++++ .../client-core-probe/src/crypto.rs | 69 +++++++ .../client-core-probe/src/lib.rs | 26 +++ .../client-core-probe/tests/native.rs | 100 ++++++++++ .../client-core-probe/uniffi-bindgen.rs | 5 + .../kotlin/build.gradle.kts | 43 ++++ .../gradle/wrapper/gradle-wrapper.properties | 5 + spike/uniffi-binding-probe/kotlin/gradlew | 185 ++++++++++++++++++ spike/uniffi-binding-probe/kotlin/gradlew.bat | 89 +++++++++ .../kotlin/settings.gradle.kts | 1 + .../kotlin/src/main/kotlin/ProbeViewModel.kt | 62 ++++++ .../kotlin/src/test/kotlin/ProbeTest.kt | 114 +++++++++++ spike/uniffi-binding-probe/run.sh | 60 ++++++ .../tauri-consumer/Cargo.toml | 18 ++ .../tauri-consumer/src/lib.rs | 63 ++++++ 18 files changed, 1156 insertions(+) create mode 100644 spike/uniffi-binding-probe/.gitignore create mode 100644 spike/uniffi-binding-probe/README.md create mode 100644 spike/uniffi-binding-probe/client-core-probe/Cargo.toml create mode 100644 spike/uniffi-binding-probe/client-core-probe/src/core.rs create mode 100644 spike/uniffi-binding-probe/client-core-probe/src/crypto.rs create mode 100644 spike/uniffi-binding-probe/client-core-probe/src/lib.rs create mode 100644 spike/uniffi-binding-probe/client-core-probe/tests/native.rs create mode 100644 spike/uniffi-binding-probe/client-core-probe/uniffi-bindgen.rs create mode 100644 spike/uniffi-binding-probe/kotlin/build.gradle.kts create mode 100644 spike/uniffi-binding-probe/kotlin/gradle/wrapper/gradle-wrapper.properties create mode 100644 spike/uniffi-binding-probe/kotlin/gradlew create mode 100644 spike/uniffi-binding-probe/kotlin/gradlew.bat create mode 100644 spike/uniffi-binding-probe/kotlin/settings.gradle.kts create mode 100644 spike/uniffi-binding-probe/kotlin/src/main/kotlin/ProbeViewModel.kt create mode 100644 spike/uniffi-binding-probe/kotlin/src/test/kotlin/ProbeTest.kt create mode 100644 spike/uniffi-binding-probe/run.sh create mode 100644 spike/uniffi-binding-probe/tauri-consumer/Cargo.toml create mode 100644 spike/uniffi-binding-probe/tauri-consumer/src/lib.rs diff --git a/spike/uniffi-binding-probe/.gitignore b/spike/uniffi-binding-probe/.gitignore new file mode 100644 index 0000000..bff8ad6 --- /dev/null +++ b/spike/uniffi-binding-probe/.gitignore @@ -0,0 +1,12 @@ +target/ +client-core-probe/target/ +tauri-consumer/target/ +kotlin/.gradle/ +kotlin/build/ +kotlin/bindings/ +kotlin/lib/ +kotlin/gradle/wrapper/gradle-wrapper.jar +Cargo.lock +*.so +*.dll +*.dylib diff --git a/spike/uniffi-binding-probe/README.md b/spike/uniffi-binding-probe/README.md new file mode 100644 index 0000000..3a966de --- /dev/null +++ b/spike/uniffi-binding-probe/README.md @@ -0,0 +1,103 @@ +# F0 · probe 1 — UniFFI binding ergonomics + +> **THROWAWAY.** This whole `spike/` tree exists only to answer, with running +> code, migration plan §7 **F0-probe-1**. Delete it once the verdict below is +> folded into the plan (or an ADR). + +## Verdict: **GO** + +A UniFFI-generated API for the future Rust `client-core` is idiomatic from +Kotlin/Compose and clean to consume from Tauri. Every hard case — foreign +callbacks, `async` commands, typed errors, object lifecycle with a background +task, and concurrency — works at runtime through the real FFI. Proceed with the +planned split: `client-core` (pure Rust) → `client-runtime` (async) → **UniFFI** +for `apps/android`, **`#[tauri::command]`** for `apps/desktop`. + +## What was exercised + +| Risk (plan §7 F0-probe-1) | How | Result | +|---|---|---| +| Foreign-implemented callback trait | Kotlin `class Collector : CoreListener`, Rust bg task calls `on_event` from its own thread | ✅ callbacks land on the Kotlin object | +| `async fn` command → Kotlin `suspend fun` | `Core::dispatch` is `#[uniffi::export(async_runtime = "tokio")]`; called from `runBlocking` / `async{}` | ✅ real `suspend fun dispatch(intent: Intent)` | +| Typed error across the boundary | `CoreError` / `CryptoError` enums → `sealed class CoreException : Exception()` | ✅ `assertFailsWith`, `.reason` readable | +| Object lifecycle + bg task, no leak | `start()` spins a `tokio::runtime::Runtime` + task; `stop()` aborts + `shutdown_background()` | ✅ no events after `stop()`; `Core : AutoCloseable` | +| Concurrency | 50 concurrent `suspend` dispatches while the bg task emits to a Kotlin listener | ✅ exactly 50 `StateChanged`, ≥1 `TranscriptAppended`, no loss/dup/deadlock | +| Real NIP-44 v2 over FFI (plan risk #13) | `nostr` crate `nip44`, `String` in/out | ✅ round-trips; footprint measured (below) | +| Same API from Tauri | `tauri-consumer` crate: `#[tauri::command]` async + typed error + `emit` event stream | ✅ `cargo check` clean | + +## Evidence (all green) + +- **Native:** `client-core-probe/tests/native.rs` — 3 `cargo test`, `cargo clippy -D warnings` clean. +- **Through the FFI:** `kotlin/src/test/kotlin/ProbeTest.kt` — 4 JUnit tests via JNA against the real `libclient_core_probe.so`. +- **Tauri:** `tauri-consumer/` — `#[tauri::command] { dispatch (async), snapshot, start, stop }` + `wire_events` (`CoreListener` → `AppHandle::emit`) compile. +- **Compose shape:** `kotlin/src/main/kotlin/ProbeViewModel.kt` — the intended `ViewModel` over the bindings compiles: sealed `CoreEvent` folds into a `StateFlow`, `dispatch` in a coroutine, `CoreException` subclasses caught normally. ~45 LOC of glue, **zero domain logic**. + +Reproduce: `./spike/uniffi-binding-probe/run.sh` (Docker only — no host toolchain). + +## Generated Kotlin — ergonomics assessment + +The `uniffi-bindgen` output (`kotlin/bindings/…/client_core_probe.kt`) is what a +Compose app would import: + +```kotlin +suspend fun dispatch(intent: Intent) // real coroutine suspend, @Throws(CoreException) +sealed class CoreEvent { data class StateChanged(val slice: String) : CoreEvent(); … } // exhaustive when +sealed class Intent { data class SendInput(val session: String, val text: String) : Intent(); … } +sealed class CoreException : kotlin.Exception() { class Rejected(val reason: String) : CoreException(); … } +data class ProbeView(var running: Boolean, var seq: ULong, var listenerCount: UInt) +open class Core : Disposable, AutoCloseable { … } // use {} / .destroy(), Cleaner fallback +interface CoreListener { fun onEvent(event: CoreEvent) } +``` + +Rust `///` docs are carried through to KDoc. Nothing here needs a wrapper layer +to be pleasant in Compose. + +## Findings to fold into the real `client-core` (both are trivial conventions) + +1. **A fielded UniFFI error variant must not name a field `message`.** It + collides with Kotlin's `Throwable.message` and the 0.28 codegen does not emit + `override` → the generated `.kt` won't compile. Convention: error fields are + `detail` / `reason` / domain-specific — never `message`. Cheap to enforce in + the planned anti-drift lint. +2. **`#[tauri::command]` functions go in a submodule, never a library crate's + root** — the macro's `#[macro_export]` collides with its own local re-export + there. `apps/desktop` will have a `commands` module regardless, so this is a + non-issue in practice; noted so the real code starts that way. + +## Data — `nostr` crate footprint (plan risk #13: hand-roll vs `nostr`) + +`nostr 0.44` with `default-features = false, features = ["std", "nip44"]` pulls, +at depth 1: `base64 bech32 bitcoin_hashes chacha20 hex secp256k1 serde +serde_json url`. The only heavy item is **`secp256k1`** (vendored C) — and secp +ECDH is unavoidable for NIP-44 whether hand-rolled (`k256`/`secp256k1`) or not. + +**Conclusion:** leaning on `nostr` for NIP-44 in F1 is fine and not bloated. A +hand-roll would only shave `serde_json` + `url` off the leaf — revisit only if +`.so` size becomes a real constraint. + +## Also confirmed + +- **`uniffi` + `serde` derives compose on the same type** → one definition of a + boundary type (`Intent`, `CoreEvent`, `ProbeView`, `CoreError`) serves both the + Kotlin (UniFFI) and the Tauri (serde/JSON) binding. No DTO duplication. +- The async bridge (`async_runtime = "tokio"`) coexists with the `Core`'s own + owned `Runtime` for the bg task — two runtimes, no clash. +- `Runtime::shutdown_background()` (not `drop`) is the safe teardown from a sync + FFI method. + +## Not covered here — follow-ups + +- **F0-probe-2** (NDK / SQLCipher / Marmot cross-compile in the new workspace) — needs `aarch64-linux-android` + NDK 28. +- **F0-probe-3** (background reliability on real Samsung/Xiaomi/Pixel) — needs hardware; the maintainer runs it. +- **F0-probe-4** (Markdown/Compose transcript parity) — independent. +- **arm64 Android build** of these bindings (`.so` per ABI) — F3 scaffolding. +- **Coroutine cancellation** of an in-flight `suspend dispatch` → Rust future drop — worth an explicit check in F1. + +## Versions pinned in this probe + +rustc 1.98.1 · uniffi 0.28.3 · nostr 0.44.8 · tokio 1.53 · tauri 2.11 · +Gradle 8.14.3 · Kotlin 2.1.0 · JNA 5.15.0 · kotlinx-coroutines 1.9.0 · JDK 17 + +## Delete criteria + +Once this GO is recorded in the plan (or an ADR), `rm -rf spike/`. diff --git a/spike/uniffi-binding-probe/client-core-probe/Cargo.toml b/spike/uniffi-binding-probe/client-core-probe/Cargo.toml new file mode 100644 index 0000000..a1866c3 --- /dev/null +++ b/spike/uniffi-binding-probe/client-core-probe/Cargo.toml @@ -0,0 +1,35 @@ +# THROWAWAY F0 spike crate. Its only job is to answer, with running code, the +# Go/No-Go question in the migration plan §7 F0-probe-1: is a UniFFI-generated +# API for the future Rust `client-core` ergonomic from Kotlin/Compose and +# reasonable from Tauri? Delete this whole `spike/` tree once +# `spike/uniffi-binding-probe/README.md` records the verdict. +[package] +name = "client-core-probe" +version = "0.0.0" +edition = "2021" +publish = false +license = "MIT" + +[lib] +name = "client_core_probe" +# cdylib: the artifact Kotlin (JNA) and a Tauri host load at runtime. +# lib: so the native `tests/` and a Tauri crate can link it directly. +crate-type = ["cdylib", "lib"] + +[dependencies] +uniffi = { version = "0.28", features = ["tokio", "cli"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } +thiserror = "1" +serde = { version = "1", features = ["derive"] } +# NIP-44 v2: reuse the vetted implementation already in this repo's tree (MDK +# pulls `nostr` 0.44). The plan's risk #13 — hand-roll vs `nostr` crate — is +# decided "con datos" here: `cargo tree` output goes in the README. +nostr = { version = "0.44", default-features = false, features = ["std", "nip44"] } + +[dev-dependencies] +tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "test-util"] } + +# Generates the language bindings: `cargo run --bin uniffi-bindgen -- ...` +[[bin]] +name = "uniffi-bindgen" +path = "uniffi-bindgen.rs" diff --git a/spike/uniffi-binding-probe/client-core-probe/src/core.rs b/spike/uniffi-binding-probe/client-core-probe/src/core.rs new file mode 100644 index 0000000..9589454 --- /dev/null +++ b/spike/uniffi-binding-probe/client-core-probe/src/core.rs @@ -0,0 +1,166 @@ +//! A toy `Core` with the exact *shape* the real client-runtime will expose: +//! `subscribe` (foreign callback), `dispatch` (async command, typed error), +//! `snapshot` (plain-data view), `start`/`stop` (lifecycle + bg task). +//! Behaviour is fake; the FFI ergonomics are real. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::runtime::Runtime; +use tokio::task::JoinHandle; + +// Spike finding: `uniffi` and `serde` derives compose on the same type, so ONE +// definition of a boundary type serves both the Kotlin (UniFFI) and the Tauri +// (serde/JSON) binding. The real client-core relies on this. +#[derive(Debug, thiserror::Error, uniffi::Error, Serialize)] +pub enum CoreError { + #[error("core not started")] + NotStarted, + #[error("bridge rejected the intent: {reason}")] + Rejected { reason: String }, + // field is `detail`, not `message` — see the note in crypto.rs + #[error("internal error: {detail}")] + Internal { detail: String }, +} + +/// Semantic events only — no UI strings. The UI decides how to surface them. +#[derive(Debug, Clone, PartialEq, uniffi::Enum, Serialize, Deserialize)] +pub enum CoreEvent { + /// a view slice changed; the UI re-reads that slice + StateChanged { slice: String }, + /// transcript rows appended (a delta, never a full snapshot) + TranscriptAppended { session: String, from_seq: u64 }, + /// a semantic failure the UI turns into a toast/banner/inline message + ActionFailed { kind: String }, +} + +#[derive(Debug, Clone, uniffi::Enum, Serialize, Deserialize)] +pub enum Intent { + SendInput { session: String, text: String }, + Interrupt { session: String }, + /// forces a typed `Err(CoreError::Rejected)` — exercises `Result` over FFI + ForceReject { reason: String }, +} + +/// Foreign types (Kotlin/Swift/JS) implement this; Rust calls back into them. +#[uniffi::export(with_foreign)] +pub trait CoreListener: Send + Sync { + fn on_event(&self, event: CoreEvent); +} + +/// Read-only projection handed to the UI as plain data (the `*_view` pattern). +#[derive(Debug, Clone, uniffi::Record, Serialize, Deserialize)] +pub struct ProbeView { + pub running: bool, + pub seq: u64, + pub listener_count: u32, +} + +#[derive(uniffi::Object)] +pub struct Core { + seq: AtomicU64, + running: AtomicBool, + listeners: Mutex>>, + rt: Mutex>, + socket_task: Mutex>>, +} + +#[uniffi::export(async_runtime = "tokio")] +impl Core { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self { + seq: AtomicU64::new(0), + running: AtomicBool::new(false), + listeners: Mutex::new(Vec::new()), + rt: Mutex::new(None), + socket_task: Mutex::new(None), + }) + } + + /// Register a foreign listener. Sync — the UI never awaits this. + pub fn subscribe(&self, listener: Arc) { + self.listeners.lock().unwrap().push(listener); + } + + /// Lifecycle: spin up the reactor + a dummy "socket" task that emits every 200ms. + pub fn start(self: Arc) { + if self.running.swap(true, Ordering::SeqCst) { + return; + } + let rt = Runtime::new().expect("tokio runtime"); + let me = Arc::clone(&self); + let handle = rt.spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_millis(200)); + loop { + ticker.tick().await; + if !me.running.load(Ordering::SeqCst) { + break; + } + let from = me.seq.fetch_add(1, Ordering::SeqCst) + 1; + me.emit(CoreEvent::TranscriptAppended { + session: "probe".into(), + from_seq: from, + }); + } + }); + *self.rt.lock().unwrap() = Some(rt); + *self.socket_task.lock().unwrap() = Some(handle); + } + + /// Lifecycle: stop cleanly. After this, no further events reach listeners + /// and the worker threads are released. + pub fn stop(self: Arc) { + if !self.running.swap(false, Ordering::SeqCst) { + return; + } + if let Some(h) = self.socket_task.lock().unwrap().take() { + h.abort(); + } + // shutdown_background: returns immediately, never panics even if called + // from inside an async context (unlike `drop(Runtime)`). + if let Some(rt) = self.rt.lock().unwrap().take() { + rt.shutdown_background(); + } + } + + pub fn snapshot(&self) -> ProbeView { + ProbeView { + running: self.running.load(Ordering::SeqCst), + seq: self.seq.load(Ordering::SeqCst), + listener_count: self.listeners.lock().unwrap().len() as u32, + } + } + + /// The async command path — becomes `suspend fun dispatch(...)` in Kotlin. + pub async fn dispatch(self: Arc, intent: Intent) -> Result<(), CoreError> { + if !self.running.load(Ordering::SeqCst) { + return Err(CoreError::NotStarted); + } + // genuinely yield so we exercise the real async bridge, not a sync fast path + tokio::time::sleep(Duration::from_millis(1)).await; + match intent { + Intent::ForceReject { reason } => Err(CoreError::Rejected { reason }), + Intent::Interrupt { session } => { + self.emit(CoreEvent::StateChanged { slice: format!("session:{session}") }); + Ok(()) + } + Intent::SendInput { session, .. } => { + self.seq.fetch_add(1, Ordering::SeqCst); + self.emit(CoreEvent::StateChanged { slice: format!("session:{session}") }); + Ok(()) + } + } + } +} + +impl Core { + fn emit(&self, event: CoreEvent) { + let listeners = self.listeners.lock().unwrap().clone(); + for l in listeners { + l.on_event(event.clone()); + } + } +} diff --git a/spike/uniffi-binding-probe/client-core-probe/src/crypto.rs b/spike/uniffi-binding-probe/client-core-probe/src/crypto.rs new file mode 100644 index 0000000..d69089d --- /dev/null +++ b/spike/uniffi-binding-probe/client-core-probe/src/crypto.rs @@ -0,0 +1,69 @@ +//! NIP-44 v2 helpers, a faithful shape-match of `apps/mobile/src/core/crypto.ts` +//! (`generateKeypair` / `keypairFromSecret` / `encryptTo` / `decryptFrom`). +//! Backed by the `nostr` crate's vetted implementation for this spike; the real +//! `client-core` decides hand-roll vs `nostr` in F1 (plan risk #13). + +use nostr::key::{Keys, PublicKey, SecretKey}; +use nostr::nips::nip44::{self, Version}; +use serde::{Deserialize, Serialize}; + +// NOTE (spike finding): a fielded uniffi error variant must NOT name a field +// `message` — it collides with Kotlin's `Throwable.message` and the 0.28 +// codegen does not emit `override`. Convention for the real client-core: +// error fields are `detail` / `reason` / domain-specific names, never `message`. +#[derive(Debug, thiserror::Error, uniffi::Error, Serialize)] +pub enum CryptoError { + #[error("invalid key: {detail}")] + BadKey { detail: String }, + #[error("nip44 failure: {detail}")] + Nip44 { detail: String }, +} + +/// Hex-encoded keypair — the wire/storage form the phone core already uses. +#[derive(Debug, Clone, uniffi::Record, Serialize, Deserialize)] +pub struct Keypair { + pub secret_hex: String, + pub public_hex: String, +} + +fn keypair_of(keys: &Keys) -> Keypair { + Keypair { + secret_hex: keys.secret_key().to_secret_hex(), + public_hex: keys.public_key().to_hex(), + } +} + +#[uniffi::export] +pub fn generate_keypair() -> Keypair { + keypair_of(&Keys::generate()) +} + +#[uniffi::export] +pub fn keypair_from_secret(secret_hex: String) -> Result { + let sk = SecretKey::from_hex(&secret_hex).map_err(|e| CryptoError::BadKey { detail: e.to_string() })?; + Ok(keypair_of(&Keys::new(sk))) +} + +#[uniffi::export] +pub fn encrypt_to( + sender_secret_hex: String, + recipient_public_hex: String, + plaintext: String, +) -> Result { + let sk = SecretKey::from_hex(&sender_secret_hex).map_err(|e| CryptoError::BadKey { detail: e.to_string() })?; + let pk = + PublicKey::from_hex(&recipient_public_hex).map_err(|e| CryptoError::BadKey { detail: e.to_string() })?; + nip44::encrypt(&sk, &pk, plaintext, Version::V2).map_err(|e| CryptoError::Nip44 { detail: e.to_string() }) +} + +#[uniffi::export] +pub fn decrypt_from( + recipient_secret_hex: String, + sender_public_hex: String, + ciphertext: String, +) -> Result { + let sk = + SecretKey::from_hex(&recipient_secret_hex).map_err(|e| CryptoError::BadKey { detail: e.to_string() })?; + let pk = PublicKey::from_hex(&sender_public_hex).map_err(|e| CryptoError::BadKey { detail: e.to_string() })?; + nip44::decrypt(&sk, &pk, &ciphertext).map_err(|e| CryptoError::Nip44 { detail: e.to_string() }) +} diff --git a/spike/uniffi-binding-probe/client-core-probe/src/lib.rs b/spike/uniffi-binding-probe/client-core-probe/src/lib.rs new file mode 100644 index 0000000..80fddfb --- /dev/null +++ b/spike/uniffi-binding-probe/client-core-probe/src/lib.rs @@ -0,0 +1,26 @@ +//! THROWAWAY F0 spike — NOT production code. +//! +//! Answers migration plan §7 F0-probe-1 with running code: is a UniFFI-generated +//! API for the future Rust `client-core` ergonomic from Kotlin/Compose and +//! reasonable from Tauri? It deliberately exercises the five things that would +//! sink the whole approach if UniFFI handled them badly: +//! +//! 1. a foreign-implemented callback trait (`CoreListener`) +//! 2. an `async fn` command (`Core::dispatch` -> Kotlin `suspend fun`) +//! 3. a typed error across the boundary (`CoreError` / `CryptoError`) +//! 4. object lifecycle with a bg task (`start` / `stop`, no thread leak) +//! 5. concurrency (dispatch storm while the bg task emits) +//! +//! Plus real NIP-44 v2 crossing the FFI as plain `String`, to size the +//! dependency footprint of leaning on the `nostr` crate (plan risk #13). +//! +//! Delete this whole `spike/` tree once `spike/uniffi-binding-probe/README.md` +//! records the Go/No-Go. + +uniffi::setup_scaffolding!(); + +mod core; +mod crypto; + +pub use core::{Core, CoreError, CoreEvent, CoreListener, Intent, ProbeView}; +pub use crypto::{decrypt_from, encrypt_to, generate_keypair, keypair_from_secret, CryptoError, Keypair}; diff --git a/spike/uniffi-binding-probe/client-core-probe/tests/native.rs b/spike/uniffi-binding-probe/client-core-probe/tests/native.rs new file mode 100644 index 0000000..fe60cbb --- /dev/null +++ b/spike/uniffi-binding-probe/client-core-probe/tests/native.rs @@ -0,0 +1,100 @@ +//! Native (`cargo test`, host target) checks of the toy Core's semantics — the +//! same behaviour the Kotlin/JVM harness then verifies *through the FFI*. + +use std::sync::{Arc, Mutex}; + +use client_core_probe::*; + +struct Collector(Mutex>); +impl CoreListener for Collector { + fn on_event(&self, event: CoreEvent) { + self.0.lock().unwrap().push(event); + } +} +impl Collector { + fn new() -> Arc { + Arc::new(Self(Mutex::new(Vec::new()))) + } + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} + +#[test] +fn nip44_roundtrip_and_typed_error() { + let a = generate_keypair(); + let b = generate_keypair(); + + let ct = encrypt_to(a.secret_hex.clone(), b.public_hex.clone(), "hola mundo".into()).unwrap(); + let pt = decrypt_from(b.secret_hex.clone(), a.public_hex.clone(), ct).unwrap(); + assert_eq!(pt, "hola mundo"); + + match keypair_from_secret("not-hex".into()) { + Err(CryptoError::BadKey { .. }) => {} + other => panic!("expected BadKey, got {other:?}"), + } +} + +#[test] +fn lifecycle_starts_and_stops_with_no_leak() { + let core = Core::new(); + let col = Collector::new(); + core.subscribe(col.clone()); + + core.clone().start(); + std::thread::sleep(std::time::Duration::from_millis(700)); + core.clone().stop(); + + let after_stop = col.events().len(); + assert!(after_stop >= 2, "bg task should have emitted a few, got {after_stop}"); + + std::thread::sleep(std::time::Duration::from_millis(500)); + assert_eq!(col.events().len(), after_stop, "no events must arrive after stop()"); + + assert!(!core.snapshot().running); +} + +#[test] +fn async_dispatch_typed_error_and_concurrency() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let core = Core::new(); + let col = Collector::new(); + core.subscribe(col.clone()); + + // dispatch before start -> typed NotStarted + assert!(matches!( + rt.block_on(core.clone().dispatch(Intent::Interrupt { session: "s".into() })), + Err(CoreError::NotStarted) + )); + + core.clone().start(); + + // typed Rejected via the async path + assert!(matches!( + rt.block_on(core.clone().dispatch(Intent::ForceReject { reason: "busy".into() })), + Err(CoreError::Rejected { .. }) + )); + + // 50 concurrent dispatches while the bg "socket" task emits in parallel + rt.block_on(async { + let mut set = tokio::task::JoinSet::new(); + for i in 0..50 { + let c = core.clone(); + set.spawn(async move { + c.dispatch(Intent::SendInput { session: "s".into(), text: format!("m{i}") }) + .await + }); + } + while let Some(r) = set.join_next().await { + r.unwrap().unwrap(); + } + }); + + core.clone().stop(); + + let events = col.events(); + let state_changes = events.iter().filter(|e| matches!(e, CoreEvent::StateChanged { .. })).count(); + let appended = events.iter().filter(|e| matches!(e, CoreEvent::TranscriptAppended { .. })).count(); + assert_eq!(state_changes, 50, "one StateChanged per SendInput, no lost/dup under contention"); + assert!(appended >= 1, "bg task emitted during the dispatch storm"); +} diff --git a/spike/uniffi-binding-probe/client-core-probe/uniffi-bindgen.rs b/spike/uniffi-binding-probe/client-core-probe/uniffi-bindgen.rs new file mode 100644 index 0000000..c26c686 --- /dev/null +++ b/spike/uniffi-binding-probe/client-core-probe/uniffi-bindgen.rs @@ -0,0 +1,5 @@ +// Binding generator entrypoint (uniffi proc-macro mode). +// cargo run --bin uniffi-bindgen -- generate --library --language kotlin --out-dir +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/spike/uniffi-binding-probe/kotlin/build.gradle.kts b/spike/uniffi-binding-probe/kotlin/build.gradle.kts new file mode 100644 index 0000000..b65afbe --- /dev/null +++ b/spike/uniffi-binding-probe/kotlin/build.gradle.kts @@ -0,0 +1,43 @@ +// THROWAWAY F0 spike. Runs the UniFFI-generated Kotlin bindings through the real +// FFI (JNA) against the Rust cdylib in ./lib, to judge Compose-side ergonomics. + +plugins { + kotlin("jvm") version "2.1.0" +} + +repositories { mavenCentral() } + +dependencies { + // What the generated bindings import. + implementation("net.java.dev.jna:jna:5.15.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + + testImplementation(kotlin("test")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") +} + +sourceSets { + // the uniffi-bindgen output, package `uniffi.client_core_probe` + main { kotlin.srcDir("bindings") } +} + +kotlin { + jvmToolchain(17) + compilerOptions { + freeCompilerArgs.addAll( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi", + ) + } +} + +tasks.test { + useJUnitPlatform() + // JNA resolves `client_core_probe` -> lib/libclient_core_probe.so + systemProperty("jna.library.path", file("lib").absolutePath) + testLogging { + events("passed", "failed", "skipped") + showStandardStreams = true + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} diff --git a/spike/uniffi-binding-probe/kotlin/gradle/wrapper/gradle-wrapper.properties b/spike/uniffi-binding-probe/kotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3ae1e2f --- /dev/null +++ b/spike/uniffi-binding-probe/kotlin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/spike/uniffi-binding-probe/kotlin/gradlew b/spike/uniffi-binding-probe/kotlin/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/spike/uniffi-binding-probe/kotlin/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/spike/uniffi-binding-probe/kotlin/gradlew.bat b/spike/uniffi-binding-probe/kotlin/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/spike/uniffi-binding-probe/kotlin/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/spike/uniffi-binding-probe/kotlin/settings.gradle.kts b/spike/uniffi-binding-probe/kotlin/settings.gradle.kts new file mode 100644 index 0000000..51b9cf2 --- /dev/null +++ b/spike/uniffi-binding-probe/kotlin/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "uniffi-binding-probe" diff --git a/spike/uniffi-binding-probe/kotlin/src/main/kotlin/ProbeViewModel.kt b/spike/uniffi-binding-probe/kotlin/src/main/kotlin/ProbeViewModel.kt new file mode 100644 index 0000000..ec51b4b --- /dev/null +++ b/spike/uniffi-binding-probe/kotlin/src/main/kotlin/ProbeViewModel.kt @@ -0,0 +1,62 @@ +package probe + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import uniffi.client_core_probe.Core +import uniffi.client_core_probe.CoreEvent +import uniffi.client_core_probe.CoreException +import uniffi.client_core_probe.CoreListener +import uniffi.client_core_probe.Intent +import uniffi.client_core_probe.ProbeView + +/** + * Illustrative — the shape a real Compose `ViewModel` would take over the + * generated bindings. Compiles (proves the API is ergonomic), not run here. + * + * Note how little glue there is: the sealed `CoreEvent` folds into a + * `StateFlow`, `dispatch` is a normal `suspend fun` in a coroutine, and + * `CoreException` subclasses are caught like any Kotlin exception. + */ +class ProbeViewModel( + private val core: Core, + private val scope: CoroutineScope, +) { + private val _view = MutableStateFlow(core.snapshot()) + val view: StateFlow = _view.asStateFlow() + + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _lastError.asStateFlow() + + private val listener = object : CoreListener { + override fun onEvent(event: CoreEvent) { + when (event) { + is CoreEvent.StateChanged, + is CoreEvent.TranscriptAppended -> _view.value = core.snapshot() + is CoreEvent.ActionFailed -> _lastError.value = event.kind + } + } + } + + fun start() { + core.subscribe(listener) + core.start() + } + + fun send(session: String, text: String) = scope.launch { + try { + core.dispatch(Intent.SendInput(session, text)) + } catch (e: CoreException.Rejected) { + _lastError.value = "rejected: ${e.reason}" + } catch (e: CoreException.NotStarted) { + _lastError.value = "not started" + } + } + + fun stop() { + core.stop() + core.close() + } +} diff --git a/spike/uniffi-binding-probe/kotlin/src/test/kotlin/ProbeTest.kt b/spike/uniffi-binding-probe/kotlin/src/test/kotlin/ProbeTest.kt new file mode 100644 index 0000000..6685396 --- /dev/null +++ b/spike/uniffi-binding-probe/kotlin/src/test/kotlin/ProbeTest.kt @@ -0,0 +1,114 @@ +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import uniffi.client_core_probe.Core +import uniffi.client_core_probe.CoreEvent +import uniffi.client_core_probe.CoreException +import uniffi.client_core_probe.CoreListener +import uniffi.client_core_probe.CryptoException +import uniffi.client_core_probe.Intent +import uniffi.client_core_probe.decryptFrom +import uniffi.client_core_probe.encryptTo +import uniffi.client_core_probe.generateKeypair +import uniffi.client_core_probe.keypairFromSecret +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** A foreign listener: Rust calls back into this on its bg thread. */ +private class Collector : CoreListener { + val events = CopyOnWriteArrayList() + override fun onEvent(event: CoreEvent) { + // exhaustive `when` on the sealed class — the Compose-side match + when (event) { + is CoreEvent.StateChanged -> events.add(event) + is CoreEvent.TranscriptAppended -> events.add(event) + is CoreEvent.ActionFailed -> events.add(event) + } + } +} + +class ProbeTest { + + @Test + fun nip44RoundTripAndTypedError() { + val a = generateKeypair() + val b = generateKeypair() + + val ct = encryptTo(a.secretHex, b.publicHex, "hola mundo") + val pt = decryptFrom(b.secretHex, a.publicHex, ct) + assertEquals("hola mundo", pt) + + assertFailsWith { keypairFromSecret("not-hex") } + } + + @Test + fun suspendDispatchTypedErrorsAndCallbacks() = runBlocking { + val core = Core() + val col = Collector() + core.subscribe(col) + + // dispatch before start -> typed NotStarted, as a normal Kotlin exception + assertFailsWith { + core.dispatch(Intent.Interrupt("s")) + } + + core.start() + + // typed Rejected via the async path + val rejected = assertFailsWith { + core.dispatch(Intent.ForceReject("busy")) + } + assertEquals("busy", rejected.reason) + + // a successful async dispatch drives a callback + core.dispatch(Intent.SendInput("s", "hi")) + assertTrue(col.events.any { it is CoreEvent.StateChanged }, "SendInput should have emitted StateChanged") + + core.stop() + core.close() + } + + @Test + fun concurrencyStormWhileBackgroundTaskEmits() = runBlocking { + val core = Core() + val col = Collector() + core.subscribe(col) + core.start() + + // 50 concurrent suspend dispatches while the Rust bg "socket" task emits + (0 until 50).map { i -> + async { core.dispatch(Intent.SendInput("s", "m$i")) } + }.awaitAll() + + core.stop() + + val stateChanges = col.events.count { it is CoreEvent.StateChanged } + val appended = col.events.count { it is CoreEvent.TranscriptAppended } + assertEquals(50, stateChanges, "one StateChanged per SendInput, none lost/dup under contention") + assertTrue(appended >= 1, "bg task emitted during the storm") + + core.close() + } + + @Test + fun lifecycleNoEventsAfterStop() = runBlocking { + val core = Core() + val col = Collector() + core.subscribe(col) + + core.start() + Thread.sleep(700) + core.stop() + val afterStop = col.events.size + assertTrue(afterStop >= 2, "bg task should have emitted a few, got $afterStop") + + Thread.sleep(500) + assertEquals(afterStop, col.events.size, "no events after stop()") + assertTrue(!core.snapshot().running) + + core.close() + } +} diff --git a/spike/uniffi-binding-probe/run.sh b/spike/uniffi-binding-probe/run.sh new file mode 100644 index 0000000..638c29f --- /dev/null +++ b/spike/uniffi-binding-probe/run.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Reproduce the F0 UniFFI-binding probe end to end, entirely in Docker (no host +# Rust/Kotlin toolchain needed — matches this repo's "everything via Docker"). +# +# ./spike/uniffi-binding-probe/run.sh +# +# Stages: +# 1. rust:1-bookworm cargo test (native) + clippy + generate Kotlin bindings + cdylib +# 2. rust:1-bookworm cargo check the Tauri `#[tauri::command]` consumer +# 3. temurin:17-jdk Gradle: run the Kotlin/JVM tests THROUGH the real FFI (JNA) +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +export MSYS_NO_PATHCONV=1 + +RUST_IMG=rust:1-bookworm +JDK_IMG=eclipse-temurin:17-jdk +CARGO_VOL=codedeck-spike-cargo +GRADLE_VOL=codedeck-spike-gradle + +# the Gradle wrapper jar is a binary — not committed; borrow the repo's own +WRAPPER="$HERE/kotlin/gradle/wrapper/gradle-wrapper.jar" +if [ ! -f "$WRAPPER" ]; then + cp "$REPO/apps/mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar" "$WRAPPER" +fi + +run_rust() { docker run --rm -v "$REPO/spike/uniffi-binding-probe:/spike" -v "$CARGO_VOL:/root/cargotarget" \ + -e CARGO_TARGET_DIR=/root/cargotarget -w /spike "$RUST_IMG" bash -c "$1"; } + +echo "== stage 1: Rust (native tests, clippy, bindgen, cdylib) ==" +run_rust ' + set -e + cd client-core-probe + cargo test + rustup component add clippy >/dev/null 2>&1 || true + cargo clippy --all-targets -- -D warnings + cargo build + cargo run --bin uniffi-bindgen -- generate \ + --library "$CARGO_TARGET_DIR/debug/libclient_core_probe.so" \ + --language kotlin --out-dir /spike/kotlin/bindings + mkdir -p /spike/kotlin/lib + cp "$CARGO_TARGET_DIR/debug/libclient_core_probe.so" /spike/kotlin/lib/ + echo "--- nostr crate dependency footprint (plan risk #13) ---" + cargo tree -e normal -p nostr --depth 1 +' + +echo "== stage 2: Tauri #[tauri::command] consumer (cargo check) ==" +run_rust ' + apt-get update -qq && apt-get install -y -qq \ + libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev >/dev/null + cd tauri-consumer && cargo check +' + +echo "== stage 3: Kotlin/JVM tests through the real FFI ==" +docker run --rm -v "$REPO/spike/uniffi-binding-probe:/spike" -v "$GRADLE_VOL:/root/.gradle" \ + -w /spike/kotlin "$JDK_IMG" sh -c './gradlew --no-daemon test' + +echo +echo "ALL GREEN — see README.md for the Go/No-Go writeup." diff --git a/spike/uniffi-binding-probe/tauri-consumer/Cargo.toml b/spike/uniffi-binding-probe/tauri-consumer/Cargo.toml new file mode 100644 index 0000000..55a7483 --- /dev/null +++ b/spike/uniffi-binding-probe/tauri-consumer/Cargo.toml @@ -0,0 +1,18 @@ +# THROWAWAY F0 spike — the OTHER half of probe-1: prove the same `client-core` +# API a Kotlin app consumes via UniFFI is also clean to consume from a Tauri +# (Rust) host via `#[tauri::command]`. `cargo check` only; no window is built. +[package] +name = "tauri-consumer" +version = "0.0.0" +edition = "2021" +publish = false +license = "MIT" + +[lib] +crate-type = ["lib"] + +[dependencies] +client-core-probe = { path = "../client-core-probe" } +tauri = "2" +serde = { version = "1", features = ["derive"] } +tokio = { version = "1", features = ["sync"] } diff --git a/spike/uniffi-binding-probe/tauri-consumer/src/lib.rs b/spike/uniffi-binding-probe/tauri-consumer/src/lib.rs new file mode 100644 index 0000000..2822024 --- /dev/null +++ b/spike/uniffi-binding-probe/tauri-consumer/src/lib.rs @@ -0,0 +1,63 @@ +//! The Desktop side of the same `client-core` API. A real `apps/desktop` would: +//! 1. `app.manage(Arc::new(Core::new()))` at setup +//! 2. `wire_events(app.handle().clone(), &core)` to bridge `CoreEvent` -> `emit` +//! 3. register `commands::{dispatch, snapshot, start, stop}` in `invoke_handler!` +//! +//! Note the total glue: the boundary types (`Intent`, `CoreEvent`, `ProbeView`, +//! `CoreError`) carry `serde` derives alongside their `uniffi` derives, so ONE +//! definition serves both bindings. `cargo check` only — no webview here. +//! +//! Spike finding: `#[tauri::command]` must live in a submodule, not at a +//! library crate's root — its `#[macro_export]` collides with its own re-export +//! there. `apps/desktop` will have a `commands` module anyway, so this is a +//! non-issue in practice; noted so the real code starts that way. + +use std::sync::Arc; + +use client_core_probe::{Core, CoreEvent}; +use tauri::Emitter; + +pub mod commands { + use std::sync::Arc; + + use client_core_probe::{Core, CoreError, Intent, ProbeView}; + use tauri::State; + + /// Desktop registers `Arc` as managed state once. + type CoreState<'a> = State<'a, Arc>; + + /// Async command -> the JS side `await invoke("dispatch", { intent })`. + /// The typed `CoreError` serializes straight to the JS rejection value. + #[tauri::command] + pub async fn dispatch(core: CoreState<'_>, intent: Intent) -> Result<(), CoreError> { + core.inner().clone().dispatch(intent).await + } + + /// Sync command returning a plain-data view -> `await invoke("snapshot")`. + #[tauri::command] + pub fn snapshot(core: CoreState<'_>) -> ProbeView { + core.snapshot() + } + + #[tauri::command] + pub fn start(core: CoreState<'_>) { + core.inner().clone().start(); + } + + #[tauri::command] + pub fn stop(core: CoreState<'_>) { + core.inner().clone().stop(); + } +} + +/// The `subscribe` callback becomes a Tauri event stream the frontend listens to +/// with `listen("core-event", ...)`. `CoreEvent`'s `serde` derive does the rest. +pub fn wire_events(app: tauri::AppHandle, core: &Arc) { + struct Emit(tauri::AppHandle); + impl client_core_probe::CoreListener for Emit { + fn on_event(&self, event: CoreEvent) { + let _ = self.0.emit("core-event", event); + } + } + core.subscribe(Arc::new(Emit(app.clone()))); +} From 69056eb910e9e832346fa39e5b1893ce07ef8241 Mon Sep 17 00:00:00 2001 From: deymosh Date: Thu, 10 Sep 2026 17:04:37 +0200 Subject: [PATCH 002/155] =?UTF-8?q?chore:=20F0=20probe-2=20=E2=80=94=20NDK?= =?UTF-8?q?/SQLCipher/Marmot=20in=20the=20workspace=20layout=20(verdict:?= =?UTF-8?q?=20GO)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MDK 0.8 / MLS + SQLCipher stack from apps/mobile/src-tauri survives the re-layout: crates/client-core as a workspace member + cdylib, alongside the uniffi proc-macro stack, and still cross-compiles to aarch64-linux-android. - host tests: two SQLCipher stores open (encryption verified), MLS key package minted, 1:1 group created (2 members) - android: cargo ndk -t arm64-v8a build -> libclient_core.so, ELF ARM aarch64, 14.5 MB; SQLCipher + vendored OpenSSL + secp256k1 C all linked - feature unification (rusqlite bundled-sqlcipher-vendored-openssl) holds across a two-member workspace - uniffi macros coexist with openmls/mdk-core in one crate Finding: cargo-ndk + NDK r28c cross-builds the vendored-openssl SQLCipher with zero AR_/RANLIB_ env hacks; the manual exports in apps/mobile/docker/Dockerfile (CDX-012) can likely go once apps/android's Gradle build uses cargo-ndk. Reproduce: ./spike/ndk-marmot-probe/run.sh (Docker only). Co-Authored-By: Claude Sonnet 5 --- spike/ndk-marmot-probe/.gitignore | 3 + spike/ndk-marmot-probe/Cargo.toml | 14 ++ spike/ndk-marmot-probe/Dockerfile | 17 +++ spike/ndk-marmot-probe/README.md | 56 ++++++++ .../crates/client-core/Cargo.toml | 35 +++++ .../crates/client-core/src/lib.rs | 26 ++++ .../crates/client-core/src/marmot.rs | 122 ++++++++++++++++++ .../crates/client-core/uniffi-bindgen.rs | 3 + .../crates/client-runtime-min/Cargo.toml | 14 ++ .../crates/client-runtime-min/src/lib.rs | 9 ++ spike/ndk-marmot-probe/run.sh | 33 +++++ 11 files changed, 332 insertions(+) create mode 100644 spike/ndk-marmot-probe/.gitignore create mode 100644 spike/ndk-marmot-probe/Cargo.toml create mode 100644 spike/ndk-marmot-probe/Dockerfile create mode 100644 spike/ndk-marmot-probe/README.md create mode 100644 spike/ndk-marmot-probe/crates/client-core/Cargo.toml create mode 100644 spike/ndk-marmot-probe/crates/client-core/src/lib.rs create mode 100644 spike/ndk-marmot-probe/crates/client-core/src/marmot.rs create mode 100644 spike/ndk-marmot-probe/crates/client-core/uniffi-bindgen.rs create mode 100644 spike/ndk-marmot-probe/crates/client-runtime-min/Cargo.toml create mode 100644 spike/ndk-marmot-probe/crates/client-runtime-min/src/lib.rs create mode 100644 spike/ndk-marmot-probe/run.sh diff --git a/spike/ndk-marmot-probe/.gitignore b/spike/ndk-marmot-probe/.gitignore new file mode 100644 index 0000000..a613310 --- /dev/null +++ b/spike/ndk-marmot-probe/.gitignore @@ -0,0 +1,3 @@ +target/ +jniLibs/ +Cargo.lock diff --git a/spike/ndk-marmot-probe/Cargo.toml b/spike/ndk-marmot-probe/Cargo.toml new file mode 100644 index 0000000..f6907c0 --- /dev/null +++ b/spike/ndk-marmot-probe/Cargo.toml @@ -0,0 +1,14 @@ +# THROWAWAY F0 probe-2. A real Cargo workspace with two members, where +# `client-core` carries BOTH the uniffi stack AND the MDK/MLS + SQLCipher stack +# from `apps/mobile/src-tauri` — the exact "re-layout" the plan flags as the +# risk (feature unification for `rusqlite`/`openssl-src`, and openmls coexisting +# with uniffi proc-macros, when the crate is a workspace member and a cdylib). +[workspace] +members = ["crates/client-core", "crates/client-runtime-min"] +resolver = "2" + +[workspace.package] +edition = "2021" +version = "0.0.0" +publish = false +license = "MIT" diff --git a/spike/ndk-marmot-probe/Dockerfile b/spike/ndk-marmot-probe/Dockerfile new file mode 100644 index 0000000..ecc0bd3 --- /dev/null +++ b/spike/ndk-marmot-probe/Dockerfile @@ -0,0 +1,17 @@ +# Lean Android cross-compile env for F0 probe-2: Rust + one NDK + the +# aarch64-linux-android target + cargo-ndk. No full Android SDK, no Gradle. +FROM rust:1-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends unzip curl file \ + && rm -rf /var/lib/apt/lists/* + +# NDK r28c == 28.2.13676358, the exact revision apps/mobile/src-tauri pins. +ARG NDK=android-ndk-r28c +RUN curl -fsSL -o /tmp/ndk.zip "https://dl.google.com/android/repository/${NDK}-linux.zip" \ + && unzip -q /tmp/ndk.zip -d /opt \ + && mv "/opt/${NDK}" /opt/ndk \ + && rm /tmp/ndk.zip +ENV ANDROID_NDK_HOME=/opt/ndk + +RUN rustup target add aarch64-linux-android \ + && cargo install cargo-ndk --locked diff --git a/spike/ndk-marmot-probe/README.md b/spike/ndk-marmot-probe/README.md new file mode 100644 index 0000000..0ecbf22 --- /dev/null +++ b/spike/ndk-marmot-probe/README.md @@ -0,0 +1,56 @@ +# F0 · probe 2 — NDK / SQLCipher / Marmot in the new workspace layout + +> **THROWAWAY.** Delete once the verdict is folded into the plan (or an ADR). + +## Verdict: **GO** + +The MDK 0.8 / MLS + SQLCipher stack from `apps/mobile/src-tauri` survives the +plan's re-layout — living in `crates/client-core` as a **workspace member** and a +**cdylib**, **alongside the `uniffi` proc-macro stack** — and still +cross-compiles to `aarch64-linux-android`. Feature unification for +`rusqlite` / `openssl-src` holds across the workspace. + +## What was proven + +| Risk (plan §7 F0-probe-2) | Result | +|---|---| +| `rusqlite` `bundled-sqlcipher-vendored-openssl` unification breaks in a workspace | ✅ holds — `client-core` keeps the direct `rusqlite` dep, `mdk-sqlite-storage`'s `bundled-sqlcipher` unifies up to it, second workspace member (`client-runtime-min`) depends on `client-core` and resolution stays correct | +| SQLCipher + vendored OpenSSL C don't cross-compile for Android | ✅ `libclient_core.so` = **ELF ARM aarch64**, 14.5 MB, SQLCipher + OpenSSL C linked in | +| `secp256k1` C (via `nostr` 0.44) doesn't cross-compile for Android | ✅ linked into the same `.so` | +| `uniffi` proc-macros can't coexist with `openmls` / `mdk-core` in one crate | ✅ `uniffi::setup_scaffolding!()` + `#[uniffi::export]` compile alongside the full MLS stack | +| the MLS code path actually runs (not just links) | ✅ host test: two SQLCipher stores opened, key package minted + verified, 1:1 MLS group created, 2 members; a second test confirms the db file has **no plaintext `SQLite format 3` header** (encryption is real) | + +## Findings + +- **`cargo-ndk` + NDK r28c needs no `AR_/RANLIB_` env hacks.** The old + `apps/mobile/docker/Dockerfile` exports + `RANLIB_/AR_aarch64_linux_android` to NDK llvm tools to work around an + `openssl-src` GNU-name issue (CDX-012). With `cargo-ndk` driving the build and + NDK r28c, the arm64 cross-build of `bundled-sqlcipher-vendored-openssl` + succeeds with **zero** manual toolchain env. The real `apps/android` Gradle + build should use `cargo-ndk` and can likely drop those exports. +- The lean image (Rust + one NDK + `cargo-ndk`, no Android SDK, no Gradle) is + enough for a `.so` cross-build — useful for a fast CI `cargo check --target + aarch64-linux-android` gate before the full APK job. + +## Not covered here + +- **Running** the `.so` on-device / emulator — that's F0-probe-3 + F3. +- `armeabi-v7a` / `x86_64` ABIs — trivial add (`-t armeabi-v7a -t x86_64`), not + needed to answer the risk. +- Stripping / final `.so` size budget — an F1/F3 concern (`≤ 50 MB` per plan). + +## Versions + +NDK r28c (28.2.13676358, matches `apps/mobile/src-tauri`) · rustc 1.98 · +mdk-core / mdk-sqlite-storage / mdk-storage-traits 0.8 · nostr 0.44.8 · +rusqlite 0.37 (`bundled-sqlcipher-vendored-openssl`) · uniffi 0.28.3 · +cargo-ndk (latest) + +## Reproduce + +`./spike/ndk-marmot-probe/run.sh` (Docker only; first run downloads NDK ~600 MB). + +## Delete criteria + +Once this GO is recorded in the plan (or an ADR), `rm -rf spike/ndk-marmot-probe/`. diff --git a/spike/ndk-marmot-probe/crates/client-core/Cargo.toml b/spike/ndk-marmot-probe/crates/client-core/Cargo.toml new file mode 100644 index 0000000..47830fd --- /dev/null +++ b/spike/ndk-marmot-probe/crates/client-core/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "client-core" +edition.workspace = true +version.workspace = true +publish.workspace = true +license.workspace = true + +[lib] +name = "client_core" +crate-type = ["cdylib", "rlib"] + +[dependencies] +# uniffi stack (same as probe-1) — must coexist with openmls/mdk in one crate +uniffi = { version = "0.28", features = ["cli"] } +thiserror = "1" + +# MDK / MLS + SQLCipher stack, byte-for-byte the versions in +# apps/mobile/src-tauri/Cargo.toml (CDX-012). +mdk-core = "0.8" +mdk-sqlite-storage = "0.8" +mdk-storage-traits = "0.8" +nostr = { version = "0.44", features = ["std", "nip44", "nip59"] } +# The direct dep that upgrades mdk's `bundled-sqlcipher` to the vendored-OpenSSL +# variant via feature unification, so SQLCipher cross-compiles self-contained +# for Android. If the workspace layout breaks this unification, probe-2 fails. +rusqlite = { version = "0.37", default-features = false, features = ["bundled-sqlcipher-vendored-openssl"] } +sha2 = "0.10" +hex = "0.4" + +[dev-dependencies] +tempfile = "3" + +[[bin]] +name = "uniffi-bindgen" +path = "uniffi-bindgen.rs" diff --git a/spike/ndk-marmot-probe/crates/client-core/src/lib.rs b/spike/ndk-marmot-probe/crates/client-core/src/lib.rs new file mode 100644 index 0000000..b10b101 --- /dev/null +++ b/spike/ndk-marmot-probe/crates/client-core/src/lib.rs @@ -0,0 +1,26 @@ +//! THROWAWAY F0 probe-2 — NOT production code. +//! +//! Proves the MDK/MLS + SQLCipher stack from `apps/mobile/src-tauri` survives +//! the plan's re-layout: living in `crates/client-core` as a workspace member + +//! cdylib, alongside the uniffi proc-macro stack, and cross-compiling to +//! `aarch64-linux-android`. + +uniffi::setup_scaffolding!(); + +mod marmot; + +pub use marmot::MarmotService; + +/// Callable smoke: open two SQLCipher-backed MDK stores under `dir` and run a +/// 1:1 MLS group creation loopback. Proves uniffi + openmls + SQLCipher coexist +/// and the code path actually executes (host only — Android just links this). +#[uniffi::export] +pub fn marmot_loopback_smoke(dir: String) -> Result { + marmot::loopback(std::path::Path::new(&dir)).map_err(MarmotProbeError::Failed) +} + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum MarmotProbeError { + #[error("{0}")] + Failed(String), +} diff --git a/spike/ndk-marmot-probe/crates/client-core/src/marmot.rs b/spike/ndk-marmot-probe/crates/client-core/src/marmot.rs new file mode 100644 index 0000000..fe652df --- /dev/null +++ b/spike/ndk-marmot-probe/crates/client-core/src/marmot.rs @@ -0,0 +1,122 @@ +//! A minimal, faithful slice of `apps/mobile/src-tauri/src/marmot.rs`: open a +//! SQLCipher-encrypted MDK store (key = domain-separated SHA-256 of the identity +//! secret) and run the MLS key-package + group-creation path. Enough to prove +//! the stack builds and executes after the workspace re-layout. + +use std::path::Path; + +use mdk_core::prelude::*; +use mdk_sqlite_storage::{EncryptionConfig, MdkSqliteStorage}; +use nostr::prelude::*; +use sha2::{Digest, Sha256}; + +pub const KIND_KEY_PACKAGE: u16 = 30443; + +pub struct MarmotService { + mdk: MDK, + keys: Keys, +} + +impl MarmotService { + pub fn open(db_path: &Path, identity_secret_hex: &str) -> Result { + let keys = Keys::parse(identity_secret_hex).map_err(|_| "invalid identity secret".to_string())?; + let mut hasher = Sha256::new(); + hasher.update(b"codedeck-marmot-db-v1"); + hasher.update(keys.secret_key().as_secret_bytes()); + let db_key: [u8; 32] = hasher.finalize().into(); + let storage = MdkSqliteStorage::new_with_key(db_path, EncryptionConfig::new(db_key)) + .map_err(|e| format!("open marmot storage: {e}"))?; + Ok(Self { mdk: MDK::new(storage), keys }) + } + + pub fn key_package_event(&self, relays: &[String]) -> Result { + let relay_urls: Vec = relays.iter().filter_map(|r| RelayUrl::parse(r).ok()).collect(); + let data = self + .mdk + .create_key_package_for_event(&self.keys.public_key(), relay_urls) + .map_err(|e| format!("create key package: {e}"))?; + EventBuilder::new(Kind::Custom(KIND_KEY_PACKAGE), data.content) + .tags(data.tags_30443) + .sign_with_keys(&self.keys) + .map_err(|e| format!("sign key package: {e}")) + } + + /// Create a 1:1 group inviting `peer` via their kind-30443 event. Returns + /// the MLS group id (hex) and the number of welcome rumors produced. + pub fn create_group( + &self, + peer: &PublicKey, + peer_kp_event: Event, + relays: &[String], + ) -> Result<(String, usize, usize), String> { + let relay_urls: Vec = relays.iter().filter_map(|r| RelayUrl::parse(r).ok()).collect(); + let config = NostrGroupConfigData::new( + "CodeDeck DM".to_string(), + "probe".to_string(), + None, + None, + None, + relay_urls, + vec![self.keys.public_key(), *peer], + ); + let res = self + .mdk + .create_group(&self.keys.public_key(), vec![peer_kp_event], config) + .map_err(|e| format!("create group: {e}"))?; + let members = self + .mdk + .get_members(&res.group.mls_group_id) + .map(|m| m.len()) + .unwrap_or(0); + let gid = hex::encode(res.group.mls_group_id.as_slice()); + Ok((gid, res.welcome_rumors.len(), members)) + } +} + +/// Two fresh identities, two encrypted stores, one group. Returns a summary. +pub fn loopback(dir: &Path) -> Result { + let a_keys = Keys::generate(); + let b_keys = Keys::generate(); + let a = MarmotService::open(&dir.join("a.db"), &a_keys.secret_key().to_secret_hex())?; + let b = MarmotService::open(&dir.join("b.db"), &b_keys.secret_key().to_secret_hex())?; + + let relays = vec!["wss://relay.example".to_string()]; + let kp_a = a.key_package_event(&relays)?; + if kp_a.kind.as_u16() != KIND_KEY_PACKAGE { + return Err("key package has wrong kind".to_string()); + } + kp_a.verify().map_err(|e| format!("kp signature: {e}"))?; + + let (gid, welcomes, members) = b.create_group(&a_keys.public_key(), kp_a, &relays)?; + if welcomes == 0 { + return Err("create_group produced no welcome rumor".to_string()); + } + if members != 2 { + return Err(format!("expected 2 members, got {members}")); + } + Ok(format!("ok group={} welcomes={} members={}", &gid[..16.min(gid.len())], welcomes, members)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sqlcipher_open_and_mls_group_loopback() { + let dir = tempfile::tempdir().unwrap(); + let summary = loopback(dir.path()).expect("loopback"); + assert!(summary.starts_with("ok group="), "{summary}"); + } + + #[test] + fn store_is_actually_encrypted() { + // A SQLCipher db has no readable "SQLite format 3" header. + let dir = tempfile::tempdir().unwrap(); + let keys = Keys::generate(); + let path = dir.path().join("enc.db"); + let _ = MarmotService::open(&path, &keys.secret_key().to_secret_hex()).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.len() > 16); + assert_ne!(&bytes[0..16], b"SQLite format 3\0", "db should be SQLCipher-encrypted"); + } +} diff --git a/spike/ndk-marmot-probe/crates/client-core/uniffi-bindgen.rs b/spike/ndk-marmot-probe/crates/client-core/uniffi-bindgen.rs new file mode 100644 index 0000000..f6cff6c --- /dev/null +++ b/spike/ndk-marmot-probe/crates/client-core/uniffi-bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/spike/ndk-marmot-probe/crates/client-runtime-min/Cargo.toml b/spike/ndk-marmot-probe/crates/client-runtime-min/Cargo.toml new file mode 100644 index 0000000..5467146 --- /dev/null +++ b/spike/ndk-marmot-probe/crates/client-runtime-min/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "client-runtime-min" +edition.workspace = true +version.workspace = true +publish.workspace = true +license.workspace = true + +[lib] +name = "client_runtime_min" + +[dependencies] +# a second workspace member that depends on client-core — so feature +# unification across the workspace is genuinely exercised, not just one crate +client-core = { path = "../client-core" } diff --git a/spike/ndk-marmot-probe/crates/client-runtime-min/src/lib.rs b/spike/ndk-marmot-probe/crates/client-runtime-min/src/lib.rs new file mode 100644 index 0000000..34c8771 --- /dev/null +++ b/spike/ndk-marmot-probe/crates/client-runtime-min/src/lib.rs @@ -0,0 +1,9 @@ +//! Trivial — its only job is to be a second workspace member depending on +//! `client-core`, so the workspace's feature resolution for `rusqlite` / +//! `openssl-src` is exercised the way the real `client-runtime` will exercise it. + +pub fn re_export_check() -> bool { + // reference a client-core symbol so the dep edge is real + let _ = client_core::MarmotService::open; + true +} diff --git a/spike/ndk-marmot-probe/run.sh b/spike/ndk-marmot-probe/run.sh new file mode 100644 index 0000000..b15644f --- /dev/null +++ b/spike/ndk-marmot-probe/run.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# F0 probe-2: does the MDK/MLS + SQLCipher stack from apps/mobile/src-tauri +# survive the plan's re-layout (workspace member + cdylib + alongside uniffi), +# and still cross-compile to aarch64-linux-android? +# +# ./spike/ndk-marmot-probe/run.sh +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export MSYS_NO_PATHCONV=1 + +# host path in the form Docker wants (Windows: C:\...; elsewhere: unchanged) +if command -v cygpath >/dev/null 2>&1; then CTX="$(cygpath -w "$HERE")"; else CTX="$HERE"; fi +IMG=codedeck-ndk-probe +VOL=codedeck-ndkprobe-target + +echo "== build the lean NDK image (first run downloads NDK r28c ~600MB) ==" +docker build -t "$IMG" "$CTX" + +echo "== host: workspace tests (SQLCipher open + MLS group loopback) ==" +docker run --rm -v "${CTX}:/w" -v "${VOL}:/w/target" -w /w "$IMG" bash -c 'cargo test --workspace' + +echo "== android: cross-compile the workspace cdylib for arm64 ==" +docker run --rm -v "${CTX}:/w" -v "${VOL}:/w/target" -w /w "$IMG" bash -c ' + set -e + cargo ndk -t arm64-v8a -o ./jniLibs build --release -p client-core + echo "--- artifact ---" + find ./jniLibs -type f + file ./jniLibs/arm64-v8a/libclient_core.so + ls -la ./jniLibs/arm64-v8a/libclient_core.so +' + +echo +echo "probe-2 GREEN — see README.md" From 7ed6dff50f1cc09f1b6e6d4112d76e86b44c1714 Mon Sep 17 00:00:00 2001 From: deymosh Date: Thu, 10 Sep 2026 17:23:17 +0200 Subject: [PATCH 003/155] =?UTF-8?q?chore:=20F0=20probe-3=20scaffold=20?= =?UTF-8?q?=E2=80=94=20Rust=20relay=20core=20+=20FGS=20app=20+=20emulator?= =?UTF-8?q?=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The networking half of "sockets in a Rust core inside the foreground service survive backgrounding, where WebView sockets do not": - rust/heartbeat-core: a real tokio-tungstenite Nostr client that subscribes to kind-30515, counts deliveries, reconnects with backoff. Host tests (in-process WS relay) cover delivery counting + reconnect-after-drop. Consumed on Android via UniFFI (the probe-1 pattern). - android/: minimal native app — a dataSync foreground service owns the Rust core; MainActivity shows received / last-heartbeat / reconnects. - pulse-relay bin: WS stand-in for a bridge emitting 30515 every 15s. - build.sh: one Docker image (Rust+NDK+cargo-ndk + JDK+Android SDK) does cargo test -> .so (x86_64+arm64) -> uniffi bindgen -> APK. - run-emulator.sh: install + run the background matrix (baseline, HOME, screen-off, forced Doze, airplane blip) and report DELIVERING/STALLED. Notes: probe pins compileSdk 36 (API-37 platform ships SDK XML v4 the Dockerized AGP 8.11.1 sdklib cannot parse — a native Studio build handles 37; the real apps/android still targets 37). A stock emulator is lenient and will NOT reproduce OEM background kills — emulator GREEN is necessary, not sufficient; a real Samsung + Xiaomi pass remains a hard F1 gate. Co-Authored-By: Claude Sonnet 5 --- spike/background-probe/.gitignore | 11 + spike/background-probe/Dockerfile | 29 +++ .../android/app/build.gradle.kts | 43 ++++ .../android/app/src/main/AndroidManifest.xml | 30 +++ .../com/codedeck/bgprobe/MainActivity.kt | 90 +++++++ .../com/codedeck/bgprobe/RelayService.kt | 101 ++++++++ .../app/src/main/res/layout/activity_main.xml | 34 +++ .../background-probe/android/build.gradle.kts | 4 + .../android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + spike/background-probe/android/gradlew | 185 +++++++++++++++ spike/background-probe/android/gradlew.bat | 89 +++++++ .../android/settings.gradle.kts | 8 + spike/background-probe/build.sh | 42 ++++ spike/background-probe/run-emulator.sh | 72 ++++++ .../rust/heartbeat-core/Cargo.toml | 35 +++ .../heartbeat-core/src/bin/pulse_relay.rs | 52 ++++ .../rust/heartbeat-core/src/lib.rs | 223 ++++++++++++++++++ .../rust/heartbeat-core/tests/native.rs | 106 +++++++++ .../rust/heartbeat-core/uniffi-bindgen.rs | 3 + 20 files changed, 1168 insertions(+) create mode 100644 spike/background-probe/.gitignore create mode 100644 spike/background-probe/Dockerfile create mode 100644 spike/background-probe/android/app/build.gradle.kts create mode 100644 spike/background-probe/android/app/src/main/AndroidManifest.xml create mode 100644 spike/background-probe/android/app/src/main/kotlin/com/codedeck/bgprobe/MainActivity.kt create mode 100644 spike/background-probe/android/app/src/main/kotlin/com/codedeck/bgprobe/RelayService.kt create mode 100644 spike/background-probe/android/app/src/main/res/layout/activity_main.xml create mode 100644 spike/background-probe/android/build.gradle.kts create mode 100644 spike/background-probe/android/gradle.properties create mode 100644 spike/background-probe/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 spike/background-probe/android/gradlew create mode 100644 spike/background-probe/android/gradlew.bat create mode 100644 spike/background-probe/android/settings.gradle.kts create mode 100644 spike/background-probe/build.sh create mode 100644 spike/background-probe/run-emulator.sh create mode 100644 spike/background-probe/rust/heartbeat-core/Cargo.toml create mode 100644 spike/background-probe/rust/heartbeat-core/src/bin/pulse_relay.rs create mode 100644 spike/background-probe/rust/heartbeat-core/src/lib.rs create mode 100644 spike/background-probe/rust/heartbeat-core/tests/native.rs create mode 100644 spike/background-probe/rust/heartbeat-core/uniffi-bindgen.rs diff --git a/spike/background-probe/.gitignore b/spike/background-probe/.gitignore new file mode 100644 index 0000000..50a0c64 --- /dev/null +++ b/spike/background-probe/.gitignore @@ -0,0 +1,11 @@ +target/ +Cargo.lock +android/.gradle/ +android/build/ +android/app/build/ +android/local.properties +android/gradle/wrapper/gradle-wrapper.jar +android/app/src/main/jniLibs/ +android/app/src/main/kotlin/uniffi/ +*.apk +artifacts/ diff --git a/spike/background-probe/Dockerfile b/spike/background-probe/Dockerfile new file mode 100644 index 0000000..70f5abd --- /dev/null +++ b/spike/background-probe/Dockerfile @@ -0,0 +1,29 @@ +# One image for the whole bgprobe build: Rust + NDK r28c + cargo-ndk (both +# android ABIs), plus JDK 17 + Android cmdline-tools so Gradle can assemble the +# APK. `platforms;android-37` is bind-mounted from the host at build time +# (android.jar is pure Java — mounting the host copy is fine); everything else +# is installed here for Linux. +FROM rust:1-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + unzip curl file openjdk-17-jdk-headless \ + && rm -rf /var/lib/apt/lists/* + +# --- Rust / Android cross-compile --- +ARG NDK=android-ndk-r28c +RUN curl -fsSL -o /tmp/ndk.zip "https://dl.google.com/android/repository/${NDK}-linux.zip" \ + && unzip -q /tmp/ndk.zip -d /opt && mv "/opt/${NDK}" /opt/ndk && rm /tmp/ndk.zip +ENV ANDROID_NDK_HOME=/opt/ndk +RUN rustup target add aarch64-linux-android x86_64-linux-android \ + && cargo install cargo-ndk --locked + +# --- Android SDK (Gradle) --- +ENV ANDROID_HOME=/opt/android-sdk +ENV ANDROID_SDK_ROOT=/opt/android-sdk +RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \ + && curl -fsSL -o /tmp/clt.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \ + && unzip -q /tmp/clt.zip -d "$ANDROID_HOME/cmdline-tools" && rm /tmp/clt.zip \ + && mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" +ENV PATH="$PATH:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools" +RUN yes | sdkmanager --licenses >/dev/null \ + && sdkmanager "platform-tools" "build-tools;36.0.0" "platforms;android-36" >/dev/null diff --git a/spike/background-probe/android/app/build.gradle.kts b/spike/background-probe/android/app/build.gradle.kts new file mode 100644 index 0000000..5212fd7 --- /dev/null +++ b/spike/background-probe/android/app/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.codedeck.bgprobe" + // The real apps/android targets SDK 37 (maintainer's choice). This probe + // pins 36: the API-37 platform ships SDK XML v4, which the sdklib bundled in + // the Dockerized AGP 8.11.1 cannot parse ("Failed to find target android-37"). + // A native Android Studio build (matching newer sdklib) handles 37 fine. + // The probe measures background delivery, not the SDK level. + compileSdk = 36 + + defaultConfig { + applicationId = "com.codedeck.bgprobe" + minSdk = 34 // typed FOREGROUND_SERVICE_DATA_SYNC + targetSdk = 36 + versionCode = 1 + versionName = "0.0" + // relay the probe connects to; 10.0.2.2 = host loopback from the emulator + buildConfigField("String", "RELAY_URL", "\"ws://10.0.2.2:7447\"") + } + buildFeatures { buildConfig = true; viewBinding = true } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } + + // libheartbeat_core.so is produced by ../build.sh (cargo-ndk) into here + sourceSets["main"].jniLibs.srcDir("src/main/jniLibs") + // uniffi-bindgen output also lands under src/main/kotlin +} + +dependencies { + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + // UniFFI-generated Kotlin needs JNA — the @aar variant on Android + implementation("net.java.dev.jna:jna:5.15.0@aar") +} diff --git a/spike/background-probe/android/app/src/main/AndroidManifest.xml b/spike/background-probe/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5eff81d --- /dev/null +++ b/spike/background-probe/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/spike/background-probe/android/app/src/main/kotlin/com/codedeck/bgprobe/MainActivity.kt b/spike/background-probe/android/app/src/main/kotlin/com/codedeck/bgprobe/MainActivity.kt new file mode 100644 index 0000000..6757c4d --- /dev/null +++ b/spike/background-probe/android/app/src/main/kotlin/com/codedeck/bgprobe/MainActivity.kt @@ -0,0 +1,90 @@ +package com.codedeck.bgprobe + +import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import com.codedeck.bgprobe.databinding.ActivityMainBinding + +/** + * Minimal dashboard: starts/stops the service and shows the running delivery + * count + seconds since the last heartbeat. `adb logcat -s bgprobe` has the + * full stream for the background matrix. + */ +class MainActivity : AppCompatActivity() { + + private lateinit var b: ActivityMainBinding + private var lastMs = 0L + private var received = 0L + private var reconnects = 0L + private var connected = false + private var startedMs = 0L + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(c: Context?, i: Intent?) { + i ?: return + received = i.getLongExtra("received", received) + lastMs = i.getLongExtra("last", lastMs) + reconnects = i.getLongExtra("reconnects", reconnects) + connected = i.getBooleanExtra("connected", connected) + startedMs = i.getLongExtra("started", startedMs) + render() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + b = ActivityMainBinding.inflate(layoutInflater) + setContentView(b.root) + + b.start.setOnClickListener { + maybeAskNotifications() + startService(Intent(this, RelayService::class.java)) + } + b.startWl.setOnClickListener { + maybeAskNotifications() + startService(Intent(this, RelayService::class.java).putExtra(RelayService.EXTRA_WAKELOCK, true)) + } + b.stop.setOnClickListener { stopService(Intent(this, RelayService::class.java)) } + render() + } + + override fun onResume() { + super.onResume() + val flags = if (Build.VERSION.SDK_INT >= 33) Context.RECEIVER_NOT_EXPORTED else 0 + registerReceiver(receiver, IntentFilter(RelayService.ACTION_STATS), flags) + render() + } + + override fun onPause() { + super.onPause() + runCatching { unregisterReceiver(receiver) } + } + + private fun render() { + val now = System.currentTimeMillis() + val sinceHb = if (lastMs == 0L) "never" else "${(now - lastMs) / 1000}s ago" + val uptime = if (startedMs == 0L) "-" else "${(now - startedMs) / 1000}s" + b.status.text = buildString { + append("connected: $connected\n") + append("heartbeats received: $received\n") + append("last heartbeat: $sinceHb\n") + append("reconnects: $reconnects\n") + append("probe uptime: $uptime\n") + } + } + + private fun maybeAskNotifications() { + if (Build.VERSION.SDK_INT >= 33 && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1) + } + } +} diff --git a/spike/background-probe/android/app/src/main/kotlin/com/codedeck/bgprobe/RelayService.kt b/spike/background-probe/android/app/src/main/kotlin/com/codedeck/bgprobe/RelayService.kt new file mode 100644 index 0000000..6b84628 --- /dev/null +++ b/spike/background-probe/android/app/src/main/kotlin/com/codedeck/bgprobe/RelayService.kt @@ -0,0 +1,101 @@ +package com.codedeck.bgprobe + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import android.util.Log +import androidx.core.app.NotificationCompat +import uniffi.heartbeat_core.HeartbeatProbe +import uniffi.heartbeat_core.ProbeEvent +import uniffi.heartbeat_core.ProbeListener + +/** + * The probe's whole point: the Nostr socket lives HERE, in a Rust core owned by + * this foreground service — not in a WebView. If deliveries keep arriving while + * the app is backgrounded / Dozed, the plan's F1 thesis holds. + */ +class RelayService : Service() { + + private var probe: HeartbeatProbe? = null + private var wakeLock: PowerManager.WakeLock? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground(NOTIF_ID, notification("starting…")) + + if (probe == null) { + if (intent?.getBooleanExtra(EXTRA_WAKELOCK, false) == true) { + val pm = getSystemService(POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "bgprobe:wl").apply { acquire() } + Log.i(TAG, "partial wakelock acquired") + } + val relay = intent?.getStringExtra(EXTRA_RELAY) ?: BuildConfig.RELAY_URL + val p = HeartbeatProbe() + p.subscribe(object : ProbeListener { + override fun onEvent(event: ProbeEvent) { + val s = p.stats() + updateNotification( + "HB ${s.received} · reconn ${s.reconnects} · " + + if (s.connected) "connected" else "offline" + ) + sendBroadcast( + Intent(ACTION_STATS).setPackage(packageName) + .putExtra("received", s.received.toLong()) + .putExtra("last", s.lastHeartbeatMs) + .putExtra("reconnects", s.reconnects.toLong()) + .putExtra("connected", s.connected) + .putExtra("started", s.startedMs) + ) + Log.i(TAG, "event=$event stats=$s") + } + }) + Log.i(TAG, "starting probe against $relay") + p.start(relay, "aa", "bb") + probe = p + } + return START_STICKY + } + + override fun onDestroy() { + Log.i(TAG, "onDestroy — stopping probe") + probe?.stop() + probe = null + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun notification(text: String): Notification { + val nm = getSystemService(NotificationManager::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + nm.createNotificationChannel( + NotificationChannel(CHANNEL, "bgprobe", NotificationManager.IMPORTANCE_LOW) + ) + } + return NotificationCompat.Builder(this, CHANNEL) + .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) + .setContentTitle("bgprobe") + .setContentText(text) + .setOngoing(true) + .build() + } + + private fun updateNotification(text: String) { + getSystemService(NotificationManager::class.java).notify(NOTIF_ID, notification(text)) + } + + companion object { + const val TAG = "bgprobe" + const val CHANNEL = "bgprobe" + const val NOTIF_ID = 1 + const val ACTION_STATS = "com.codedeck.bgprobe.STATS" + const val EXTRA_RELAY = "relay" + const val EXTRA_WAKELOCK = "wakelock" + } +} diff --git a/spike/background-probe/android/app/src/main/res/layout/activity_main.xml b/spike/background-probe/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..712447b --- /dev/null +++ b/spike/background-probe/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,34 @@ + + + + + +