diff --git a/.github/workflows/c-bindings.yml b/.github/workflows/c-bindings.yml new file mode 100644 index 00000000..9872a194 --- /dev/null +++ b/.github/workflows/c-bindings.yml @@ -0,0 +1,156 @@ +name: Marmot C Bindings + +on: + push: + tags: + - marmotc-v* + +permissions: + contents: read + +concurrency: + group: marmot-c-bindings-${{ github.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_RELEASE_DEBUG: "0" + +jobs: + linux-bindings: + name: Build Linux C bindings + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Check out repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Validate release tag + shell: bash + run: | + set -euo pipefail + if [[ ! "$GITHUB_REF_NAME" =~ ^marmotc-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "error: C binding releases must use a version tag like marmotc-v0.1.0" >&2 + exit 1 + fi + + - name: Install Rust toolchain + shell: bash + run: | + set -euo pipefail + rustup show active-toolchain || rustup toolchain install + + - name: Cache Cargo artifacts + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + # Release artifacts are published to a public GitHub Release, so + # never write (or reuse a poisoned) cache on this tag-triggered path. + save-if: false + + - name: Build C bindings + shell: bash + run: ./crates/marmot-c/c-bindings.sh + + - name: Smoke-test against gcc and clang + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y valgrind + ./crates/marmot-c/c-smoke.sh gcc clang + + - name: Package bundle + shell: bash + run: | + set -euo pipefail + tag="$GITHUB_REF_NAME" + version="${tag#marmotc-v}" + workspace_version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)" + lock_sha="$(sha256sum Cargo.lock | awk '{print $1}')" + + stage_parent="build/marmot-c-stage" + stage="$stage_parent/marmot-c-$version" + asset="marmot-c-linux-x86_64-$version.zip" + rm -rf "$stage_parent" "$asset" "$asset.sha256" + mkdir -p "$stage" dist + + cp -R crates/marmot-c/output/. "$stage/" + cp crates/marmot-c/README.md "$stage/" + cat > "$stage/manifest.json" < "$asset.sha256" + cp "$asset" "$asset.sha256" dist/ + + - name: Upload bundle + uses: actions/upload-artifact@v4 + with: + name: marmot-c-linux + path: dist/ + if-no-files-found: error + + release: + name: Create GitHub release + runs-on: ubuntu-latest + needs: + - linux-bindings + timeout-minutes: 15 + permissions: + actions: read + contents: write + + steps: + - name: Download binding artifacts + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir -p dist + gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name marmot-c-linux --dir dist + + - name: Create or update release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + tag="$GITHUB_REF_NAME" + version="${tag#marmotc-v}" + notes="$RUNNER_TEMP/release-notes.md" + cat > "$notes" </dev/null 2>&1; then + gh release upload "$tag" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber + gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --title "v$version - Marmot C" --notes-file "$notes" + else + gh release create "$tag" "${assets[@]}" \ + --repo "$GITHUB_REPOSITORY" \ + --title "v$version - Marmot C" \ + --notes-file "$notes" + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5965e92b..e4ecf353 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,49 @@ jobs: - name: Run clippy with all features run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + c-smoke: + name: C bindings smoke + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Check out repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Install Rust toolchain + run: | + set -euo pipefail + toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml)" + rustup toolchain install "$toolchain" --profile minimal + rustup default "$toolchain" + + - name: Cache Cargo artifacts + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Header diff gate + run: | + set -euo pipefail + # Pin the exact version so a newer cbindgen can't reformat the + # checked-in header and fail the diff gate on unrelated PRs. + cargo install cbindgen --version 0.29.4 --locked + cbindgen --config crates/marmot-c/cbindgen.toml --crate marmot-c \ + --output crates/marmot-c/include/marmot.h crates/marmot-c + git diff --exit-code crates/marmot-c/include/marmot.h + + - name: Alloc-audit tests + run: cargo test -p marmot-c --features alloc-audit --locked + + - name: Compile and run C smoke test (gcc + clang, valgrind) + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y valgrind clang + # Also compiles and runs the Zig and Odin consumers when those + # toolchains are present; the script skips them otherwise. + ./crates/marmot-c/c-smoke.sh gcc clang + rust-test: name: Rust tests (${{ matrix.partition }}/4) runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 7a299783..6d8ee4ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ This repo owns the Rust implementation workspace for Marmot: - app message Markdown display parser, - shared JSONL forensic audit schema, - UniFFI bindings for the app runtime, +- C ABI bindings for the app runtime, - CLI surface, daemon, and TUI, - conformance simulator and vector fixtures, - Tamarin models for distributed convergence, @@ -49,6 +50,7 @@ The canonical protocol specification lives in | Host integrations / connector coexistence | `integrations/AGENTS.md` | | Forensic audit schema | `crates/marmot-forensics/AGENTS.md` | | App runtime UniFFI bindings | `crates/marmot-uniffi/AGENTS.md` | +| App runtime C ABI bindings | `crates/marmot-c/AGENTS.md` | | CLI / daemon / TUI surface | `crates/cli/AGENTS.md` | | Multi-client harness / vectors | `crates/cgka-conformance-simulator/AGENTS.md` | | Architecture docs | `docs/AGENTS.md` and `docs/marmot-architecture/AGENTS.md` | diff --git a/Cargo.lock b/Cargo.lock index 6f8e3c75..eeb99bd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2605,6 +2605,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "marmot-c" +version = "0.9.3" +dependencies = [ + "libc", + "marmot-uniffi", + "tempfile", + "tokio", +] + [[package]] name = "marmot-forensics" version = "0.9.3" diff --git a/Cargo.toml b/Cargo.toml index 7424fe4c..0c78535d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/marmot-account", "crates/marmot-app", "crates/marmot-uniffi", + "crates/marmot-c", "crates/cli", "integrations/opencode/marmot", ] @@ -113,4 +114,5 @@ marmot-markdown = { path = "crates/marmot-markdown" } marmot-account = { path = "crates/marmot-account" } marmot-app = { path = "crates/marmot-app" } marmot-uniffi = { path = "crates/marmot-uniffi" } +marmot-c = { path = "crates/marmot-c" } wn-cli = { path = "crates/cli" } diff --git a/Justfile b/Justfile index 4c114620..020a743b 100644 --- a/Justfile +++ b/Justfile @@ -265,6 +265,14 @@ dead-code-audit: naming-gate: ./scripts/check_legacy_naming.sh +# Regenerate the checked-in C header from crates/marmot-c (CI diff-gates it). +c-header: + cbindgen --config crates/marmot-c/cbindgen.toml --crate marmot-c --output crates/marmot-c/include/marmot.h crates/marmot-c + +# Build marmot-c and compile+run the C smoke test (valgrind when available). +c-smoke: + ./crates/marmot-c/c-smoke.sh + # Fast local pre-push gate: mechanical/static checks only. GitHub CI runs the # full `just ci` suite (including the workspace test matrix). fast-ci: fmt-check naming-gate check clippy diff --git a/crates/marmot-c/.gitignore b/crates/marmot-c/.gitignore new file mode 100644 index 00000000..2ffbe8e4 --- /dev/null +++ b/crates/marmot-c/.gitignore @@ -0,0 +1,5 @@ +# Generated/derived build artifacts. c-bindings.sh writes into output/ and +# c-smoke.sh writes into build/. The checked-in include/marmot.h header is the +# source of truth for the C surface (CI diff-gates it); nothing else here is. +/build/ +/output/ diff --git a/crates/marmot-c/AGENTS.md b/crates/marmot-c/AGENTS.md new file mode 100644 index 00000000..d6b53431 --- /dev/null +++ b/crates/marmot-c/AGENTS.md @@ -0,0 +1,51 @@ +# AGENTS.md - marmot-c + +C ABI bindings for the Marmot app runtime. Read `README.md` first for build, linking, and packaging. + +## Scope + +- Own the stable C ABI over `marmot-uniffi` for C/C++, Zig, Odin, and raw-FFI consumers (Go, Lua, …). +- Own `include/marmot.h` (generated by cbindgen, checked in) and `cbindgen.toml`. +- Own `c-bindings.sh` (cdylib + staticlib + header + pkg-config staging into `output/`) and the `examples/` consumers + (`smoke.{c,zig,nim,go,odin,lua,php}`) driven by `c-smoke.sh`. + +## Known gaps + +- External-signer accounts (`register_external_signer` / `login_external_signer`) are not yet exposed. They take a + host-implemented `ExternalAccountSignerFfi` trait (public key, sign event, NIP-04 encrypt/decrypt). A C mapping needs a + callback vtable (function pointers + `user_data`) invoked from runtime worker threads, with owned-string return and + error signalling across the boundary. That deserves its own focused design + tests rather than a rushed vtable. + +## Architecture + +- `marmot-uniffi` is the single source of truth for FFI DTO shapes and the runtime object; this crate never talks to + `marmot-app` directly. Every `…Ffi` record/enum has a `#[repr(C)]` mirror in `src/types/` (one module per + `marmot-uniffi` conversions module) built by `From<…Ffi>` and released by a `CFree` deep-free. +- `src/commands/` holds the `extern "C"` wrappers (one module per `marmot-uniffi` commands module): `ffi_guard` + (catch_unwind), borrowed-input readers, `block_on` onto the embedded tokio runtime, mirror conversion, out-pointer. +- `src/subscriptions.rs` wraps the 8 subscription objects as opaque handles with blocking `*_next` (timeout → + `MARMOT_STATUS_TIMEOUT`, shutdown → `MARMOT_STATUS_CLOSED`) and optional callback registration. +- `src/memory.rs` is the only allocation chokepoint; the `alloc-audit` test feature counts live allocations so tests + can prove deep-free completeness. + +## Invariants + +- No struct crossing the ABI is freed anywhere except its own `marmot_*_free`; frees are deep and NULL-tolerant; + input structs and strings are borrowed and never freed or retained. +- Every `extern "C"` body is wrapped in `ffi_guard` — panics must not unwind into C. +- `MarmotStatus` values are stable ABI: append, never renumber. Keep the variant set in lockstep with + `marmot_uniffi::MarmotKitError`. +- `include/marmot.h` is generated, never hand-edited; regenerate with `just c-header`. CI diff-gates it. +- Host-supplied `group_id_hex` values are variable-length MLS `GroupId` bytes — do not validate them with the + 32-byte route-id/pubkey/message-id rule. +- No `tracing`/logging in this crate; error detail strings carry only what `MarmotKitError` Display already exposes. +- Keep this crate in lockstep with `marmot-uniffi` surface changes; bump the workspace version when mirrors, + commands, status codes, or callback signatures change. + +## Verification + +```sh +cargo test -p marmot-c --features alloc-audit +just c-header && git diff --exit-code crates/marmot-c/include/marmot.h +just c-smoke # compiles and runs examples/smoke.c (valgrind when available) +``` diff --git a/crates/marmot-c/CHANGELOG.md b/crates/marmot-c/CHANGELOG.md new file mode 100644 index 00000000..072adc38 --- /dev/null +++ b/crates/marmot-c/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to `marmot-c` are tracked here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This crate uses semantic +versioning through the workspace version in the root `Cargo.toml`. + +## [0.9.3] - 2026-07-08 + +### Added + +- Added `marmot-c`, a stable C ABI over the Marmot app runtime for consumers that cannot pull in a UniFFI runtime + (C/C++, and raw-FFI callers such as Zig, Nim, Go, Odin, Lua, and PHP). The crate builds a `cdylib` and a `staticlib`, + ships a cbindgen-generated, checked-in `include/marmot.h`, and mirrors the full `marmot-uniffi` surface: account and + session lifecycle, group operations, messaging, media, timeline, notifications, push, audit, relay health, agent text + streams, and Markdown parsing. +- Every runtime record/enum has a `#[repr(C)]` mirror built from the `marmot-uniffi` `…Ffi` types, so the C shape can + never drift from the Swift/Kotlin surface. Complex values cross the ABI as owned pointers freed only by their matching + `marmot_*_free`; inputs are borrowed and never retained. Async runtime work runs on an embedded tokio runtime via + blocking calls, and subscriptions expose both blocking `*_next` reads and callback registration. +- Added memory-safety scaffolding: an `alloc-audit` test feature that proves deep-free completeness, `catch_unwind` at + every ABI boundary so panics never unwind into C, and a typed `MarmotStatus` code per `MarmotKitError` variant with a + thread-local last-error detail channel. +- Added `marmot_download_group_blossom_image` (fetch + verify + decrypt the group's encrypted Blossom image to an owned + byte buffer freed with `marmot_bytes_free`), completing the group-image surface alongside `image_hash_hex`. External + signer accounts remain a documented gap pending a C callback-vtable design. +- Added packaging and cross-language verification: `c-bindings.sh` (staged libraries + header + pkg-config), + `c-smoke.sh`, and seven worked example consumers (`examples/smoke.{c,zig,nim,go,odin,lua,php}`) exercised in CI, plus a + `marmotc-v*` release track titled `v - Marmot C`. + +Closes [#328](https://github.com/marmot-protocol/mdk/issues/328) diff --git a/crates/marmot-c/CLAUDE.md b/crates/marmot-c/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/crates/marmot-c/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/crates/marmot-c/Cargo.toml b/crates/marmot-c/Cargo.toml new file mode 100644 index 00000000..2fb030ca --- /dev/null +++ b/crates/marmot-c/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "marmot-c" +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +name = "marmot_c" +crate-type = ["cdylib", "staticlib", "lib"] + +[features] +default = [] +# Test-only accounting of every FFI allocation/free pair; used by the +# roundtrip tests to prove deep-free completeness. Never enable in release +# artifacts. +alloc-audit = [] + +[dependencies] +marmot-uniffi.workspace = true +tokio = { workspace = true, features = ["io-util", "net", "time"] } +libc.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/marmot-c/README.md b/crates/marmot-c/README.md new file mode 100644 index 00000000..a8a21d60 --- /dev/null +++ b/crates/marmot-c/README.md @@ -0,0 +1,110 @@ +# marmot-c + +Stable C ABI for the Marmot app runtime. Exposes the same surface as the UniFFI bindings +(`crates/marmot-uniffi` — accounts, groups, messaging, media, timeline, notifications, push, +audit, relay health, agent text streams, markdown parsing) to consumers that cannot pull in a +UniFFI runtime: C/C++ applications, Zig, Odin, and raw FFI from Go, Lua, and friends. + +## Build + +```sh +# Library + checked-in header +cargo build -p marmot-c --release +# → target/release/libmarmot_c.so (cdylib) and libmarmot_c.a (staticlib) +# → header at crates/marmot-c/include/marmot.h + +# Full packaged artifact set (libs + header + pkg-config) into output/ +./crates/marmot-c/c-bindings.sh +``` + +## Quick start + +```c +#include + +const char *relays[] = { "wss://relay.example.org" }; +MarmotClient *client = NULL; +MarmotStatus st = marmot_client_new("/home/me/.marmot", relays, 1, &client); +if (st != MARMOT_STATUS_OK) { + char *msg = marmot_last_error_message(); + fprintf(stderr, "marmot: %s\n", msg ? msg : "(no detail)"); + marmot_string_free(msg); + return 1; +} +marmot_client_start(client); +/* ... marmot_send_text(client, ...), subscriptions, ... */ +marmot_client_shutdown(client); +marmot_client_free(client); +``` + +Rules of the road (also in the header comment): + +- Fallible calls return `MarmotStatus`; `MARMOT_STATUS_OK` is `0`. Detail text for the calling + thread's most recent failure: `marmot_last_error_message()` (free with `marmot_string_free`). +- Returned structs are freed **only** with their matching `marmot_*_free`, which deep-frees every + field. Never free fields individually, never free twice. Inputs are always borrowed. +- Async runtime work happens on an embedded tokio runtime; calls block the calling thread. + Subscriptions offer blocking `*_next` with a timeout, or callback registration (callbacks run on + runtime worker threads; item pointers are valid only during the call). + +## Linking + +pkg-config (after `c-bindings.sh`, or point `PKG_CONFIG_PATH` at `output/`): + +```sh +cc app.c $(pkg-config --cflags --libs marmot-c) +``` + +Minimal CMake: + +```cmake +find_library(MARMOT_C marmot_c PATHS ${MARMOT_OUTPUT_DIR}) +target_include_directories(app PRIVATE ${MARMOT_OUTPUT_DIR}/include) +target_link_libraries(app PRIVATE ${MARMOT_C}) +``` + +The static library additionally needs the usual Rust system deps (`-lm -lpthread -ldl` on Linux). + +## Examples + +`examples/` holds one worked end-to-end consumer per language. Each drives a +realistic slice of the runtime — argument validation and NULL-free discipline, +client lifecycle, markdown parsing, offline account/directory reads, the typed +error taxonomy (`UNKNOWN_ACCOUNT`), and best-effort identity creation — behind +an abstraction idiomatic to its language (real error values/exceptions and the +language's cleanup idiom, with the out-parameter plumbing hidden): + +- `smoke.c` — C11, static archive. Also walks the recursive Markdown DTO tree + (tagged-union blocks/inlines) — the reference for that navigation. +- `smoke.zig` — Zig `@cImport`, static archive; `Marmot` struct + error unions; + deep markdown walk. +- `smoke.nim` — Nim `importc`, static archive; object + `MarmotError` + `=destroy` + RAII; deep markdown walk. +- `smoke.go` — Go cgo, static archive; wrapper type returning `error`; markdown + at document level (cgo's anonymous-union access is unergonomic). +- `smoke.odin` — Odin hand-declared `foreign`, shared library; `Marmot` struct + + `or_return`; document-level markdown. +- `smoke.lua` — LuaJIT `ffi`, shared library; metatable object, `ffi.gc` cleanup, + `pcall`-catchable errors; document-level markdown. +- `smoke.php` — PHP FFI, shared library; `Marmot` class with exceptions, destructor + cleanup, and a `@method`-typed FFI view for static analysis; document-level + markdown. + +The deep tagged-union walk lives in the header-import languages (C, Zig, Nim) +where the compiler guarantees the union layout; hand-declared consumers keep +markdown at the document level rather than risk transcribing the recursive +layout by hand. + +`c-smoke.sh` builds and runs every one of these (C under valgrind when +available); each language is skipped if its toolchain is absent. Run the whole +set with `just c-smoke`. Between them they cover both linkage models (static +archive and shared object) and both binding styles (header import and +hand-declared decls) — the raw-FFI matrix a stable C ABI has to satisfy. + +## Notes + +- The account store uses the platform keychain (same as the UniFFI constructor). Headless servers + without a secret service will fail account creation; a non-keychain store is a planned follow-up. +- `group_id_hex` parameters are opaque variable-length MLS group ids (hex), not 32-byte Nostr + route ids. +- Header regeneration: `just c-header` (cbindgen). The header is checked in and CI-gated. diff --git a/crates/marmot-c/c-bindings.sh b/crates/marmot-c/c-bindings.sh new file mode 100755 index 00000000..5c32762d --- /dev/null +++ b/crates/marmot-c/c-bindings.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Build and stage the marmot-c artifact set. +# +# Outputs (under /output/): +# lib/libmarmot_c.so (cdylib, host target) +# lib/libmarmot_c.a (staticlib, host target) +# include/marmot.h (checked-in cbindgen header, copied) +# lib/pkgconfig/marmot-c.pc + +set -euo pipefail + +export PATH="$HOME/.cargo/bin:$PATH" + +CRATE_DIR="$(cd "$(dirname "$0")" && pwd)" +WORKSPACE_DIR="$(cd "$CRATE_DIR/../.." && pwd)" +TARGET_DIR="$WORKSPACE_DIR/target" +OUT_DIR="$CRATE_DIR/output" + +CRATE_NAME="marmot-c" +LIB_BASENAME="marmot_c" + +cd "$WORKSPACE_DIR" + +echo "==> Cleaning previous output" +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR/lib/pkgconfig" "$OUT_DIR/include" + +echo "==> Building release cdylib + staticlib" +cargo build --release -p "$CRATE_NAME" + +case "$(uname -s)" in + Darwin) DYLIB_EXT="dylib" ;; + *) DYLIB_EXT="so" ;; +esac + +cp "$TARGET_DIR/release/lib$LIB_BASENAME.$DYLIB_EXT" "$OUT_DIR/lib/" +cp "$TARGET_DIR/release/lib$LIB_BASENAME.a" "$OUT_DIR/lib/" +cp "$CRATE_DIR/include/marmot.h" "$OUT_DIR/include/" + +echo "==> Generating pkg-config file" +version="$(sed -n 's/^version = "\(.*\)"/\1/p' "$WORKSPACE_DIR/Cargo.toml" | head -n 1)" +sed -e "s|@VERSION@|$version|" "$CRATE_DIR/marmot-c.pc.in" > "$OUT_DIR/lib/pkgconfig/marmot-c.pc" + +echo "==> Done: $OUT_DIR" diff --git a/crates/marmot-c/c-smoke.sh b/crates/marmot-c/c-smoke.sh new file mode 100755 index 00000000..00fd7791 --- /dev/null +++ b/crates/marmot-c/c-smoke.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Compile and run the C smoke test against the freshly built staticlib. +# Runs under valgrind when available (CI installs it; locally optional). +# +# Usage: ./crates/marmot-c/c-smoke.sh [CC...] +# CC list defaults to "cc"; CI passes "gcc clang" to cover both. + +set -euo pipefail + +export PATH="$HOME/.cargo/bin:$PATH" + +CRATE_DIR="$(cd "$(dirname "$0")" && pwd)" +WORKSPACE_DIR="$(cd "$CRATE_DIR/../.." && pwd)" +TARGET_DIR="$WORKSPACE_DIR/target" +BUILD_DIR="$CRATE_DIR/build" + +COMPILERS=("${@:-cc}") + +cd "$WORKSPACE_DIR" + +echo "==> Building marmot-c staticlib (debug, faster + assert-friendly)" +cargo build -p marmot-c + +mkdir -p "$BUILD_DIR" + +for compiler in "${COMPILERS[@]}"; do + if ! command -v "$compiler" >/dev/null 2>&1; then + echo "==> Skipping $compiler (not installed)" + continue + fi + bin="$BUILD_DIR/smoke-$compiler" + echo "==> Compiling smoke.c with $compiler" + "$compiler" -std=c11 -Wall -Wextra -Werror \ + -I "$CRATE_DIR/include" \ + "$CRATE_DIR/examples/smoke.c" \ + "$TARGET_DIR/debug/libmarmot_c.a" \ + -lm -lpthread -ldl \ + -o "$bin" + + smoke_home="$(mktemp -d)" + trap 'rm -rf "$smoke_home"' EXIT + ran_under_valgrind=0 + if command -v valgrind >/dev/null 2>&1; then + # valgrind needs a redirectable ld.so (glibc debuginfo). On hosts that + # ship a stripped dynamic linker it aborts at startup (exit 1) before + # running the program; that is an infra gap, not a leak. Probe once and + # fall back to a direct run so a local box never reports a false failure. + # `--error-exitcode=42` makes an actual leak/error distinguishable from + # a clean run (0) or a startup abort (non-0, non-42). + echo "==> Running smoke test under valgrind ($compiler)" + set +e + valgrind --leak-check=full --errors-for-leak-kinds=definite \ + --error-exitcode=42 "$bin" "$smoke_home" + vg_status=$? + set -e + if [[ "$vg_status" == "42" ]]; then + echo "valgrind reported a definite leak or memory error" >&2 + exit 1 + elif [[ "$vg_status" == "0" ]]; then + ran_under_valgrind=1 + else + echo "==> valgrind could not start (exit $vg_status); running directly" >&2 + rm -rf "$smoke_home" + smoke_home="$(mktemp -d)" + fi + fi + if [[ "$ran_under_valgrind" == "0" ]]; then + echo "==> Running smoke test ($compiler, direct)" + "$bin" "$smoke_home" + fi + rm -rf "$smoke_home" + trap - EXIT +done + +# Optional cross-language consumers. These prove a non-C, non-cbindgen FFI +# caller (Zig via @cImport, Odin via hand-declared foreign bindings) drives +# the same ABI correctly — exactly the multi-language coverage the issue +# asked for. Each is skipped when its toolchain is absent. +STATIC_LIB="$TARGET_DIR/debug/libmarmot_c.a" + +if command -v zig >/dev/null 2>&1; then + echo "==> Compiling smoke.zig" + zig_bin="$BUILD_DIR/smoke-zig" + # -fllvm: standard ELF the loader accepts. -lunwind: the `_Unwind_*` + # symbols the Rust staticlib needs (gcc/clang link these implicitly). + zig build-exe "$CRATE_DIR/examples/smoke.zig" \ + -I "$CRATE_DIR/include" -lc -fllvm \ + "$STATIC_LIB" -lm -lpthread -ldl -lunwind \ + -femit-bin="$zig_bin" + smoke_home="$(mktemp -d)" + trap 'rm -rf "$smoke_home"' EXIT + echo "==> Running smoke.zig" + "$zig_bin" "$smoke_home" + rm -rf "$smoke_home" + trap - EXIT +else + echo "==> Skipping Zig consumer (zig not installed)" +fi + +if command -v odin >/dev/null 2>&1; then + echo "==> Compiling smoke.odin" + odin_bin="$BUILD_DIR/smoke-odin" + # Odin links the shared library (complements the static-linked C/Zig + # tests); the run needs it on the loader path. + odin build "$CRATE_DIR/examples/smoke.odin" -file -out:"$odin_bin" \ + -extra-linker-flags:"-L$TARGET_DIR/debug -lmarmot_c -lm -lpthread -ldl" + smoke_home="$(mktemp -d)" + trap 'rm -rf "$smoke_home"' EXIT + echo "==> Running smoke.odin" + LD_LIBRARY_PATH="$TARGET_DIR/debug" "$odin_bin" "$smoke_home" + rm -rf "$smoke_home" + trap - EXIT +else + echo "==> Skipping Odin consumer (odin not installed)" +fi + +if command -v nim >/dev/null 2>&1; then + echo "==> Compiling smoke.nim" + nim_bin="$BUILD_DIR/smoke-nim" + nim_cache="$BUILD_DIR/nimcache" + # -Wno-incompatible-pointer-types: Nim emits `char**` for the relay array + # where the header declares `const char *const *`; benign FFI const gap + # that gcc 14+ otherwise errors on. + nim c -d:release --hints:off --nimcache:"$nim_cache" \ + --passC:"-I$CRATE_DIR/include -Wno-incompatible-pointer-types" \ + --passL:"$STATIC_LIB -lm -lpthread -ldl" \ + -o:"$nim_bin" "$CRATE_DIR/examples/smoke.nim" + smoke_home="$(mktemp -d)" + trap 'rm -rf "$smoke_home"' EXIT + echo "==> Running smoke.nim" + "$nim_bin" "$smoke_home" + rm -rf "$smoke_home" + trap - EXIT +else + echo "==> Skipping Nim consumer (nim not installed)" +fi + +if command -v go >/dev/null 2>&1; then + echo "==> Building smoke.go (cgo)" + go_bin="$BUILD_DIR/smoke-go" + # cgo LDFLAGS in the source link the static archive relative to ${SRCDIR}; + # a plain `go build` of the single file is enough. + ( cd "$CRATE_DIR/examples" && go build -o "$go_bin" smoke.go ) + smoke_home="$(mktemp -d)" + trap 'rm -rf "$smoke_home"' EXIT + echo "==> Running smoke.go" + "$go_bin" "$smoke_home" + rm -rf "$smoke_home" + trap - EXIT +else + echo "==> Skipping Go consumer (go not installed)" +fi + +# Lua via FFI needs LuaJIT (stock Lua has no ffi); it loads the shared lib. +if command -v luajit >/dev/null 2>&1; then + echo "==> Running smoke.lua (LuaJIT FFI)" + smoke_home="$(mktemp -d)" + trap 'rm -rf "$smoke_home"' EXIT + MARMOT_C_LIB="$TARGET_DIR/debug/libmarmot_c.so" \ + luajit "$CRATE_DIR/examples/smoke.lua" "$smoke_home" + rm -rf "$smoke_home" + trap - EXIT +else + echo "==> Skipping Lua consumer (luajit not installed)" +fi + +# PHP via the FFI extension; it loads the shared lib. The extension may be +# built-in but disabled by default, so enable it explicitly and only run when +# a probe confirms the FFI class is reachable. +if command -v php >/dev/null 2>&1 && \ + php -d extension=ffi -r 'exit(class_exists("FFI") ? 0 : 1);' >/dev/null 2>&1; then + echo "==> Running smoke.php (FFI)" + smoke_home="$(mktemp -d)" + trap 'rm -rf "$smoke_home"' EXIT + MARMOT_C_LIB="$TARGET_DIR/debug/libmarmot_c.so" \ + php -d extension=ffi -d ffi.enable=1 "$CRATE_DIR/examples/smoke.php" "$smoke_home" + rm -rf "$smoke_home" + trap - EXIT +else + echo "==> Skipping PHP consumer (php with FFI not available)" +fi + +echo "==> Smoke test passed" diff --git a/crates/marmot-c/cbindgen.toml b/crates/marmot-c/cbindgen.toml new file mode 100644 index 00000000..07b19b09 --- /dev/null +++ b/crates/marmot-c/cbindgen.toml @@ -0,0 +1,34 @@ +language = "C" +pragma_once = true +include_version = true +cpp_compat = true +documentation = true +documentation_style = "doxy" +header = """/* + * marmot.h — C ABI for the Marmot app runtime. + * + * Generated by cbindgen from crates/marmot-c; do not edit by hand. + * Regenerate with `just c-header`. + * + * Ownership rules: + * - Fallible functions return MarmotStatus; MARMOT_STATUS_OK is 0. + * Detail text for the calling thread's most recent failure comes from + * marmot_last_error_message() (free with marmot_string_free). + * - Structs returned by pointer are freed ONLY with their matching + * marmot_*_free function, which deep-frees every field. Never free + * fields individually and never free a struct twice. + * - Input structs and strings are borrowed: the library never frees or + * retains caller memory. + * - Callbacks run on runtime worker threads; item pointers passed to a + * callback are borrowed and valid only for the duration of the call. + */""" + +[export] +prefix = "" + +[enum] +rename_variants = "ScreamingSnakeCase" +prefix_with_name = true + +[parse] +parse_deps = false diff --git a/crates/marmot-c/examples/smoke.c b/crates/marmot-c/examples/smoke.c new file mode 100644 index 00000000..1d08ae4f --- /dev/null +++ b/crates/marmot-c/examples/smoke.c @@ -0,0 +1,218 @@ +/* + * Worked example / smoke test for the marmot-c ABI, in C. + * + * Goes beyond a bare liveness check: it drives a realistic slice of the + * runtime and walks the recursive Markdown DTO tree, so it doubles as a + * reference for how a C consumer navigates the ABI's tagged unions and + * owned pointers. Exercised by c-smoke.sh (optionally under valgrind). + * + * The steps that need a relay (identity publish) are best-effort: in an + * offline sandbox they report the runtime's typed error and the run + * continues. Everything else is asserted hard. + * + * Usage: smoke + */ + +#include +#include +#include +#include +#include + +#include + +static int failures = 0; + +static void ok(const char *what) { + printf("smoke: ok: %s\n", what); +} + +static void check(bool cond, const char *what) { + if (cond) { + ok(what); + } else { + fprintf(stderr, "smoke: FAILED: %s\n", what); + failures++; + } +} + +/* Fetch and free the thread-local detail for the most recent failure. */ +static void print_last_error(const char *context) { + char *msg = marmot_last_error_message(); + printf("smoke: %s: %s\n", context, msg ? msg : "(no detail)"); + marmot_string_free(msg); +} + +/* Reconstruct the plain text of an inline run by concatenating its Text + * spans — a small walk of the inline tagged union. */ +static void print_inline_text(const struct MarmotMarkdownInline *inlines, + uintptr_t len) { + printf("\""); + for (uintptr_t i = 0; i < len; i++) { + if (inlines[i].tag == MARMOT_MARKDOWN_INLINE_TEXT) { + printf("%s", inlines[i].TEXT.content); + } else if (inlines[i].tag == MARMOT_MARKDOWN_INLINE_CODE) { + printf("`%s`", inlines[i].CODE.content); + } + } + printf("\""); +} + +/* Walk the top-level blocks of a parsed document, classifying each by its + * tag and reaching into the union body. Returns the heading count seen. */ +static int walk_markdown(const struct MarmotMarkdownDocument *doc) { + int headings = 0; + for (uintptr_t i = 0; i < doc->blocks_len; i++) { + const struct MarmotMarkdownBlock *b = &doc->blocks[i]; + switch (b->tag) { + case MARMOT_MARKDOWN_BLOCK_HEADING: + headings++; + printf("smoke: block %zu: heading (h%u) ", i, b->HEADING.level); + print_inline_text(b->HEADING.inlines, b->HEADING.inlines_len); + printf("\n"); + break; + case MARMOT_MARKDOWN_BLOCK_PARAGRAPH: + printf("smoke: block %zu: paragraph ", i); + print_inline_text(b->PARAGRAPH.inlines, b->PARAGRAPH.inlines_len); + printf("\n"); + break; + case MARMOT_MARKDOWN_BLOCK_CODE_BLOCK: + printf("smoke: block %zu: code block (lang=%s)\n", i, + b->CODE_BLOCK.info ? b->CODE_BLOCK.info : ""); + break; + case MARMOT_MARKDOWN_BLOCK_LIST_BLOCK: + printf("smoke: block %zu: list with %zu items\n", i, + b->LIST_BLOCK.items_len); + break; + default: + printf("smoke: block %zu: other (tag %d)\n", i, (int)b->tag); + break; + } + } + return headings; +} + +int main(int argc, char **argv) { + if (argc != 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + const char *home = argv[1]; + + /* ---- argument validation + NULL-free discipline (no client) ------- */ + MarmotClient *client = NULL; + MarmotStatus st = marmot_client_new(NULL, NULL, 0, &client); + check(st == MARMOT_STATUS_NULL_POINTER, "NULL root_path rejected"); + + marmot_client_free(NULL); + marmot_string_free(NULL); + marmot_account_summary_list_free(NULL); + marmot_markdown_document_free(NULL); + ok("NULL frees are no-ops"); + + /* ---- construct + lifecycle ---------------------------------------- */ + const char *relays[] = {"wss://relay.example.org"}; + st = marmot_client_new(home, relays, 1, &client); + if (st != MARMOT_STATUS_OK) { + /* No platform keychain (headless): a documented limitation. */ + print_last_error("client_new"); + printf("smoke: SKIP: no client\n"); + return 0; + } + ok("client constructed"); + + bool stopping = true; + st = marmot_client_is_stopping(client, &stopping); + check(st == MARMOT_STATUS_OK && !stopping, "client not stopping"); + + st = marmot_client_start(client); + if (st == MARMOT_STATUS_OK) { + ok("client started"); + } else { + printf("smoke: ok: client start offline (status %d)\n", (int)st); + print_last_error("start"); + } + + /* ---- Markdown: parse + walk the recursive DTO tree ---------------- */ + MarmotMarkdownDocument *doc = NULL; + const char *md = + "# Marmot\n\n" + "A **bold** claim with `code` and a [link](https://example.org).\n\n" + "- [x] dig burrow\n" + "- [ ] store acorns\n\n" + "```rust\nfn main() {}\n```\n"; + st = marmot_parse_markdown(client, md, &doc); + check(st == MARMOT_STATUS_OK && doc != NULL, "markdown parsed"); + check(!doc->truncated, "markdown not truncated"); + check(doc->blocks_len >= 4, "markdown has heading + paragraph + list + code"); + int headings = walk_markdown(doc); + check(headings == 1, "walked tree found the heading"); + marmot_markdown_document_free(doc); + + /* ---- offline reads ------------------------------------------------ */ + MarmotAccountSummaryList *accounts = NULL; + st = marmot_list_accounts(client, &accounts); + check(st == MARMOT_STATUS_OK && accounts != NULL, "list_accounts"); + check(accounts->len == 0, "fresh home has no accounts"); + marmot_account_summary_list_free(accounts); + + /* Directory lookups for an unknown id resolve to absent (NULL out). */ + char *npub = NULL; + const char *unknown_id = + "0000000000000000000000000000000000000000000000000000000000000000"; + st = marmot_npub(client, unknown_id, &npub); + check(st == MARMOT_STATUS_OK, "npub lookup call succeeds"); + marmot_string_free(npub); + + /* ---- error taxonomy ----------------------------------------------- */ + MarmotStringList *relays_out = NULL; + st = marmot_account_nip65_relays(client, "no-such-account", &relays_out); + check(st == MARMOT_STATUS_UNKNOWN_ACCOUNT, + "unknown account -> MARMOT_STATUS_UNKNOWN_ACCOUNT"); + + char *nsec = NULL; + st = marmot_reveal_nsec(client, "no-such-account", &nsec); + check(st == MARMOT_STATUS_UNKNOWN_ACCOUNT, + "reveal_nsec on unknown account -> UNKNOWN_ACCOUNT"); + if (st != MARMOT_STATUS_OK) { + marmot_string_free(marmot_last_error_message()); + } else { + marmot_string_free(nsec); + } + + /* ---- best-effort identity (needs a relay) ------------------------- */ + MarmotAccountSummary *summary = NULL; + st = marmot_create_identity(client, relays, 1, relays, 1, &summary); + if (st == MARMOT_STATUS_OK && summary != NULL) { + printf("smoke: ok: identity created: %s\n", summary->account_id_hex); + marmot_account_summary_free(summary); + } else { + printf("smoke: ok: identity create offline (status %d)\n", (int)st); + print_last_error("create_identity"); + } + + /* ---- byte-buffer command + NULL-safe bytes free ------------------- */ + uint8_t *img = NULL; + uintptr_t img_len = 0; + st = marmot_download_group_blossom_image(client, "no-such-account", + "aabb", &img, &img_len); + check(st != MARMOT_STATUS_OK, + "blossom image download on unknown account errors"); + marmot_bytes_free(img, img_len); /* (NULL, 0) on the error path */ + marmot_bytes_free(NULL, 0); /* no-op */ + ok("bytes free is NULL-safe"); + + /* ---- shutdown ----------------------------------------------------- */ + st = marmot_client_shutdown(client); + check(st == MARMOT_STATUS_OK, "client shutdown"); + st = marmot_client_is_stopping(client, &stopping); + check(st == MARMOT_STATUS_OK && stopping, "client reports stopping"); + marmot_client_free(client); + + if (failures == 0) { + printf("smoke: all checks passed\n"); + return 0; + } + fprintf(stderr, "smoke: %d checks FAILED\n", failures); + return 1; +} diff --git a/crates/marmot-c/examples/smoke.go b/crates/marmot-c/examples/smoke.go new file mode 100644 index 00000000..360cf093 --- /dev/null +++ b/crates/marmot-c/examples/smoke.go @@ -0,0 +1,357 @@ +// End-to-end smoke test for the marmot-c ABI from Go (cgo). +// +// cgo pulls in the real marmot.h, so the generated header stays the single +// source of truth for signatures and struct layout. Mirrors the flow of +// examples/smoke.c: argument validation, client lifecycle, markdown parsing, +// offline directory + account reads, the error taxonomy, and best-effort +// identity creation — wrapped in an idiomatic Go type that returns `error` +// values and owns the C resources. +// +// The #cgo lines below link the static archive relative to this source file +// (${SRCDIR}); the Rust staticlib needs the C math/thread/dl libs. Build & +// run (see c-smoke.sh for the canonical invocation): +// go run examples/smoke.go +// or, so the binary can move: +// go build -o smoke_go examples/smoke.go && ./smoke_go + +package main + +/* +#cgo CFLAGS: -I${SRCDIR}/../include +#cgo LDFLAGS: ${SRCDIR}/../../../target/debug/libmarmot_c.a -lm -lpthread -ldl +#include +#include +*/ +import "C" + +import ( + "fmt" + "os" + "unsafe" +) + +// MarmotError carries the ABI status code plus the thread-local detail string +// from marmot_last_error_message(). +type MarmotError struct { + Op string + Status int + Detail string +} + +func (e *MarmotError) Error() string { + detail := e.Detail + if detail == "" { + detail = "(no detail)" + } + return fmt.Sprintf("%s: status %d: %s", e.Op, e.Status, detail) +} + +// check turns a raw MarmotStatus into an idiomatic error, draining the +// thread-local last-error detail on failure. Typed as C.MarmotStatus so it +// stays correct whether cgo maps the enum to a signed or unsigned width. +func check(status C.MarmotStatus, op string) error { + if status == C.MARMOT_STATUS_OK { + return nil + } + detail := "" + if msg := C.marmot_last_error_message(); msg != nil { + detail = C.GoString(msg) + C.marmot_string_free(msg) + } + return &MarmotError{Op: op, Status: int(status), Detail: detail} +} + +// Marmot wraps the opaque *C.MarmotClient with idiomatic methods. +type Marmot struct { + client *C.MarmotClient +} + +// cStrings marshals a []string into a NULL-safe C `**char`, returning the +// pointer to the first element (or nil for an empty slice) and a cleanup func. +func cStrings(items []string) (**C.char, C.uintptr_t, func()) { + if len(items) == 0 { + return nil, 0, func() {} + } + arr := make([]*C.char, len(items)) + for i, s := range items { + arr[i] = C.CString(s) + } + free := func() { + for _, p := range arr { + C.free(unsafe.Pointer(p)) + } + } + return (**C.char)(unsafe.Pointer(&arr[0])), C.uintptr_t(len(items)), free +} + +// Open constructs a client rooted at home with the given default relays. +func Open(home string, relays []string) (*Marmot, error) { + cHome := C.CString(home) + defer C.free(unsafe.Pointer(cHome)) + + cRelays, n, freeRelays := cStrings(relays) + defer freeRelays() + + var client *C.MarmotClient + st := C.marmot_client_new(cHome, cRelays, n, &client) + if err := check(st, "client_new"); err != nil { + return nil, err + } + return &Marmot{client: client}, nil +} + +// IsStopping reports whether the client is shutting down. +func (m *Marmot) IsStopping() (bool, error) { + var stopping C.bool + st := C.marmot_client_is_stopping(m.client, &stopping) + if err := check(st, "is_stopping"); err != nil { + return false, err + } + return bool(stopping), nil +} + +// TryStart attempts to start the runtime, returning the typed error (e.g. the +// offline dial-safety rejection) for the caller to decide on. +func (m *Marmot) TryStart() error { + return check(C.marmot_client_start(m.client), "start") +} + +// ParseMarkdown parses text and returns (blockCount, truncated). +func (m *Marmot) ParseMarkdown(text string) (int, bool, error) { + cText := C.CString(text) + defer C.free(unsafe.Pointer(cText)) + + var doc *C.MarmotMarkdownDocument + st := C.marmot_parse_markdown(m.client, cText, &doc) + if err := check(st, "parse_markdown"); err != nil { + return 0, false, err + } + defer C.marmot_markdown_document_free(doc) + // The C example (examples/smoke.c) demonstrates the deep tagged-union walk + // down to HEADING/PARAGRAPH inline runs; cgo's rendering of cbindgen's + // anonymous union is unergonomic (each access needs unsafe pointer casts), + // so here we stay at the DOCUMENT level and only inspect the block count. + return int(doc.blocks_len), bool(doc.truncated), nil +} + +// AccountCount returns the number of local accounts. +func (m *Marmot) AccountCount() (int, error) { + var accounts *C.MarmotAccountSummaryList + st := C.marmot_list_accounts(m.client, &accounts) + if err := check(st, "list_accounts"); err != nil { + return 0, err + } + defer C.marmot_account_summary_list_free(accounts) + return int(accounts.len), nil +} + +// Npub resolves an account id (hex) to its npub via the directory. An absent +// id yields ("", nil). +func (m *Marmot) Npub(accountIDHex string) (string, error) { + cID := C.CString(accountIDHex) + defer C.free(unsafe.Pointer(cID)) + + var out *C.char + st := C.marmot_npub(m.client, cID, &out) + if err := check(st, "npub"); err != nil { + return "", err + } + if out == nil { + return "", nil + } + defer C.marmot_string_free(out) + return C.GoString(out), nil +} + +// Nip65Relays returns the NIP-65 relays for an account ref. +func (m *Marmot) Nip65Relays(accountRef string) ([]string, error) { + cRef := C.CString(accountRef) + defer C.free(unsafe.Pointer(cRef)) + + var list *C.MarmotStringList + st := C.marmot_account_nip65_relays(m.client, cRef, &list) + if err := check(st, "nip65_relays"); err != nil { + return nil, err + } + defer C.marmot_string_list_free(list) + n := int(list.len) + out := make([]string, 0, n) + items := unsafe.Slice(list.items, n) + for _, p := range items { + out = append(out, C.GoString(p)) + } + return out, nil +} + +// RevealNsec returns the plaintext nsec for an account ref. +func (m *Marmot) RevealNsec(accountRef string) (string, error) { + cRef := C.CString(accountRef) + defer C.free(unsafe.Pointer(cRef)) + + var out *C.char + st := C.marmot_reveal_nsec(m.client, cRef, &out) + if err := check(st, "reveal_nsec"); err != nil { + return "", err + } + defer C.marmot_string_free(out) + return C.GoString(out), nil +} + +// CreateIdentity creates a fresh identity published to the given relays, +// returning its account id (hex). Needs a live relay, so it is best-effort. +func (m *Marmot) CreateIdentity(defaultRelays, bootstrapRelays []string) (string, error) { + cDef, nDef, freeDef := cStrings(defaultRelays) + defer freeDef() + cBoot, nBoot, freeBoot := cStrings(bootstrapRelays) + defer freeBoot() + + var summary *C.MarmotAccountSummary + st := C.marmot_create_identity(m.client, cDef, nDef, cBoot, nBoot, &summary) + if err := check(st, "create_identity"); err != nil { + return "", err + } + defer C.marmot_account_summary_free(summary) + return C.GoString(summary.account_id_hex), nil +} + +// Close shuts the runtime down and frees the client. Safe to defer. +func (m *Marmot) Close() { + if m == nil || m.client == nil { + return + } + C.marmot_client_shutdown(m.client) + C.marmot_client_free(m.client) + m.client = nil +} + +func fail(what string, err error) { + if err != nil { + fmt.Fprintf(os.Stderr, "smoke(go): FAILED: %s: %v\n", what, err) + } else { + fmt.Fprintf(os.Stderr, "smoke(go): FAILED: %s\n", what) + } + os.Exit(1) +} + +func ok(what string) { + fmt.Printf("smoke(go): ok: %s\n", what) +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: smoke_go ") + os.Exit(2) + } + home := os.Args[1] + + // --- argument validation paths (no client) -------------------------- + var raw *C.MarmotClient + st := C.marmot_client_new(nil, nil, 0, &raw) + if st != C.MARMOT_STATUS_NULL_POINTER { + fail("NULL root_path should be rejected", nil) + } + ok("NULL root_path rejected") + + // NULL is a no-op for every free function. + C.marmot_client_free(nil) + C.marmot_string_free(nil) + C.marmot_account_summary_list_free(nil) + C.marmot_markdown_document_free(nil) + ok("NULL frees are no-ops") + + // --- construct + lifecycle ------------------------------------------ + relays := []string{"wss://relay.example.org"} + m, err := Open(home, relays) + if err != nil { + // Headless environments may lack a platform keychain; a documented + // limitation, not an ABI defect. + fmt.Printf("smoke(go): SKIP: %v\n", err) + return + } + defer m.Close() + ok("client constructed") + + stopping, err := m.IsStopping() + if err != nil || stopping { + fail("client should not be stopping", err) + } + ok("client not stopping") + + // start() may fail offline (dial-safety rejects the relay); either + // outcome exercises the start + error path. Best-effort. + if err := m.TryStart(); err != nil { + fmt.Printf("smoke(go): ok: client start offline: %v\n", err) + } else { + ok("client started") + } + + // --- markdown parsing ----------------------------------------------- + md := "# Marmot\n\n" + + "A **bold** claim with `code` and a [link](https://example.org).\n\n" + + "- [x] dig burrow\n" + + "- [ ] store acorns\n\n" + + "```rust\nfn main() {}\n```\n" + blockCount, truncated, err := m.ParseMarkdown(md) + if err != nil { + fail("markdown parse", err) + } + if truncated { + fail("markdown should not be truncated", nil) + } + if blockCount < 4 { + fail(fmt.Sprintf("markdown should have >= 4 blocks, got %d", blockCount), nil) + } + ok(fmt.Sprintf("markdown parsed: %d blocks (heading + paragraph + list + code)", blockCount)) + + // --- offline reads -------------------------------------------------- + count, err := m.AccountCount() + if err != nil { + fail("list_accounts", err) + } + if count != 0 { + fail(fmt.Sprintf("fresh home should have 0 accounts, got %d", count), nil) + } + ok("fresh home has no accounts") + + // Directory lookup for an unknown id: the call succeeds and the (owned) + // npub string is freed inside the wrapper. + zeroID := "0000000000000000000000000000000000000000000000000000000000000000" + if _, err := m.Npub(zeroID); err != nil { + fail("npub lookup", err) + } + ok("npub lookup call succeeds") + + // --- error taxonomy (hard asserts) ---------------------------------- + if _, err := m.Nip65Relays("no-such-account"); err == nil { + fail("nip65_relays on unknown account should error", nil) + } else if me, isMarmot := err.(*MarmotError); !isMarmot || me.Status != int(C.MARMOT_STATUS_UNKNOWN_ACCOUNT) { + fail("nip65_relays should map to UNKNOWN_ACCOUNT", err) + } + ok("unknown account -> UNKNOWN_ACCOUNT (nip65_relays)") + + if _, err := m.RevealNsec("no-such-account"); err == nil { + fail("reveal_nsec on unknown account should error", nil) + } else if me, isMarmot := err.(*MarmotError); !isMarmot || me.Status != int(C.MARMOT_STATUS_UNKNOWN_ACCOUNT) { + fail("reveal_nsec should map to UNKNOWN_ACCOUNT", err) + } + ok("unknown account -> UNKNOWN_ACCOUNT (reveal_nsec)") + + // --- best-effort identity (needs a relay) --------------------------- + if accountID, err := m.CreateIdentity(relays, relays); err != nil { + fmt.Printf("smoke(go): ok: identity create offline: %v\n", err) + } else { + fmt.Printf("smoke(go): ok: identity created: %s\n", accountID) + } + + // --- shutdown ------------------------------------------------------- + if err := check(C.marmot_client_shutdown(m.client), "shutdown"); err != nil { + fail("client shutdown", err) + } + stopping, err = m.IsStopping() + if err != nil || !stopping { + fail("client should report stopping after shutdown", err) + } + ok("client reports stopping") + + fmt.Println("smoke(go): all checks passed") +} diff --git a/crates/marmot-c/examples/smoke.lua b/crates/marmot-c/examples/smoke.lua new file mode 100644 index 00000000..1cd1b6bf --- /dev/null +++ b/crates/marmot-c/examples/smoke.lua @@ -0,0 +1,287 @@ +-- Idiomatic Lua consumer of the marmot-c ABI, via the LuaJIT FFI. +-- +-- Stock Lua has no FFI, so this targets LuaJIT's `ffi`. The raw C surface is +-- wrapped in a small `Marmot` object (metatable + methods) that returns Lua +-- values, ties the client's lifetime to the garbage collector with ffi.gc, +-- and raises Lua errors (catchable with pcall) on failure -- so the smoke +-- test at the bottom reads like ordinary Lua rather than transliterated C. It +-- exercises the same behaviour as examples/smoke.c: client lifecycle, markdown +-- parsing, account listing, directory (npub) lookup, the UNKNOWN_ACCOUNT error +-- taxonomy, best-effort identity creation, and cleanup. +-- +-- Run (see c-smoke.sh for the canonical invocation): +-- MARMOT_C_LIB=target/debug/libmarmot_c.so \ +-- luajit examples/smoke.lua + +local ffi = require("ffi") + +ffi.cdef([[ +typedef struct MarmotClient MarmotClient; +typedef struct MarmotStringList MarmotStringList; +typedef struct MarmotMarkdownBlock MarmotMarkdownBlock; +typedef struct MarmotAccountSummary MarmotAccountSummary; + +typedef struct MarmotMarkdownDocument { + MarmotMarkdownBlock *blocks; + uintptr_t blocks_len; + bool truncated; +} MarmotMarkdownDocument; + +typedef struct MarmotAccountSummary { + char *label; + char *account_id_hex; + bool local_signing; + bool signed_out; + bool running; +} MarmotAccountSummary; + +typedef struct MarmotAccountSummaryList { + MarmotAccountSummary *items; + uintptr_t len; +} MarmotAccountSummaryList; + +int marmot_client_new(const char *root_path, const char *const *relay_urls, + uintptr_t relay_urls_len, MarmotClient **out_client); +int marmot_client_start(const MarmotClient *client); +int marmot_client_shutdown(const MarmotClient *client); +int marmot_client_is_stopping(const MarmotClient *client, bool *out_stopping); +void marmot_client_free(MarmotClient *client); +char *marmot_last_error_message(void); +void marmot_string_free(char *s); +int marmot_parse_markdown(const MarmotClient *client, const char *text, + MarmotMarkdownDocument **out_document); +void marmot_markdown_document_free(MarmotMarkdownDocument *document); +int marmot_list_accounts(const MarmotClient *client, MarmotAccountSummaryList **out_list); +void marmot_account_summary_list_free(MarmotAccountSummaryList *list); +int marmot_account_nip65_relays(const MarmotClient *client, const char *account_ref, + MarmotStringList **out_list); +int marmot_npub(const MarmotClient *client, const char *account_id_hex, char **out_npub); +int marmot_reveal_nsec(const MarmotClient *client, const char *account_ref, char **out_nsec); +int marmot_create_identity(const MarmotClient *client, + const char *const *default_relays, uintptr_t default_relays_len, + const char *const *bootstrap_relays, uintptr_t bootstrap_relays_len, + MarmotAccountSummary **out_summary); +void marmot_account_summary_free(MarmotAccountSummary *summary); +]]) + +--- Marmot: a thin object wrapper over the C ABI. -------------------------- + +local Marmot = {} +Marmot.__index = Marmot + +-- MarmotStatus values referenced by name (stable ABI). +Marmot.STATUS_OK = 0 +Marmot.STATUS_UNKNOWN_ACCOUNT = 11 + +-- Take and free the thread-local last-error detail. +local function take_last_error(lib) + local msg = lib.marmot_last_error_message() + if msg == nil then return "(no detail)" end + local text = ffi.string(msg) + lib.marmot_string_free(msg) + return text +end + +-- Raise a Lua error carrying the status code and detail (catch with pcall). +local function throw(lib, op) + error({ status = nil, message = op .. ": " .. take_last_error(lib) }, 0) +end + +local function check(lib, status, op) + if status ~= Marmot.STATUS_OK then + error({ status = status, message = op .. ": " .. take_last_error(lib) }, 0) + end +end + +--- Open a client rooted at `root` and connected to `relays` (a list of URLs). +--- Raises on failure. +function Marmot.open(root, relays, libpath) + local lib = ffi.load(libpath or os.getenv("MARMOT_C_LIB") or "marmot_c") + local arr = ffi.new("const char*[?]", #relays, relays) + local out = ffi.new("MarmotClient*[1]") + check(lib, lib.marmot_client_new(root, arr, #relays, out), "open") + -- Tie the client's lifetime to GC; :close() detaches this finalizer. + local client = ffi.gc(out[0], function(c) lib.marmot_client_free(c) end) + return setmetatable({ _lib = lib, _client = client }, Marmot) +end + +--- Start the runtime. Returns nil on success, or the error detail when it +--- fails (e.g. offline, where dial-safety rejects the relay). +function Marmot:try_start() + local status = self._lib.marmot_client_start(self._client) + if status == Marmot.STATUS_OK then return nil end + return take_last_error(self._lib) +end + +function Marmot:is_stopping() + local flag = ffi.new("bool[1]") + check(self._lib, self._lib.marmot_client_is_stopping(self._client, flag), "is_stopping") + return flag[0] +end + +--- Parse markdown, returning { blocks = , truncated = }. +function Marmot:parse_markdown(text) + local out = ffi.new("MarmotMarkdownDocument*[1]") + check(self._lib, self._lib.marmot_parse_markdown(self._client, text, out), "parse_markdown") + local doc = out[0] + local result = { blocks = tonumber(doc.blocks_len), truncated = doc.truncated } + self._lib.marmot_markdown_document_free(doc) + return result +end + +--- Number of accounts known to this device. +function Marmot:account_count() + local out = ffi.new("MarmotAccountSummaryList*[1]") + check(self._lib, self._lib.marmot_list_accounts(self._client, out), "list_accounts") + local n = tonumber(out[0].len) + self._lib.marmot_account_summary_list_free(out[0]) + return n +end + +--- Raises { status = STATUS_UNKNOWN_ACCOUNT, ... } for an unknown ref. +function Marmot:nip65_relays(account_ref) + local out = ffi.new("MarmotStringList*[1]") + check(self._lib, self._lib.marmot_account_nip65_relays(self._client, account_ref, out), "nip65_relays") + -- (A real caller would read + free out[0] here; this example only needs + -- the error path.) +end + +--- Directory lookup: resolve an account id to its npub. An id the directory +--- has never seen resolves to absent (nil); a live account yields the string. +--- Raises on an outright ABI failure. +function Marmot:npub(account_id_hex) + local out = ffi.new("char*[1]") + check(self._lib, self._lib.marmot_npub(self._client, account_id_hex, out), "npub") + if out[0] == nil then return nil end + local text = ffi.string(out[0]) + self._lib.marmot_string_free(out[0]) + return text +end + +--- Reveal the nsec (private key) for a local-signing account. Raises +--- { status = STATUS_UNKNOWN_ACCOUNT, ... } for a ref this device doesn't hold. +function Marmot:reveal_nsec(account_ref) + local out = ffi.new("char*[1]") + check(self._lib, self._lib.marmot_reveal_nsec(self._client, account_ref, out), "reveal_nsec") + if out[0] == nil then return nil end + local text = ffi.string(out[0]) + self._lib.marmot_string_free(out[0]) + return text +end + +--- Best-effort: mint a fresh local identity, publishing to `relays`. Online +--- this returns the new account's id (hex); offline the runtime raises a typed +--- error (dial-safety / no relay reachable). Callers wrap this in pcall. +function Marmot:create_identity(relays) + local arr = ffi.new("const char*[?]", #relays, relays) + local out = ffi.new("MarmotAccountSummary*[1]") + check(self._lib, + self._lib.marmot_create_identity(self._client, arr, #relays, arr, #relays, out), + "create_identity") + local summary = out[0] + local account_id = summary.account_id_hex ~= nil and ffi.string(summary.account_id_hex) or nil + self._lib.marmot_account_summary_free(summary) + return account_id +end + +function Marmot:shutdown() + if self._client ~= nil then + self._lib.marmot_client_shutdown(self._client) + end +end + +--- Free the client now and detach the GC finalizer (idempotent). +function Marmot:close() + if self._client ~= nil then + ffi.gc(self._client, nil) + self._lib.marmot_client_free(self._client) + self._client = nil + end +end + +--- smoke test ------------------------------------------------------------- + +local function ok(what) + io.write("smoke(lua): ok: ", what, "\n") +end + +local function fail(what) + io.write("smoke(lua): FAILED: ", what, "\n") + os.exit(1) +end + +local home = arg[1] +if not home then + io.stderr:write("usage: smoke.lua \n") + os.exit(2) +end + +local opened, marmot = pcall(Marmot.open, home, { "ws://127.0.0.1:7777" }) +if not opened then + -- Headless environments may lack a platform keychain; documented + -- limitation, not an ABI defect. + io.write("smoke(lua): SKIP: open failed: ", tostring(marmot.message or marmot), "\n") + os.exit(0) +end +ok("client constructed") + +if marmot:is_stopping() then fail("fresh client should not be stopping") end +ok("client not stopping") + +-- start() may fail offline (dial-safety rejects loopback relays); that still +-- exercises the start + error path. Either outcome is fine. +local start_err = marmot:try_start() +ok(start_err == nil and "client started" or ("client start offline: " .. start_err)) + +-- A document with a heading, a paragraph mixing bold/code/link, a task list, +-- and a fenced code block -- four top-level blocks the parser must not truncate. +local doc = marmot:parse_markdown( + "# Marmot\n\n" .. + "A **bold** claim with `code` and a [link](https://example.org).\n\n" .. + "- [x] dig burrow\n" .. + "- [ ] store acorns\n\n" .. + "```rust\nfn main() {}\n```\n") +if doc.blocks < 4 then fail("markdown should have heading + paragraph + list + code") end +ok("markdown parsed with " .. doc.blocks .. " blocks") +if doc.truncated then fail("markdown should not be truncated") end +ok("markdown not truncated") + +if marmot:account_count() ~= 0 then fail("fresh home should have no accounts") end +ok("fresh home has no accounts") + +-- Directory lookup for an id nobody has published resolves to absent (nil), +-- not an error. +local unknown_id = string.rep("0", 64) +local looked_up, npub = pcall(function() return marmot:npub(unknown_id) end) +if not looked_up then fail("npub lookup should not raise for an unknown id") end +ok("npub lookup of unknown id returns " .. (npub == nil and "absent" or npub)) + +-- Error taxonomy: both nip65 relays and reveal_nsec on an account this device +-- doesn't hold must raise the same typed UNKNOWN_ACCOUNT status. +local caught, err = pcall(function() marmot:nip65_relays("no-such-account") end) +if caught then fail("unknown account nip65 should raise") end +if err.status ~= Marmot.STATUS_UNKNOWN_ACCOUNT then fail("unexpected nip65 status " .. tostring(err.status)) end +ok("nip65 on unknown account raises typed error") + +caught, err = pcall(function() marmot:reveal_nsec("no-such-account") end) +if caught then fail("unknown account reveal_nsec should raise") end +if err.status ~= Marmot.STATUS_UNKNOWN_ACCOUNT then fail("unexpected reveal_nsec status " .. tostring(err.status)) end +ok("reveal_nsec on unknown account raises typed error") + +-- Best-effort: creating an identity needs a reachable relay. Online it mints +-- an account; offline the runtime raises a typed error. Either is fine. +local created, result = pcall(function() return marmot:create_identity({ "wss://relay.example.org" }) end) +if created then + ok("identity created: " .. tostring(result)) +else + ok("identity create offline: " .. tostring(result.message or result)) +end + +marmot:shutdown() +if not marmot:is_stopping() then fail("client should report stopping after shutdown") end +ok("client reports stopping after shutdown") + +marmot:close() +ok("client freed via close") + +io.write("smoke(lua): all checks passed\n") diff --git a/crates/marmot-c/examples/smoke.nim b/crates/marmot-c/examples/smoke.nim new file mode 100644 index 00000000..bf49e7d1 --- /dev/null +++ b/crates/marmot-c/examples/smoke.nim @@ -0,0 +1,386 @@ +# End-to-end smoke test for the marmot-c ABI from Nim. +# +# Nim compiles to C, so it pulls in the real marmot.h and lets the C compiler +# see the true declarations (importc + header). Struct layout and signatures +# are therefore the C compiler's, not hand-transcribed. This exercises the +# same realistic slice as examples/smoke.c: client lifecycle, the last-error +# and typed-error paths, a DEEP walk of the recursive Markdown DTO tree +# (block/inline tagged unions), offline reads, a directory lookup, and the +# best-effort identity publish — all behind an idiomatic Nim wrapper that +# raises `MarmotError` and frees via `=destroy`. +# +# Build & run (see c-smoke.sh for the canonical invocation): +# nim c -d:release --nimcache: \ +# --passC:"-I/include -Wno-incompatible-pointer-types" \ +# --passL:"/debug/libmarmot_c.a -lm -lpthread -ldl" \ +# -o:smoke_nim examples/smoke.nim +# ./smoke_nim +# +# `-Wno-incompatible-pointer-types`: Nim models `cstring` as `char*` and has +# no `const`-pointee notion, so it emits `char**` where the header declares +# `const char *const *relay_urls`. gcc 14+ makes that const mismatch an error +# by default; it is benign for FFI, so we downgrade just that diagnostic. + +import std/[os, options] + +# The header include path (-I /include) and the link flags are supplied +# on the compile command; see the build recipe above and c-smoke.sh. + +# Pull declarations straight from the header via importc, so struct layout and +# signatures are the C compiler's, not hand-transcribed. cbindgen emits the +# tagged unions as `{ tag; union { HEADING; PARAGRAPH; ... }; }`; because these +# are importc + header types, Nim never computes the layout itself — it defers +# to the C header — so we only declare the union members we actually read, each +# with its C name, and access `blk.heading` compiles to `blk.HEADING`, which C +# resolves through the anonymous union. +{.push header: "marmot.h".} + +type + MarmotClient {.importc: "MarmotClient", incompleteStruct.} = object + MarmotStringList {.importc: "MarmotStringList", incompleteStruct.} = object + + MarmotStatus {.importc: "MarmotStatus".} = distinct cint + MarmotMarkdownBlockTag {.importc: "MarmotMarkdownBlock_Tag".} = distinct cint + MarmotMarkdownInlineTag {.importc: "MarmotMarkdownInline_Tag".} = distinct cint + + # --- inline nodes (leaf bodies we reconstruct heading text from) ----- + MarmotMarkdownInlineTextBody {.importc: "MarmotMarkdownInline_Text_Body".} = object + content {.importc: "content".}: cstring + MarmotMarkdownInlineCodeBody {.importc: "MarmotMarkdownInline_Code_Body".} = object + content {.importc: "content".}: cstring + MarmotMarkdownInline {.importc: "MarmotMarkdownInline".} = object + tag {.importc: "tag".}: MarmotMarkdownInlineTag + text {.importc: "TEXT".}: MarmotMarkdownInlineTextBody + code {.importc: "CODE".}: MarmotMarkdownInlineCodeBody + + # --- block bodies (only the fields this walk touches) ---------------- + MarmotMarkdownHeadingBody {.importc: "MarmotMarkdownBlock_Heading_Body".} = object + level {.importc: "level".}: uint8 + inlines {.importc: "inlines".}: ptr UncheckedArray[MarmotMarkdownInline] + inlines_len {.importc: "inlines_len".}: csize_t + MarmotMarkdownParagraphBody {.importc: "MarmotMarkdownBlock_Paragraph_Body".} = object + inlines {.importc: "inlines".}: ptr UncheckedArray[MarmotMarkdownInline] + inlines_len {.importc: "inlines_len".}: csize_t + MarmotMarkdownCodeBlockBody {.importc: "MarmotMarkdownBlock_CodeBlock_Body".} = object + info {.importc: "info".}: cstring + content {.importc: "content".}: cstring + MarmotMarkdownListBlockBody {.importc: "MarmotMarkdownBlock_ListBlock_Body".} = object + items_len {.importc: "items_len".}: csize_t + MarmotMarkdownBlock {.importc: "MarmotMarkdownBlock".} = object + tag {.importc: "tag".}: MarmotMarkdownBlockTag + heading {.importc: "HEADING".}: MarmotMarkdownHeadingBody + paragraph {.importc: "PARAGRAPH".}: MarmotMarkdownParagraphBody + codeBlock {.importc: "CODE_BLOCK".}: MarmotMarkdownCodeBlockBody + listBlock {.importc: "LIST_BLOCK".}: MarmotMarkdownListBlockBody + + MarmotMarkdownDocument {.importc: "MarmotMarkdownDocument".} = object + blocks: ptr UncheckedArray[MarmotMarkdownBlock] + blocks_len: csize_t + truncated: bool + + MarmotAccountSummary {.importc: "MarmotAccountSummary".} = object + label {.importc: "label".}: cstring + account_id_hex {.importc: "account_id_hex".}: cstring + local_signing {.importc: "local_signing".}: bool + signed_out {.importc: "signed_out".}: bool + running {.importc: "running".}: bool + + MarmotAccountSummaryList {.importc: "MarmotAccountSummaryList".} = object + items: ptr MarmotAccountSummary + len: csize_t + +var + MARMOT_STATUS_OK {.importc, nodecl.}: MarmotStatus + MARMOT_STATUS_NULL_POINTER {.importc, nodecl.}: MarmotStatus + MARMOT_STATUS_UNKNOWN_ACCOUNT {.importc, nodecl.}: MarmotStatus + MARMOT_MARKDOWN_BLOCK_HEADING {.importc, nodecl.}: MarmotMarkdownBlockTag + MARMOT_MARKDOWN_BLOCK_PARAGRAPH {.importc, nodecl.}: MarmotMarkdownBlockTag + MARMOT_MARKDOWN_BLOCK_CODE_BLOCK {.importc, nodecl.}: MarmotMarkdownBlockTag + MARMOT_MARKDOWN_BLOCK_LIST_BLOCK {.importc, nodecl.}: MarmotMarkdownBlockTag + MARMOT_MARKDOWN_INLINE_TEXT {.importc, nodecl.}: MarmotMarkdownInlineTag + MARMOT_MARKDOWN_INLINE_CODE {.importc, nodecl.}: MarmotMarkdownInlineTag + +proc marmot_client_new(root_path: cstring, relay_urls: ptr cstring, + relay_urls_len: csize_t, out_client: ptr ptr MarmotClient): MarmotStatus {.importc.} +proc marmot_client_start(client: ptr MarmotClient): MarmotStatus {.importc.} +proc marmot_client_shutdown(client: ptr MarmotClient): MarmotStatus {.importc.} +proc marmot_client_is_stopping(client: ptr MarmotClient, out_stopping: ptr bool): MarmotStatus {.importc.} +proc marmot_client_free(client: ptr MarmotClient) {.importc.} +proc marmot_last_error_message(): cstring {.importc.} +proc marmot_string_free(s: cstring) {.importc.} +proc marmot_parse_markdown(client: ptr MarmotClient, text: cstring, + out_document: ptr ptr MarmotMarkdownDocument): MarmotStatus {.importc.} +proc marmot_markdown_document_free(document: ptr MarmotMarkdownDocument) {.importc.} +proc marmot_list_accounts(client: ptr MarmotClient, + out_list: ptr ptr MarmotAccountSummaryList): MarmotStatus {.importc.} +proc marmot_account_summary_list_free(list: ptr MarmotAccountSummaryList) {.importc.} +proc marmot_account_summary_free(summary: ptr MarmotAccountSummary) {.importc.} +proc marmot_account_nip65_relays(client: ptr MarmotClient, account_ref: cstring, + out_list: ptr ptr MarmotStringList): MarmotStatus {.importc.} +proc marmot_string_list_free(list: ptr MarmotStringList) {.importc.} +proc marmot_npub(client: ptr MarmotClient, account_id_hex: cstring, + out_npub: ptr cstring): MarmotStatus {.importc.} +proc marmot_reveal_nsec(client: ptr MarmotClient, account_ref: cstring, + out_nsec: ptr cstring): MarmotStatus {.importc.} +proc marmot_create_identity(client: ptr MarmotClient, + default_relays: ptr cstring, default_relays_len: csize_t, + bootstrap_relays: ptr cstring, bootstrap_relays_len: csize_t, + out_summary: ptr ptr MarmotAccountSummary): MarmotStatus {.importc.} + +{.pop.} + +proc `==`(a, b: MarmotStatus): bool {.borrow.} +proc `==`(a, b: MarmotMarkdownBlockTag): bool {.borrow.} +proc `==`(a, b: MarmotMarkdownInlineTag): bool {.borrow.} + +# -------------------------------------------------------------------------- +# Idiomatic wrapper: a `Marmot` handle that raises `MarmotError` on any +# non-OK status and frees the client on destruction. The raw out-pointer +# dance stays hidden inside these procs. +# -------------------------------------------------------------------------- + +type + MarmotError = object of CatchableError + status: MarmotStatus + + Marmot = object + client: ptr MarmotClient + + # RAII owner for a parsed document, freed as one root by `=destroy`. + Markdown = object + doc: ptr MarmotMarkdownDocument + +proc `=copy`(dst: var Marmot, src: Marmot) {.error.} +proc `=copy`(dst: var Markdown, src: Markdown) {.error.} + +proc `=destroy`(m: Marmot) = + if m.client != nil: + marmot_client_free(m.client) + +proc `=destroy`(m: Markdown) = + if m.doc != nil: + marmot_markdown_document_free(m.doc) + +# Take and free the thread-local detail for the most recent failure. +proc lastError(): string = + let msg = marmot_last_error_message() + result = if msg != nil: $msg else: "(no detail)" + marmot_string_free(msg) + +# Raise `MarmotError` (carrying the status code + last-error detail) unless +# `status` is OK. The single choke point every wrapper proc funnels through. +proc check(status: MarmotStatus, context: string) = + if status != MARMOT_STATUS_OK: + var e = newException(MarmotError, context & ": " & lastError()) + e.status = status + raise e + +proc open(root: string, relays: openArray[string]): Marmot = + # `allocCStringArray` copies each URL into C-owned memory that outlives the + # call. Borrowing `r.cstring` from the loop variable would dangle once the + # temporary Nim string is freed under ARC/ORC. + let arr = allocCStringArray(relays) + defer: deallocCStringArray(arr) + var client: ptr MarmotClient = nil + check(marmot_client_new(root.cstring, cast[ptr cstring](arr), csize_t(relays.len), addr client), + "client_new") + result.client = client + +proc isStopping(m: Marmot): bool = + var stopping = true + check(marmot_client_is_stopping(m.client, addr stopping), "is_stopping") + stopping + +# Best-effort start: OK online, a typed error offline. Returns the message on +# the offline path so the caller can report the outcome without failing. +proc tryStart(m: Marmot): Option[string] = + let st = marmot_client_start(m.client) + if st == MARMOT_STATUS_OK: none(string) else: some(lastError()) + +proc parseMarkdown(m: Marmot, text: string): Markdown = + var doc: ptr MarmotMarkdownDocument = nil + check(marmot_parse_markdown(m.client, text.cstring, addr doc), "parse_markdown") + result.doc = doc + +proc truncated(md: Markdown): bool = md.doc.truncated +proc blockCount(md: Markdown): int = md.doc.blocks_len.int + +# Reconstruct the plain text of an inline run by concatenating its Text and +# Code spans — a small walk of the inline tagged union. +proc inlineText(inlines: ptr UncheckedArray[MarmotMarkdownInline], n: csize_t): string = + for i in 0 ..< n.int: + let inl = inlines[i] + if inl.tag == MARMOT_MARKDOWN_INLINE_TEXT: + result.add $inl.text.content + elif inl.tag == MARMOT_MARKDOWN_INLINE_CODE: + result.add "`" & $inl.code.content & "`" + +# Walk the top-level blocks, classifying each by tag and reaching into the +# union body; reconstruct heading text from its inlines. Returns the heading +# count seen. +proc walk(md: Markdown): int = + let doc = md.doc + for i in 0 ..< doc.blocks_len.int: + let blk = doc.blocks[i] + if blk.tag == MARMOT_MARKDOWN_BLOCK_HEADING: + inc result + let h = blk.heading + echo "smoke(nim): block ", i, ": heading (h", h.level, ") \"", + inlineText(h.inlines, h.inlines_len), "\"" + elif blk.tag == MARMOT_MARKDOWN_BLOCK_PARAGRAPH: + let p = blk.paragraph + echo "smoke(nim): block ", i, ": paragraph \"", + inlineText(p.inlines, p.inlines_len), "\"" + elif blk.tag == MARMOT_MARKDOWN_BLOCK_CODE_BLOCK: + let c = blk.codeBlock + echo "smoke(nim): block ", i, ": code block (lang=", + (if c.info != nil: $c.info else: ""), ")" + elif blk.tag == MARMOT_MARKDOWN_BLOCK_LIST_BLOCK: + echo "smoke(nim): block ", i, ": list with ", blk.listBlock.items_len, " items" + else: + echo "smoke(nim): block ", i, ": other" + +proc accountCount(m: Marmot): int = + var accounts: ptr MarmotAccountSummaryList = nil + check(marmot_list_accounts(m.client, addr accounts), "list_accounts") + result = accounts.len.int + marmot_account_summary_list_free(accounts) + +# Directory lookup: an absent id resolves to OK with a NULL string. +proc npub(m: Marmot, accountIdHex: string): Option[string] = + var outNpub: cstring = nil + check(marmot_npub(m.client, accountIdHex.cstring, addr outNpub), "npub") + if outNpub == nil: + result = none(string) + else: + result = some($outNpub) + marmot_string_free(outNpub) + +proc nip65Relays(m: Marmot, accountRef: string): seq[string] = + var lst: ptr MarmotStringList = nil + check(marmot_account_nip65_relays(m.client, accountRef.cstring, addr lst), "nip65_relays") + # Reached only on success; the smoke run always exercises the error path. + marmot_string_list_free(lst) + +proc revealNsec(m: Marmot, accountRef: string): string = + var outNsec: cstring = nil + check(marmot_reveal_nsec(m.client, accountRef.cstring, addr outNsec), "reveal_nsec") + result = if outNsec != nil: $outNsec else: "" + marmot_string_free(outNsec) + +# Best-effort identity publish: returns the account id hex online, or raises +# the typed error offline/rejected. +proc createIdentity(m: Marmot, relays: openArray[string]): string = + # Owned C-string copies that outlive the call (see `open`). + let arr = allocCStringArray(relays) + defer: deallocCStringArray(arr) + let relayPtr = cast[ptr cstring](arr) + var summary: ptr MarmotAccountSummary = nil + check(marmot_create_identity(m.client, relayPtr, csize_t(relays.len), + relayPtr, csize_t(relays.len), addr summary), + "create_identity") + result = if summary.account_id_hex != nil: $summary.account_id_hex else: "" + marmot_account_summary_free(summary) + +proc shutdown(m: Marmot) = + check(marmot_client_shutdown(m.client), "shutdown") + +# -------------------------------------------------------------------------- + +proc expect(cond: bool, what: string) = + if not cond: + stdout.writeLine("smoke(nim): FAILED: " & what) + quit(1) + stdout.writeLine("smoke(nim): ok: " & what) + +proc main() = + if paramCount() != 1: + stderr.writeLine("usage: smoke_nim ") + quit(2) + let home = paramStr(1) + + # --- argument validation + NULL-free discipline (no client) -------- + block: + var client: ptr MarmotClient = nil + let st = marmot_client_new(nil, nil, 0, addr client) + expect(st == MARMOT_STATUS_NULL_POINTER, "NULL root_path rejected") + + # NULL is a no-op for every free function. + marmot_client_free(nil) + marmot_string_free(nil) + marmot_account_summary_list_free(nil) + marmot_markdown_document_free(nil) + stdout.writeLine("smoke(nim): ok: NULL frees are no-ops") + + # --- construct + lifecycle ----------------------------------------- + var marmot: Marmot + try: + marmot = open(home, ["wss://relay.example.org"]) + except MarmotError as e: + # Headless environments may lack a platform keychain; a documented + # limitation, not an ABI defect. + stdout.writeLine("smoke(nim): SKIP: " & e.msg) + quit(0) + expect(marmot.client != nil, "client constructed") + + expect(not marmot.isStopping(), "client not stopping") + + # start() may fail offline (dial-safety rejects unreachable relays); that + # still exercises the start + error path. Either outcome is fine. + let startErr = marmot.tryStart() + if startErr.isNone: + stdout.writeLine("smoke(nim): ok: client started") + else: + stdout.writeLine("smoke(nim): ok: client start returned offline error: " & startErr.get) + + # --- markdown: parse + DEEP walk of the recursive DTO tree --------- + const md = + "# Marmot\n\n" & + "A **bold** claim with `code` and a [link](https://example.org).\n\n" & + "- [x] dig burrow\n" & + "- [ ] store acorns\n\n" & + "```rust\nfn main() {}\n```\n" + let doc = marmot.parseMarkdown(md) + expect(not doc.truncated, "markdown not truncated") + expect(doc.blockCount >= 4, "markdown has heading + paragraph + list + code") + expect(doc.walk() == 1, "walked tree found exactly one heading") + + # --- offline reads ------------------------------------------------- + expect(marmot.accountCount() == 0, "fresh home has no accounts") + + # Directory lookup: the call resolves with OK (a well-formed id encodes to + # an npub; a malformed one yields absent). Either way it must not raise. + let unknownId = "0000000000000000000000000000000000000000000000000000000000000000" + discard marmot.npub(unknownId) + expect(true, "npub lookup call succeeds") + + # --- error taxonomy (hard asserts on typed status) ----------------- + try: + discard marmot.nip65Relays("no-such-account") + expect(false, "nip65 on unknown account should raise") + except MarmotError as e: + expect(e.status == MARMOT_STATUS_UNKNOWN_ACCOUNT, + "unknown account -> MARMOT_STATUS_UNKNOWN_ACCOUNT") + + try: + discard marmot.revealNsec("no-such-account") + expect(false, "reveal_nsec on unknown account should raise") + except MarmotError as e: + expect(e.status == MARMOT_STATUS_UNKNOWN_ACCOUNT, + "reveal_nsec on unknown account -> UNKNOWN_ACCOUNT") + + # --- best-effort identity (needs a relay) -------------------------- + try: + let id = marmot.createIdentity(["wss://relay.example.org"]) + stdout.writeLine("smoke(nim): ok: identity created: " & id) + except MarmotError as e: + stdout.writeLine("smoke(nim): ok: identity create offline: " & e.msg) + + # --- shutdown ------------------------------------------------------ + marmot.shutdown() + expect(marmot.isStopping(), "client reports stopping") + + # `marmot` (and its client) is freed by `=destroy` at scope exit. + stdout.writeLine("smoke(nim): all checks passed") + +main() diff --git a/crates/marmot-c/examples/smoke.odin b/crates/marmot-c/examples/smoke.odin new file mode 100644 index 00000000..18e8b43e --- /dev/null +++ b/crates/marmot-c/examples/smoke.odin @@ -0,0 +1,290 @@ +// Worked example / smoke test for the marmot-c ABI, in Odin. +// +// Odin has no C-header import, so the subset of the ABI used here is +// hand-declared in a `foreign` block. That doubles as a check that a non-C, +// non-cbindgen consumer can reproduce the struct layout and calling +// convention. It drives a realistic slice of the runtime and mirrors the +// FLOW of examples/smoke.c: argument validation, client lifecycle, the +// error + last-error paths, Markdown parsing, offline directory/list reads, +// the typed error taxonomy, and best-effort identity creation. +// +// Idiomatically this wraps the raw calls in a `Marmot` handle, threads a +// `Marmot_Error` (status + owned detail) through Odin's `or_return` and +// multiple-return `(value, err)` idiom, and cleans up with +// `defer marmot_close(&m)`. No out-parameter plumbing leaks into main. +// +// The Markdown DTO is only touched at DOCUMENT level here: the recursive +// tagged-union walk (heading/paragraph/list/code blocks and their inline +// runs) is a transcription hazard to hand-declare, so examples/smoke.c is +// the reference for that deep walk. +// +// Build & run (see c-smoke.sh for the canonical invocation). Links the +// SHARED library (so the run needs it on the loader path), which nicely +// complements the C/Zig tests that link the static archive: +// odin build examples/smoke.odin -file -out:smoke_odin \ +// -extra-linker-flags:"-Ltarget/debug -lmarmot_c -lm -lpthread -ldl" +// LD_LIBRARY_PATH=target/debug ./smoke_odin + +package smoke + +import "core:fmt" +import "core:os" +import "core:strings" + +// Opaque handles: only ever held by pointer. +Marmot_Client :: struct {} +Marmot_Markdown_Block :: struct {} +Marmot_String_List :: struct {} + +// Subset of MarmotStatus actually asserted here (values are stable ABI). +Marmot_Status :: enum i32 { + Ok = 0, + Null_Pointer = 1, + Unknown_Account = 11, +} + +// Mirror of MarmotMarkdownDocument (document level only — see file header). +Marmot_Markdown_Document :: struct { + blocks: ^Marmot_Markdown_Block, + blocks_len: uint, + truncated: bool, +} + +// Mirror of MarmotAccountSummary. +Marmot_Account_Summary :: struct { + label: cstring, + account_id_hex: cstring, + local_signing: bool, + signed_out: bool, + running: bool, +} + +// Mirror of MarmotAccountSummaryList. +Marmot_Account_Summary_List :: struct { + items: ^Marmot_Account_Summary, + len: uint, +} + +// Linked as a shared library: build with +// -extra-linker-flags:"-L -lmarmot_c -lm -lpthread -ldl" +foreign import marmot "system:marmot_c" + +@(default_calling_convention = "c") +foreign marmot { + marmot_client_new :: proc(root_path: cstring, relay_urls: [^]cstring, relay_urls_len: uint, out_client: ^^Marmot_Client) -> Marmot_Status --- + marmot_client_start :: proc(client: ^Marmot_Client) -> Marmot_Status --- + marmot_client_shutdown :: proc(client: ^Marmot_Client) -> Marmot_Status --- + marmot_client_is_stopping :: proc(client: ^Marmot_Client, out_stopping: ^bool) -> Marmot_Status --- + marmot_client_free :: proc(client: ^Marmot_Client) --- + marmot_last_error_message :: proc() -> cstring --- + marmot_string_free :: proc(s: cstring) --- + marmot_parse_markdown :: proc(client: ^Marmot_Client, text: cstring, out_document: ^^Marmot_Markdown_Document) -> Marmot_Status --- + marmot_markdown_document_free :: proc(document: ^Marmot_Markdown_Document) --- + marmot_list_accounts :: proc(client: ^Marmot_Client, out_list: ^^Marmot_Account_Summary_List) -> Marmot_Status --- + marmot_account_summary_list_free :: proc(list: ^Marmot_Account_Summary_List) --- + marmot_account_summary_free :: proc(summary: ^Marmot_Account_Summary) --- + marmot_account_nip65_relays :: proc(client: ^Marmot_Client, account_ref: cstring, out_list: ^^Marmot_String_List) -> Marmot_Status --- + marmot_npub :: proc(client: ^Marmot_Client, account_id_hex: cstring, out_npub: ^cstring) -> Marmot_Status --- + marmot_reveal_nsec :: proc(client: ^Marmot_Client, account_ref: cstring, out_nsec: ^cstring) -> Marmot_Status --- + marmot_create_identity :: proc(client: ^Marmot_Client, default_relays: [^]cstring, default_relays_len: uint, bootstrap_relays: [^]cstring, bootstrap_relays_len: uint, out_summary: ^^Marmot_Account_Summary) -> Marmot_Status --- +} + +// ---- idiomatic wrapper ----------------------------------------------------- + +// A non-Ok status plus the thread-local detail string, cloned into Odin +// storage. Modelled as a union so `nil` is the "no error" value, which is +// what lets it flow through Odin's `or_return`. Free with `free_err`. +Marmot_Fault :: struct { + status: Marmot_Status, + detail: string, +} +Marmot_Error :: union { + Marmot_Fault, +} + +// The check helper: turn a raw status into a Marmot_Error, reading +// marmot_last_error_message for the detail on failure (nil on success). +check :: proc(st: Marmot_Status) -> Marmot_Error { + if st == .Ok do return nil + detail: string + if msg := marmot_last_error_message(); msg != nil { + detail = strings.clone(string(msg)) + marmot_string_free(msg) + } + return Marmot_Fault{status = st, detail = detail} +} + +// Accessors that treat nil as success. +status_of :: proc(err: Marmot_Error) -> Marmot_Status { + if f, ok := err.(Marmot_Fault); ok do return f.status + return .Ok +} +detail_of :: proc(err: Marmot_Error) -> string { + if f, ok := err.(Marmot_Fault); ok do return f.detail + return "" +} +free_err :: proc(err: Marmot_Error) { + if f, ok := err.(Marmot_Fault); ok do delete(f.detail) +} + +// Owned client handle. Held by value; the pointer inside is the ABI object. +Marmot :: struct { + client: ^Marmot_Client, +} + +marmot_open :: proc(home: string, relays: []cstring) -> (m: Marmot, err: Marmot_Error) { + croot := strings.clone_to_cstring(home) + defer delete(croot) + check(marmot_client_new(croot, raw_data(relays), len(relays), &m.client)) or_return + return +} + +// RAII-style cleanup: shutdown (idempotent) then deep-free the handle. +// Safe on a zero/failed handle, so it works as a `defer` on every path. +marmot_close :: proc(m: ^Marmot) { + if m.client == nil do return + marmot_client_shutdown(m.client) + marmot_client_free(m.client) + m.client = nil +} + +is_stopping :: proc(m: Marmot) -> (stopping: bool, err: Marmot_Error) { + err = check(marmot_client_is_stopping(m.client, &stopping)) + return +} + +// Directory lookup: an absent id resolves to OK with a NULL string, which +// this returns as "". +npub_of :: proc(m: Marmot, account_id_hex: string) -> (npub: string, err: Marmot_Error) { + cid := strings.clone_to_cstring(account_id_hex) + defer delete(cid) + out: cstring + check(marmot_npub(m.client, cid, &out)) or_return + if out != nil { + npub = strings.clone(string(out)) + marmot_string_free(out) + } + return +} + +nip65_relays :: proc(m: Marmot, account_ref: string) -> Marmot_Error { + cref := strings.clone_to_cstring(account_ref) + defer delete(cref) + out: ^Marmot_String_List + return check(marmot_account_nip65_relays(m.client, cref, &out)) +} + +reveal_nsec :: proc(m: Marmot, account_ref: string) -> Marmot_Error { + cref := strings.clone_to_cstring(account_ref) + defer delete(cref) + out: cstring + err := check(marmot_reveal_nsec(m.client, account_ref = cref, out_nsec = &out)) + if out != nil do marmot_string_free(out) + return err +} + +// ---- assertions ------------------------------------------------------------ + +fail :: proc(what: string) -> ! { + fmt.eprintfln("smoke(odin): FAILED: %s", what) + os.exit(1) +} + +expect :: proc(condition: bool, what: string) { + if !condition do fail(what) + fmt.printfln("smoke(odin): ok: %s", what) +} + +main :: proc() { + if len(os.args) != 2 { + fmt.eprintln("usage: smoke_odin ") + os.exit(2) + } + home := os.args[1] + + // --- argument validation paths (no client needed) ------------------ + client: ^Marmot_Client + st := marmot_client_new(nil, nil, 0, &client) + expect(st == .Null_Pointer, "NULL root_path rejected") + + // NULL is a no-op for every free function. + marmot_client_free(nil) + marmot_string_free(nil) + marmot_account_summary_list_free(nil) + marmot_account_summary_free(nil) + marmot_markdown_document_free(nil) + fmt.println("smoke(odin): ok: NULL frees are no-ops") + + // --- construct + lifecycle ----------------------------------------- + relays := [?]cstring{"wss://relay.example.org"} + m, err := marmot_open(home, relays[:]) + if err != nil { + // No platform keychain (headless): a documented limitation. + fmt.printfln("smoke(odin): SKIP: no client (status %d): %s", i32(status_of(err)), detail_of(err)) + free_err(err) + os.exit(0) + } + defer marmot_close(&m) + fmt.println("smoke(odin): ok: client constructed") + + stopping, serr0 := is_stopping(m) + expect(serr0 == nil && !stopping, "client not stopping") + + // start() may fail offline (dial-safety); either outcome is fine. + if serr := check(marmot_client_start(m.client)); serr == nil { + fmt.println("smoke(odin): ok: client started") + } else { + fmt.printfln("smoke(odin): ok: client start offline (status %d): %s", i32(status_of(serr)), detail_of(serr)) + free_err(serr) + } + + // --- markdown: parse at document level (see file header) ----------- + doc: ^Marmot_Markdown_Document + md := "# Marmot\n\nA **bold** claim with `code` and a [link](https://example.org).\n\n- [x] dig burrow\n- [ ] store acorns\n\n```rust\nfn main() {}\n```\n" + cmd := strings.clone_to_cstring(md) + defer delete(cmd) + perr := check(marmot_parse_markdown(m.client, cmd, &doc)) + expect(perr == nil && doc != nil, "markdown parsed") + expect(!doc.truncated, "markdown not truncated") + expect(doc.blocks_len >= 4, "markdown has heading + paragraph + list + code") + marmot_markdown_document_free(doc) + + // --- offline reads ------------------------------------------------- + accounts: ^Marmot_Account_Summary_List + expect(check(marmot_list_accounts(m.client, &accounts)) == nil && accounts != nil, "list_accounts") + expect(accounts.len == 0, "fresh home has no accounts") + marmot_account_summary_list_free(accounts) + + // Directory lookup for an unknown id: the call resolves OK; an absent + // id comes back as a NULL string (here ""). + zero_id := "0000000000000000000000000000000000000000000000000000000000000000" + npub, nerr := npub_of(m, zero_id) + expect(nerr == nil, "npub lookup call succeeds") + delete(npub) + + // --- error taxonomy (hard asserts) --------------------------------- + nerr65 := nip65_relays(m, "no-such-account") + expect(status_of(nerr65) == .Unknown_Account, "nip65 on unknown account -> UNKNOWN_ACCOUNT") + free_err(nerr65) + rerr := reveal_nsec(m, "no-such-account") + expect(status_of(rerr) == .Unknown_Account, "reveal_nsec on unknown account -> UNKNOWN_ACCOUNT") + free_err(rerr) + + // --- best-effort identity (needs a relay) -------------------------- + summary: ^Marmot_Account_Summary + ierr := check(marmot_create_identity(m.client, raw_data(relays[:]), len(relays), raw_data(relays[:]), len(relays), &summary)) + if ierr == nil && summary != nil { + fmt.printfln("smoke(odin): ok: identity created: %s", summary.account_id_hex) + marmot_account_summary_free(summary) + } else { + fmt.printfln("smoke(odin): ok: identity create offline (status %d): %s", i32(status_of(ierr)), detail_of(ierr)) + free_err(ierr) + } + + // --- shutdown ------------------------------------------------------ + expect(check(marmot_client_shutdown(m.client)) == nil, "client shutdown") + stopping2, serr1 := is_stopping(m) + expect(serr1 == nil && stopping2, "client reports stopping") + + fmt.println("smoke(odin): all checks passed") +} diff --git a/crates/marmot-c/examples/smoke.php b/crates/marmot-c/examples/smoke.php new file mode 100644 index 00000000..9eacdf73 --- /dev/null +++ b/crates/marmot-c/examples/smoke.php @@ -0,0 +1,413 @@ + + */ + +declare(strict_types=1); + +/** + * Static-analysis view of the C functions the FFI handle defines at runtime. + * Declaring them as `@method` on an interface, then typing the handle as the + * intersection `\FFI&MarmotFfi`, lets editors and phpstan resolve calls that + * FFI would otherwise expose only dynamically. + * + * @method int marmot_client_new(?string $root, ?\FFI\CData $relays, int $len, \FFI\CData $out) + * @method int marmot_client_start(?\FFI\CData $client) + * @method int marmot_client_shutdown(?\FFI\CData $client) + * @method int marmot_client_is_stopping(?\FFI\CData $client, \FFI\CData $out) + * @method void marmot_client_free(?\FFI\CData $client) + * @method \FFI\CData|null marmot_last_error_message() + * @method void marmot_string_free(?\FFI\CData $s) + * @method int marmot_parse_markdown(?\FFI\CData $client, ?string $text, \FFI\CData $out) + * @method void marmot_markdown_document_free(?\FFI\CData $doc) + * @method int marmot_list_accounts(?\FFI\CData $client, \FFI\CData $out) + * @method void marmot_account_summary_list_free(?\FFI\CData $list) + * @method int marmot_account_nip65_relays(?\FFI\CData $client, ?string $ref, \FFI\CData $out) + * @method int marmot_npub(?\FFI\CData $client, ?string $accountIdHex, \FFI\CData $out) + * @method int marmot_reveal_nsec(?\FFI\CData $client, ?string $accountRef, \FFI\CData $out) + * @method int marmot_create_identity(?\FFI\CData $client, ?\FFI\CData $def, int $defLen, ?\FFI\CData $boot, int $bootLen, \FFI\CData $out) + * @method void marmot_account_summary_free(?\FFI\CData $summary) + */ +interface MarmotFfi +{ +} + +/** A non-OK MarmotStatus surfaced as an exception carrying the code + detail. */ +final class MarmotException extends RuntimeException +{ + public function __construct(public readonly int $status, string $message) + { + parent::__construct($message); + } +} + +/** A parsed markdown document, reduced to what this example inspects. */ +final class MarkdownDocument +{ + public function __construct( + public readonly int $blockCount, + public readonly bool $truncated, + ) { + } +} + +final class Marmot +{ + private const CDEF = <<<'CDEF' + typedef struct MarmotClient MarmotClient; + typedef struct MarmotStringList MarmotStringList; + typedef struct MarmotMarkdownBlock MarmotMarkdownBlock; + + typedef struct MarmotMarkdownDocument { + MarmotMarkdownBlock *blocks; + uintptr_t blocks_len; + bool truncated; + } MarmotMarkdownDocument; + + typedef struct MarmotAccountSummary { + char *label; + char *account_id_hex; + bool local_signing; + bool signed_out; + bool running; + } MarmotAccountSummary; + + typedef struct MarmotAccountSummaryList { + MarmotAccountSummary *items; + uintptr_t len; + } MarmotAccountSummaryList; + + int marmot_client_new(const char *root_path, const char *const *relay_urls, + uintptr_t relay_urls_len, MarmotClient **out_client); + int marmot_client_start(const MarmotClient *client); + int marmot_client_shutdown(const MarmotClient *client); + int marmot_client_is_stopping(const MarmotClient *client, bool *out_stopping); + void marmot_client_free(MarmotClient *client); + char *marmot_last_error_message(void); + void marmot_string_free(char *s); + int marmot_parse_markdown(const MarmotClient *client, const char *text, + MarmotMarkdownDocument **out_document); + void marmot_markdown_document_free(MarmotMarkdownDocument *document); + int marmot_list_accounts(const MarmotClient *client, MarmotAccountSummaryList **out_list); + void marmot_account_summary_list_free(MarmotAccountSummaryList *list); + int marmot_account_nip65_relays(const MarmotClient *client, const char *account_ref, + MarmotStringList **out_list); + int marmot_npub(const MarmotClient *client, const char *account_id_hex, char **out_npub); + int marmot_reveal_nsec(const MarmotClient *client, const char *account_ref, char **out_nsec); + int marmot_create_identity(const MarmotClient *client, + const char *const *default_relays, uintptr_t default_relays_len, + const char *const *bootstrap_relays, uintptr_t bootstrap_relays_len, + MarmotAccountSummary **out_summary); + void marmot_account_summary_free(MarmotAccountSummary *summary); + CDEF; + + // MarmotStatus values referenced by name (stable ABI). + public const STATUS_OK = 0; + public const STATUS_UNKNOWN_ACCOUNT = 11; + + /** @var \FFI&MarmotFfi */ + private FFI $ffi; + private ?FFI\CData $client; + + private function __construct(FFI $ffi, FFI\CData $client) + { + /** @var \FFI&MarmotFfi $ffi */ + $this->ffi = $ffi; + $this->client = $client; + } + + /** + * Open a client rooted at $root and connected to $relays. Throws + * MarmotException if the runtime rejects the configuration. + * + * @param list $relays + */ + public static function open(string $root, array $relays, ?string $libPath = null): self + { + /** @var \FFI&MarmotFfi $ffi */ + $ffi = FFI::cdef(self::CDEF, $libPath ?? (getenv('MARMOT_C_LIB') ?: 'libmarmot_c.so')); + + // Marshal the relay list into a C array of NUL-terminated strings. + $buffers = []; + $array = self::marshalStrings($ffi, $relays, $buffers); + + $out = $ffi->new('MarmotClient*'); + $status = $ffi->marmot_client_new($root, $array, count($relays), FFI::addr($out)); + if ($status !== self::STATUS_OK) { + throw new MarmotException($status, self::takeLastError($ffi)); + } + return new self($ffi, $out); + } + + /** + * Start the runtime. Returns null on success, or the runtime's error + * detail when it fails (e.g. offline, where dial-safety rejects the + * relay) so the caller can proceed in a degraded mode. + */ + public function tryStart(): ?string + { + $status = $this->ffi->marmot_client_start($this->client); + return $status === self::STATUS_OK ? null : self::takeLastError($this->ffi); + } + + public function isStopping(): bool + { + $flag = $this->ffi->new('bool'); + $this->check($this->ffi->marmot_client_is_stopping($this->client, FFI::addr($flag)), 'is_stopping'); + return (bool) $flag->cdata; + } + + public function parseMarkdown(string $text): MarkdownDocument + { + $out = $this->ffi->new('MarmotMarkdownDocument*'); + $this->check($this->ffi->marmot_parse_markdown($this->client, $text, FFI::addr($out)), 'parse_markdown'); + try { + return new MarkdownDocument((int) $out->blocks_len, (bool) $out->truncated); + } finally { + $this->ffi->marmot_markdown_document_free($out); + } + } + + /** Number of accounts known to this device. */ + public function accountCount(): int + { + $out = $this->ffi->new('MarmotAccountSummaryList*'); + $this->check($this->ffi->marmot_list_accounts($this->client, FFI::addr($out)), 'list_accounts'); + try { + return (int) $out->len; + } finally { + $this->ffi->marmot_account_summary_list_free($out); + } + } + + /** Throws MarmotException (status STATUS_UNKNOWN_ACCOUNT) for a bad ref. */ + public function nip65Relays(string $accountRef): void + { + $out = $this->ffi->new('MarmotStringList*'); + $this->check( + $this->ffi->marmot_account_nip65_relays($this->client, $accountRef, FFI::addr($out)), + 'nip65_relays', + ); + // (A real caller would read + free $out here; this example only needs + // the error path.) + } + + /** + * Directory lookup: the npub for a known account id, or null when the id + * is absent from the directory. Throws MarmotException on a call error. + */ + public function npub(string $accountIdHex): ?string + { + $out = $this->ffi->new('char*'); + $this->check($this->ffi->marmot_npub($this->client, $accountIdHex, FFI::addr($out)), 'npub'); + if (FFI::isNull($out)) { + return null; + } + try { + return FFI::string($out); + } finally { + $this->ffi->marmot_string_free($out); + } + } + + /** + * Reveal the nsec (private key) for a local account. Throws + * MarmotException (status STATUS_UNKNOWN_ACCOUNT) for an unknown ref. + */ + public function revealNsec(string $accountRef): string + { + $out = $this->ffi->new('char*'); + $this->check($this->ffi->marmot_reveal_nsec($this->client, $accountRef, FFI::addr($out)), 'reveal_nsec'); + try { + return FFI::isNull($out) ? '' : FFI::string($out); + } finally { + $this->ffi->marmot_string_free($out); + } + } + + /** + * Best-effort identity creation: publishes a fresh account to the given + * relays and returns its account_id_hex. Throws MarmotException when the + * runtime rejects it (e.g. offline, where the publish cannot reach a relay). + * + * @param list $relays default + bootstrap relay set for the account + */ + public function createIdentity(array $relays): string + { + $buffers = []; + $array = self::marshalStrings($this->ffi, $relays, $buffers); + $count = count($relays); + + $out = $this->ffi->new('MarmotAccountSummary*'); + $this->check( + $this->ffi->marmot_create_identity($this->client, $array, $count, $array, $count, FFI::addr($out)), + 'create_identity', + ); + try { + return FFI::string($out->account_id_hex); + } finally { + $this->ffi->marmot_account_summary_free($out); + } + } + + public function shutdown(): void + { + if ($this->client !== null) { + $this->ffi->marmot_client_shutdown($this->client); + } + } + + public function __destruct() + { + if ($this->client !== null) { + $this->ffi->marmot_client_free($this->client); + $this->client = null; + } + } + + private function check(int $status, string $op): void + { + if ($status !== self::STATUS_OK) { + throw new MarmotException($status, "$op: " . self::takeLastError($this->ffi)); + } + } + + /** + * Marshal a PHP string list into a C `char*[]` of NUL-terminated buffers. + * The owning buffers are appended to $buffers, which the caller must keep + * alive for the duration of the FFI call. + * + * @param list $strings + * @param list<\FFI\CData> $buffers out-param: backing buffers to pin + */ + private static function marshalStrings(FFI $ffi, array $strings, array &$buffers): FFI\CData + { + $count = max(count($strings), 1); // char*[0] is not a valid type + $array = $ffi->new("char*[$count]"); + foreach (array_values($strings) as $i => $s) { + // Owned buffers (PHP frees them when $buffers is collected); the + // caller keeps $buffers alive across the FFI call. + $buf = $ffi->new('char[' . (strlen($s) + 1) . ']'); + FFI::memcpy($buf, $s, strlen($s)); + $buffers[] = $buf; + $array[$i] = FFI::cast('char*', FFI::addr($buf)); + } + return $array; + } + + private static function takeLastError(FFI $ffi): string + { + /** @var \FFI&MarmotFfi $ffi */ + $msg = $ffi->marmot_last_error_message(); + if (FFI::isNull($msg)) { + return '(no detail)'; + } + try { + return FFI::string($msg); + } finally { + $ffi->marmot_string_free($msg); + } + } +} + +// --- smoke test ----------------------------------------------------------- + +/** @param non-empty-string $what */ +function ok(string $what): void +{ + fwrite(STDOUT, "smoke(php): ok: $what\n"); +} + +function fail(string $what): never +{ + fwrite(STDOUT, "smoke(php): FAILED: $what\n"); + exit(1); +} + +$home = $argv[1] ?? null; +if ($home === null) { + fwrite(STDERR, "usage: smoke.php \n"); + exit(2); +} + +try { + $marmot = Marmot::open($home, ['ws://127.0.0.1:7777']); +} catch (MarmotException $e) { + // Headless environments may lack a platform keychain; documented + // limitation, not an ABI defect. + fwrite(STDOUT, "smoke(php): SKIP: open failed (status {$e->status}): {$e->getMessage()}\n"); + exit(0); +} +ok('client constructed'); + +$marmot->isStopping() && fail('fresh client should not be stopping'); +ok('client not stopping'); + +// start() may fail offline (dial-safety rejects loopback relays); that still +// exercises the start + error path. Either outcome is fine. +$startError = $marmot->tryStart(); +ok($startError === null ? 'client started' : "client start offline: $startError"); + +$doc = $marmot->parseMarkdown( + "# Marmot\n\n" + . "A **bold** claim with `code` and a [link](https://example.org).\n\n" + . "- [x] dig burrow\n" + . "- [ ] store acorns\n\n" + . "```rust\nfn main() {}\n```\n" +); +$doc->blockCount >= 4 || fail('markdown should have heading + paragraph + list + code'); +ok('markdown parsed with ' . $doc->blockCount . ' blocks'); +$doc->truncated && fail('markdown should not be truncated'); +ok('markdown not truncated'); + +$marmot->accountCount() === 0 || fail('fresh home should have no accounts'); +ok('fresh home has no accounts'); + +// Directory lookup for an unknown id succeeds (absent id -> null or a +// derived npub); either way it must not raise. +$unknownId = str_repeat('0', 64); +$npub = $marmot->npub($unknownId); +ok('directory npub lookup returns ' . ($npub === null ? 'null (absent)' : $npub)); + +// Error taxonomy: both reads on an unknown account raise the same typed error. +foreach (['nip65Relays', 'revealNsec'] as $op) { + try { + $marmot->{$op}('no-such-account'); + fail("$op on unknown account should throw"); + } catch (MarmotException $e) { + $e->status === Marmot::STATUS_UNKNOWN_ACCOUNT || fail("$op: unexpected status {$e->status}"); + $e->getMessage() !== '' || fail("$op: exception should carry detail"); + ok("$op unknown account throws typed MarmotException"); + } +} + +// Best-effort: creating an identity needs a relay; offline it reports the +// typed error and the run continues. +try { + $accountId = $marmot->createIdentity(['wss://relay.example.org']); + ok("identity created: $accountId"); +} catch (MarmotException $e) { + ok("identity create offline (status {$e->status}): {$e->getMessage()}"); +} + +$marmot->shutdown(); +$marmot->isStopping() || fail('client should report stopping after shutdown'); +ok('client reports stopping after shutdown'); + +unset($marmot); // destructor frees the client +ok('client freed via destructor'); + +fwrite(STDOUT, "smoke(php): all checks passed\n"); diff --git a/crates/marmot-c/examples/smoke.zig b/crates/marmot-c/examples/smoke.zig new file mode 100644 index 00000000..5e763ad1 --- /dev/null +++ b/crates/marmot-c/examples/smoke.zig @@ -0,0 +1,317 @@ +// End-to-end smoke test for the marmot-c ABI from Zig. +// +// Imports the real marmot.h via @cImport, so the generated header is the +// single source of truth for signatures and struct layout. Exercises the +// same surface as examples/smoke.c: client lifecycle, error + last-error +// paths, a DEEP walk of the recursive Markdown DTO tree, offline reads, +// the error taxonomy, best-effort identity, and the free-the-root-only +// ownership rule. +// +// The calls are wrapped in a small `Marmot` type whose methods return Zig +// error unions (`MarmotError`); a helper maps any non-OK status to an +// error and stashes the thread-local last-error text. Cleanup uses +// `defer client.deinit()`, the natural Zig resource idiom. No naked +// out-parameter arrays leak into the top-level flow. +// +// Build & run (see c-smoke.sh for the canonical invocation): +// zig build-exe examples/smoke.zig -I include -lc -fllvm \ +// target/debug/libmarmot_c.a -lm -lpthread -ldl -lunwind \ +// -femit-bin=smoke_zig +// ./smoke_zig +// +// `-fllvm` selects the LLVM backend (a standard ELF the loader accepts); +// `-lunwind` supplies the `_Unwind_*` symbols the Rust staticlib needs +// (gcc/clang link these implicitly, Zig does not). + +const std = @import("std"); + +const c = @cImport({ + @cInclude("marmot.h"); +}); + +// --- error handling ------------------------------------------------------- + +const MarmotError = error{ + NullPointer, + UnknownAccount, + Failed, +}; + +// Thread-local last-error detail, copied out of the C string the runtime +// hands us (and freed) so callers can print it after the fact. +var last_error_buf: [1024]u8 = undefined; +var last_error_len: usize = 0; + +fn stashLastError() void { + const msg = c.marmot_last_error_message(); + if (msg == null) { + last_error_len = 0; + return; + } + const s = std.mem.sliceTo(msg, 0); + const n = @min(s.len, last_error_buf.len); + @memcpy(last_error_buf[0..n], s[0..n]); + last_error_len = n; + c.marmot_string_free(msg); +} + +fn lastError() []const u8 { + return if (last_error_len == 0) "(no detail)" else last_error_buf[0..last_error_len]; +} + +// Map a raw status into a Zig error, capturing the runtime's detail text +// on the failure path. OK is the only success value. +fn mapStatus(st: c.MarmotStatus) MarmotError!void { + if (st == c.MARMOT_STATUS_OK) return; + stashLastError(); + if (st == c.MARMOT_STATUS_NULL_POINTER) return MarmotError.NullPointer; + if (st == c.MARMOT_STATUS_UNKNOWN_ACCOUNT) return MarmotError.UnknownAccount; + return MarmotError.Failed; +} + +// --- idiomatic wrapper over the raw client ------------------------------- + +const Marmot = struct { + client: *c.MarmotClient, + + fn open(root: [*c]const u8, relays: []const [*c]const u8) MarmotError!Marmot { + var client: ?*c.MarmotClient = null; + try mapStatus(c.marmot_client_new(root, relays.ptr, relays.len, &client)); + return .{ .client = client.? }; + } + + fn deinit(self: Marmot) void { + c.marmot_client_free(self.client); + } + + fn isStopping(self: Marmot) MarmotError!bool { + var stopping: bool = false; + try mapStatus(c.marmot_client_is_stopping(self.client, &stopping)); + return stopping; + } + + fn start(self: Marmot) MarmotError!void { + try mapStatus(c.marmot_client_start(self.client)); + } + + fn shutdown(self: Marmot) MarmotError!void { + try mapStatus(c.marmot_client_shutdown(self.client)); + } + + fn parseMarkdown(self: Marmot, text: [*c]const u8) MarmotError!*c.MarmotMarkdownDocument { + var doc: ?*c.MarmotMarkdownDocument = null; + try mapStatus(c.marmot_parse_markdown(self.client, text, &doc)); + return doc.?; + } + + fn listAccounts(self: Marmot) MarmotError!*c.MarmotAccountSummaryList { + var list: ?*c.MarmotAccountSummaryList = null; + try mapStatus(c.marmot_list_accounts(self.client, &list)); + return list.?; + } + + // Absent directory entries resolve to OK with a NULL string; the + // caller owns and frees whatever comes back. + fn npub(self: Marmot, account_id_hex: [*c]const u8) MarmotError![*c]u8 { + var out: [*c]u8 = null; + try mapStatus(c.marmot_npub(self.client, account_id_hex, &out)); + return out; + } + + fn nip65Relays(self: Marmot, account_ref: [*c]const u8) MarmotError!*c.MarmotStringList { + var out: ?*c.MarmotStringList = null; + try mapStatus(c.marmot_account_nip65_relays(self.client, account_ref, &out)); + return out.?; + } + + fn revealNsec(self: Marmot, account_ref: [*c]const u8) MarmotError![*c]u8 { + var out: [*c]u8 = null; + try mapStatus(c.marmot_reveal_nsec(self.client, account_ref, &out)); + return out; + } + + fn createIdentity( + self: Marmot, + def_relays: []const [*c]const u8, + boot_relays: []const [*c]const u8, + ) MarmotError!*c.MarmotAccountSummary { + var out: ?*c.MarmotAccountSummary = null; + try mapStatus(c.marmot_create_identity( + self.client, + def_relays.ptr, + def_relays.len, + boot_relays.ptr, + boot_relays.len, + &out, + )); + return out.?; + } +}; + +// --- reporting helpers ---------------------------------------------------- + +fn fail(what: []const u8) noreturn { + std.debug.print("smoke(zig): FAILED: {s}\n", .{what}); + std.process.exit(1); +} + +fn expect(condition: bool, comptime what: []const u8) void { + if (!condition) fail(what); + std.debug.print("smoke(zig): ok: {s}\n", .{what}); +} + +// --- markdown walk (DEEP: reach into the tagged-union bodies) ------------ + +// Reconstruct the plain text of an inline run by concatenating its Text +// spans (and wrapping Code spans in backticks) — a walk of the inline +// tagged union, mirroring print_inline_text in smoke.c. +fn printInlineText(inlines: [*c]const c.MarmotMarkdownInline, len: usize) void { + std.debug.print("\"", .{}); + for (inlines[0..len]) |inl| { + if (inl.tag == c.MARMOT_MARKDOWN_INLINE_TEXT) { + std.debug.print("{s}", .{inl.unnamed_0.TEXT.content}); + } else if (inl.tag == c.MARMOT_MARKDOWN_INLINE_CODE) { + std.debug.print("`{s}`", .{inl.unnamed_0.CODE.content}); + } + } + std.debug.print("\"", .{}); +} + +// Classify each top-level block by its tag and reach into the union body, +// mirroring walk_markdown in smoke.c. Returns the heading count seen. +fn walkMarkdown(doc: *const c.MarmotMarkdownDocument) usize { + var headings: usize = 0; + for (doc.blocks[0..doc.blocks_len], 0..) |block, i| { + if (block.tag == c.MARMOT_MARKDOWN_BLOCK_HEADING) { + headings += 1; + const heading = block.unnamed_0.HEADING; + std.debug.print("smoke(zig): block {d}: heading (h{d}) ", .{ i, heading.level }); + printInlineText(heading.inlines, heading.inlines_len); + std.debug.print("\n", .{}); + } else if (block.tag == c.MARMOT_MARKDOWN_BLOCK_PARAGRAPH) { + std.debug.print( + "smoke(zig): block {d}: paragraph ({d} inlines)\n", + .{ i, block.unnamed_0.PARAGRAPH.inlines_len }, + ); + } else if (block.tag == c.MARMOT_MARKDOWN_BLOCK_CODE_BLOCK) { + const info = block.unnamed_0.CODE_BLOCK.info; + if (info == null) { + std.debug.print("smoke(zig): block {d}: code block (lang=)\n", .{i}); + } else { + std.debug.print("smoke(zig): block {d}: code block (lang={s})\n", .{ i, info }); + } + } else if (block.tag == c.MARMOT_MARKDOWN_BLOCK_LIST_BLOCK) { + std.debug.print( + "smoke(zig): block {d}: list with {d} items\n", + .{ i, block.unnamed_0.LIST_BLOCK.items_len }, + ); + } else { + std.debug.print("smoke(zig): block {d}: other\n", .{i}); + } + } + return headings; +} + +pub fn main() void { + var args = std.process.args(); + _ = args.next(); // argv[0] + const home = args.next() orelse { + std.debug.print("usage: smoke \n", .{}); + std.process.exit(2); + }; + + // --- argument validation + NULL-free discipline (no client) --------- + const no_relays = [_][*c]const u8{}; + if (Marmot.open(null, &no_relays)) |m| { + m.deinit(); + fail("NULL root_path should have been rejected"); + } else |err| { + expect(err == MarmotError.NullPointer, "NULL root_path rejected"); + expect(last_error_len > 0, "NULL root_path sets last-error"); + } + + // NULL is a no-op for every free function. + c.marmot_client_free(null); + c.marmot_string_free(null); + c.marmot_account_summary_list_free(null); + c.marmot_markdown_document_free(null); + std.debug.print("smoke(zig): ok: NULL frees are no-ops\n", .{}); + + // --- construct + lifecycle ------------------------------------------ + const relays = [_][*c]const u8{"wss://relay.example.org"}; + const client = Marmot.open(home.ptr, &relays) catch { + // Headless environments may lack a platform keychain; a documented + // limitation, not an ABI defect. + std.debug.print("smoke(zig): SKIP: client_new failed: {s}\n", .{lastError()}); + return; + }; + defer client.deinit(); + std.debug.print("smoke(zig): ok: client constructed\n", .{}); + + const stopping_before = client.isStopping() catch |e| fail(@errorName(e)); + expect(!stopping_before, "client not stopping"); + + // start() may fail offline (dial-safety rejects unreachable relays); + // that still exercises the start + error path. Either outcome is fine. + if (client.start()) { + std.debug.print("smoke(zig): ok: client started\n", .{}); + } else |_| { + std.debug.print("smoke(zig): ok: client start offline: {s}\n", .{lastError()}); + } + + // --- Markdown: parse + DEEP walk of the recursive DTO tree ---------- + const md = + "# Marmot\n\n" ++ + "A **bold** claim with `code` and a [link](https://example.org).\n\n" ++ + "- [x] dig burrow\n" ++ + "- [ ] store acorns\n\n" ++ + "```rust\nfn main() {}\n```\n"; + const doc = client.parseMarkdown(md) catch |e| fail(@errorName(e)); + defer c.marmot_markdown_document_free(doc); + expect(!doc.truncated, "markdown not truncated"); + expect(doc.blocks_len >= 4, "markdown has heading + paragraph + list + code"); + const headings = walkMarkdown(doc); + expect(headings == 1, "walked tree found exactly one heading"); + + // --- offline reads -------------------------------------------------- + const accounts = client.listAccounts() catch |e| fail(@errorName(e)); + defer c.marmot_account_summary_list_free(accounts); + expect(accounts.len == 0, "fresh home has no accounts"); + + // A directory lookup for an unknown id resolves to absent (NULL out). + const unknown_id = "0000000000000000000000000000000000000000000000000000000000000000"; + const npub = client.npub(unknown_id) catch |e| fail(@errorName(e)); + std.debug.print("smoke(zig): ok: npub lookup for absent id resolves\n", .{}); + c.marmot_string_free(npub); + + // --- error taxonomy (hard asserts) ---------------------------------- + if (client.nip65Relays("no-such-account")) |list| { + c.marmot_string_list_free(list); + fail("nip65_relays on unknown account should error"); + } else |err| { + expect(err == MarmotError.UnknownAccount, "unknown account -> UnknownAccount"); + } + + if (client.revealNsec("no-such-account")) |nsec| { + c.marmot_string_free(nsec); + fail("reveal_nsec on unknown account should error"); + } else |err| { + expect(err == MarmotError.UnknownAccount, "reveal_nsec unknown account -> UnknownAccount"); + } + + // --- best-effort identity (needs a relay) --------------------------- + if (client.createIdentity(&relays, &relays)) |summary| { + std.debug.print("smoke(zig): ok: identity created: {s}\n", .{summary.account_id_hex}); + c.marmot_account_summary_free(summary); + } else |_| { + std.debug.print("smoke(zig): ok: identity create offline: {s}\n", .{lastError()}); + } + + // --- shutdown ------------------------------------------------------- + client.shutdown() catch |e| fail(@errorName(e)); + std.debug.print("smoke(zig): ok: client shutdown\n", .{}); + const stopping_after = client.isStopping() catch |e| fail(@errorName(e)); + expect(stopping_after, "client reports stopping"); + + std.debug.print("smoke(zig): all checks passed\n", .{}); +} diff --git a/crates/marmot-c/include/marmot.h b/crates/marmot-c/include/marmot.h new file mode 100644 index 00000000..04fbb014 --- /dev/null +++ b/crates/marmot-c/include/marmot.h @@ -0,0 +1,5138 @@ +/* + * marmot.h — C ABI for the Marmot app runtime. + * + * Generated by cbindgen from crates/marmot-c; do not edit by hand. + * Regenerate with `just c-header`. + * + * Ownership rules: + * - Fallible functions return MarmotStatus; MARMOT_STATUS_OK is 0. + * Detail text for the calling thread's most recent failure comes from + * marmot_last_error_message() (free with marmot_string_free). + * - Structs returned by pointer are freed ONLY with their matching + * marmot_*_free function, which deep-frees every field. Never free + * fields individually and never free a struct twice. + * - Input structs and strings are borrowed: the library never frees or + * retains caller memory. + * - Callbacks run on runtime worker threads; item pointers passed to a + * callback are borrowed and valid only for the duration of the call. + */ + +#pragma once + +/* Generated with cbindgen:0.29.4 */ + +#include +#include +#include +#include + +/** + * Status code returned by every fallible `marmot_*` function. + * + * `MARMOT_STATUS_OK` (0) means success. Codes 1-9 are binding-level + * failures raised by `marmot-c` itself; codes 10+ mirror the runtime's + * typed error variants one-to-one. Retrieve the human-readable detail for + * the most recent failure on the current thread with + * `marmot_last_error_message()`. + */ +enum MarmotStatus +#if defined(__cplusplus) || __STDC_VERSION__ >= 202311L + : int32_t +#endif // defined(__cplusplus) || __STDC_VERSION__ >= 202311L + { + MARMOT_STATUS_OK = 0, + /** + * A required pointer argument was NULL. + */ + MARMOT_STATUS_NULL_POINTER = 1, + /** + * A string argument was not valid UTF-8. + */ + MARMOT_STATUS_INVALID_UTF8 = 2, + /** + * A Rust panic was caught at the FFI boundary. State may be + * inconsistent; treat as fatal. + */ + MARMOT_STATUS_PANIC_CAUGHT = 3, + /** + * A blocking subscription read timed out; no item was produced. + */ + MARMOT_STATUS_TIMEOUT = 4, + /** + * The subscription is closed (runtime shutdown or sender dropped); + * no further items will be produced. + */ + MARMOT_STATUS_CLOSED = 5, + MARMOT_STATUS_DUPLICATE_IDENTITY = 10, + MARMOT_STATUS_UNKNOWN_ACCOUNT = 11, + MARMOT_STATUS_UNKNOWN_GROUP = 12, + MARMOT_STATUS_INVALID_HEX = 13, + MARMOT_STATUS_INVALID_IDENTITY = 14, + MARMOT_STATUS_MISSING_KEY_PACKAGE = 15, + MARMOT_STATUS_PUBLISH = 16, + MARMOT_STATUS_TRANSPORT_CLOSED = 17, + MARMOT_STATUS_RUNTIME_STOPPING = 18, + MARMOT_STATUS_NOT_GROUP_ADMIN = 19, + MARMOT_STATUS_ADMIN_CANNOT_SELF_REMOVE = 20, + MARMOT_STATUS_WOULD_REMOVE_LAST_ADMIN = 21, + MARMOT_STATUS_MEMBER_NOT_IN_GROUP = 22, + MARMOT_STATUS_ALREADY_ADMIN = 23, + MARMOT_STATUS_NOT_ADMIN = 24, + MARMOT_STATUS_STORAGE_BUSY = 25, + MARMOT_STATUS_SECRET_NOT_FOUND = 26, + MARMOT_STATUS_KEYSTORE_UNAVAILABLE = 27, + MARMOT_STATUS_EMPTY_PASSPHRASE = 28, + MARMOT_STATUS_ENCRYPTION_FAILED = 29, + MARMOT_STATUS_IO = 30, + MARMOT_STATUS_RUNTIME = 31, + MARMOT_STATUS_EXTERNAL_SIGNER_UNAVAILABLE = 32, + MARMOT_STATUS_EXTERNAL_SIGNER_MISMATCH = 33, + MARMOT_STATUS_EXTERNAL_SIGNER_REJECTED = 34, +}; +#ifndef __cplusplus +#if __STDC_VERSION__ >= 202311L +typedef enum MarmotStatus MarmotStatus; +#else +typedef int32_t MarmotStatus; +#endif // __STDC_VERSION__ >= 202311L +#endif // __cplusplus + +/** + * A relay list the account is missing, as a stable typed variant clients + * localize without parsing strings. + */ +typedef enum MarmotMissingRelayListKind { + /** + * NIP-65 relay list — where this account publishes (outbox/write-side). + */ + MARMOT_MISSING_RELAY_LIST_KIND_NIP65, + /** + * Marmot inbox relay list — where this account receives (inbox/read-side). + */ + MARMOT_MISSING_RELAY_LIST_KIND_INBOX, +} MarmotMissingRelayListKind; + +/** + * Forensic audit data mode exposed to host apps. + */ +typedef enum MarmotAuditDataMode { + /** + * Default safety posture: obfuscated/hashed identifiers, no plaintext. + */ + MARMOT_AUDIT_DATA_MODE_OBFUSCATED_SENSITIVE_DATA, + /** + * Explicit opt-in: decrypted content and full identifiers where useful. + */ + MARMOT_AUDIT_DATA_MODE_FULL_DATA, +} MarmotAuditDataMode; + +/** + * Flavor of an autolink. + */ +typedef enum MarmotMarkdownAutolinkKind { + MARMOT_MARKDOWN_AUTOLINK_KIND_URI, + MARMOT_MARKDOWN_AUTOLINK_KIND_EMAIL, +} MarmotMarkdownAutolinkKind; + +/** + * Human-readable prefix of a bech32 Nostr entity. + */ +typedef enum MarmotMarkdownNostrHrp { + MARMOT_MARKDOWN_NOSTR_HRP_NPUB, + MARMOT_MARKDOWN_NOSTR_HRP_NOTE, + MARMOT_MARKDOWN_NOSTR_HRP_NEVENT, + MARMOT_MARKDOWN_NOSTR_HRP_NPROFILE, + MARMOT_MARKDOWN_NOSTR_HRP_NADDR, + MARMOT_MARKDOWN_NOSTR_HRP_NRELAY, +} MarmotMarkdownNostrHrp; + +/** + * How a code block was written in the source text. + */ +typedef enum MarmotMarkdownCodeBlockKind { + MARMOT_MARKDOWN_CODE_BLOCK_KIND_INDENTED, + MARMOT_MARKDOWN_CODE_BLOCK_KIND_FENCED, +} MarmotMarkdownCodeBlockKind; + +/** + * Per-column table alignment. + */ +typedef enum MarmotMarkdownAlignment { + MARMOT_MARKDOWN_ALIGNMENT_NONE, + MARMOT_MARKDOWN_ALIGNMENT_LEFT, + MARMOT_MARKDOWN_ALIGNMENT_CENTER, + MARMOT_MARKDOWN_ALIGNMENT_RIGHT, +} MarmotMarkdownAlignment; + +/** + * The local account's own membership in a group: an active `Member`, or a + * terminal state describing how it left — `Left` (a voluntary self-removal + * or declined invite) or `Removed` (evicted by another member). Surfaced on + * both the chat-list row and the group-detail record. + */ +typedef enum MarmotSelfMembership { + MARMOT_SELF_MEMBERSHIP_MEMBER, + MARMOT_SELF_MEMBERSHIP_LEFT, + MARMOT_SELF_MEMBERSHIP_REMOVED, +} MarmotSelfMembership; + +/** + * Coarse, privacy-safe reason a stored group failed session-open hydration + * and was quarantined. Carries no group/member ids, payloads, or key + * material — only a category the client can map to per-reason recovery + * guidance. + */ +typedef enum MarmotAppGroupHydrationQuarantineReason { + /** + * The MLS stack returned an error while loading the stored group state. + */ + MARMOT_APP_GROUP_HYDRATION_QUARANTINE_REASON_OPEN_MLS_LOAD_FAILED, + /** + * Marmot metadata referenced a group whose MLS state was missing. + */ + MARMOT_APP_GROUP_HYDRATION_QUARANTINE_REASON_OPEN_MLS_GROUP_MISSING, + /** + * Member credentials, account-identity proofs, or ratchet-tree export + * validation failed for the loaded MLS group. + */ + MARMOT_APP_GROUP_HYDRATION_QUARANTINE_REASON_MEMBER_VALIDATION_FAILED, + /** + * The Marmot group record could not be loaded or refreshed. + */ + MARMOT_APP_GROUP_HYDRATION_QUARANTINE_REASON_GROUP_RECORD_LOAD_FAILED, + /** + * Hydrate found a stranded pending commit, but recovery itself failed. + */ + MARMOT_APP_GROUP_HYDRATION_QUARANTINE_REASON_PENDING_COMMIT_RECOVERY_FAILED, +} MarmotAppGroupHydrationQuarantineReason; + +/** + * Platform mechanism that woke the process for background notification + * collection. Borrowed input to `marmot_collect_notifications_after_wake`. + */ +typedef enum MarmotNotificationWakeSource { + /** + * iOS APNs Notification Service Extension wake. + */ + MARMOT_NOTIFICATION_WAKE_SOURCE_APNS_NSE, + /** + * Android FCM data-message wake. + */ + MARMOT_NOTIFICATION_WAKE_SOURCE_FCM_DATA_MESSAGE, + /** + * Android foreground-service driven collection. + */ + MARMOT_NOTIFICATION_WAKE_SOURCE_ANDROID_FOREGROUND_SERVICE, + /** + * Explicit user- or app-initiated catch-up. + */ + MARMOT_NOTIFICATION_WAKE_SOURCE_MANUAL_CATCH_UP, +} MarmotNotificationWakeSource; + +/** + * Overall outcome of a background notification collection pass. + */ +typedef enum MarmotNotificationCollectionStatus { + /** + * New notifications were collected. + */ + MARMOT_NOTIFICATION_COLLECTION_STATUS_NEW_DATA, + /** + * The pass completed but nothing new arrived. + */ + MARMOT_NOTIFICATION_COLLECTION_STATUS_NO_DATA, + /** + * The pass failed; see the collection's `error` field. + */ + MARMOT_NOTIFICATION_COLLECTION_STATUS_FAILED, +} MarmotNotificationCollectionStatus; + +/** + * What kind of event a notification update describes. + */ +typedef enum MarmotNotificationTrigger { + /** + * A new message arrived in a group or DM. + */ + MARMOT_NOTIFICATION_TRIGGER_NEW_MESSAGE, + /** + * The account was invited to a group. + */ + MARMOT_NOTIFICATION_TRIGGER_GROUP_INVITE, +} MarmotNotificationTrigger; + +/** + * Native push platform a device token belongs to. Used both as a return + * field and as a borrowed input to `marmot_upsert_push_registration`. + */ +typedef enum MarmotPushPlatform { + /** + * Apple Push Notification service. + */ + MARMOT_PUSH_PLATFORM_APNS, + /** + * Firebase Cloud Messaging. + */ + MARMOT_PUSH_PLATFORM_FCM, +} MarmotPushPlatform; + +/** + * Why the timeline subscription raised an upsert. + */ +typedef enum MarmotTimelineUpdateTrigger { + MARMOT_TIMELINE_UPDATE_TRIGGER_NEW_MESSAGE, + MARMOT_TIMELINE_UPDATE_TRIGGER_MESSAGE_EDITED_OR_REPROJECTED, + MARMOT_TIMELINE_UPDATE_TRIGGER_REACTION_ADDED, + MARMOT_TIMELINE_UPDATE_TRIGGER_REACTION_REMOVED, + MARMOT_TIMELINE_UPDATE_TRIGGER_MESSAGE_DELETED, + MARMOT_TIMELINE_UPDATE_TRIGGER_REPLY_PREVIEW_CHANGED, + MARMOT_TIMELINE_UPDATE_TRIGGER_AGENT_STREAM_STARTED, + MARMOT_TIMELINE_UPDATE_TRIGGER_AGENT_STREAM_FINISHED, + MARMOT_TIMELINE_UPDATE_TRIGGER_AGENT_ACTIVITY, + MARMOT_TIMELINE_UPDATE_TRIGGER_AGENT_OPERATION, + MARMOT_TIMELINE_UPDATE_TRIGGER_GROUP_SYSTEM, + MARMOT_TIMELINE_UPDATE_TRIGGER_DELIVERY_OR_SEND_STATE_CHANGED, + MARMOT_TIMELINE_UPDATE_TRIGGER_RECEIPT_CHANGED, + MARMOT_TIMELINE_UPDATE_TRIGGER_SNAPSHOT_REFRESH, +} MarmotTimelineUpdateTrigger; + +/** + * Why a message left the subscribed timeline window. + */ +typedef enum MarmotTimelineRemoveReason { + MARMOT_TIMELINE_REMOVE_REASON_INVALIDATED, + MARMOT_TIMELINE_REMOVE_REASON_CLEARED, + MARMOT_TIMELINE_REMOVE_REASON_PRUNED, + MARMOT_TIMELINE_REMOVE_REASON_NO_LONGER_MATCHES_QUERY, +} MarmotTimelineRemoveReason; + +/** + * Why a chat-list subscription update fired. + */ +typedef enum MarmotChatListUpdateTrigger { + MARMOT_CHAT_LIST_UPDATE_TRIGGER_NEW_GROUP, + MARMOT_CHAT_LIST_UPDATE_TRIGGER_NEW_LAST_MESSAGE, + MARMOT_CHAT_LIST_UPDATE_TRIGGER_LAST_MESSAGE_DELETED, + MARMOT_CHAT_LIST_UPDATE_TRIGGER_ARCHIVE_CHANGED, + MARMOT_CHAT_LIST_UPDATE_TRIGGER_PENDING_CONFIRMATION_CHANGED, + MARMOT_CHAT_LIST_UPDATE_TRIGGER_MEMBERSHIP_CHANGED, + MARMOT_CHAT_LIST_UPDATE_TRIGGER_UNREAD_CHANGED, + MARMOT_CHAT_LIST_UPDATE_TRIGGER_SNAPSHOT_REFRESH, + MARMOT_CHAT_LIST_UPDATE_TRIGGER_REMOVED, +} MarmotChatListUpdateTrigger; + +/** + * Opaque handle to a live agent-text-stream watch: incremental `Chunk`s, + * then a terminal `Finished` / `Failed`, after which the stream closes. + * Created by `marmot_watch_agent_text_stream`; the matching anchor/start + * command is `marmot_start_agent_text_stream` in the commands layer. + */ +typedef struct MarmotAgentStreamSubscription MarmotAgentStreamSubscription; + +/** + * Opaque handle to one account's durable chat-list projection: an initial + * row snapshot, then row upserts (`next`) or raw deltas including row + * removals (`next_update`). + */ +typedef struct MarmotChatListSubscription MarmotChatListSubscription; + +/** + * Opaque handle to one account's chats list: an initial snapshot of every + * group projection, then one record per projection change. + */ +typedef struct MarmotChatsSubscription MarmotChatsSubscription; + +/** + * Opaque handle to a running Marmot client: the app runtime plus the + * tokio runtime that drives it. Create with `marmot_client_new`, destroy + * with `marmot_client_free`. + */ +typedef struct MarmotClient MarmotClient; + +/** + * Opaque handle to the top-level event firehose: one subscription, every + * account, every event type. Broadcast lag is skipped silently — catch + * back up via the per-account subscriptions. + */ +typedef struct MarmotEventsSubscription MarmotEventsSubscription; + +/** + * Opaque handle to one group's state: an initial record snapshot, then + * the full record after each member/profile/roster change. + */ +typedef struct MarmotGroupStateSubscription MarmotGroupStateSubscription; + +/** + * Opaque handle to a message stream: an initial record snapshot, then one + * message update per store change. + */ +typedef struct MarmotMessagesSubscription MarmotMessagesSubscription; + +/** + * Opaque handle to the notification pipeline: local-notification updates + * produced by the runtime (foreground receipt, background collection, …). + */ +typedef struct MarmotNotificationsSubscription MarmotNotificationsSubscription; + +/** + * Opaque handle to one conversation's materialized timeline window. + * `next` returns the full authoritative window after each update; + * `next_update` returns the raw delta; pagination extends the window + * without blocking a concurrent `next`. + */ +typedef struct MarmotTimelineSubscription MarmotTimelineSubscription; + +/** + * One signed-in (or signed-out but known) account. + */ +typedef struct MarmotAccountSummary { + char *label; + char *account_id_hex; + bool local_signing; + bool signed_out; + bool running; +} MarmotAccountSummary; + +/** + * Owned list of account summaries (`marmot_list_accounts`). + */ +typedef struct MarmotAccountSummaryList { + struct MarmotAccountSummary *items; + uintptr_t len; +} MarmotAccountSummaryList; + +/** + * Per-account unread aggregate for the account-switcher badge. + */ +typedef struct MarmotAccountUnread { + char *account_id_hex; + /** + * Total unread messages across all unarchived conversations. + */ + uint64_t unread_count; + /** + * Number of unarchived conversations with at least one unread message. + */ + uint64_t unread_conversations; + /** + * Whether the account has any unread message at all. + */ + bool has_unread; +} MarmotAccountUnread; + +/** + * Owned list of unread aggregates (`marmot_account_unread_summary`). + */ +typedef struct MarmotAccountUnreadList { + struct MarmotAccountUnread *items; + uintptr_t len; +} MarmotAccountUnreadList; + +/** + * Per-group leave failure inside a wipe outcome. Best-effort: the wipe + * does not abort on these. + */ +typedef struct MarmotGroupLeaveFailure { + char *group_id_hex; + char *reason; +} MarmotGroupLeaveFailure; + +/** + * Per-relay KeyPackage deletion (or discovery) failure. + */ +typedef struct MarmotRelayFailure { + char *event_id_hex; + char *reason; +} MarmotRelayFailure; + +/** + * Local cleanup result inside a sign-out/wipe outcome. + */ +typedef struct MarmotLocalCleanupReport { + bool completed; + /** + * Failure classification when not completed. Nullable. + */ + char *reason; +} MarmotLocalCleanupReport; + +/** + * Structured result of the destructive sign-out-and-wipe. + */ +typedef struct MarmotWipeOutcome { + /** + * Active MLS groups this account successfully left. + */ + uint32_t groups_left; + struct MarmotGroupLeaveFailure *group_leave_failures; + uintptr_t group_leave_failures_len; + /** + * Relay-published KeyPackage events successfully deleted. + */ + uint32_t key_packages_deleted; + struct MarmotRelayFailure *key_package_failures; + uintptr_t key_package_failures_len; + struct MarmotLocalCleanupReport local_cleanup; +} MarmotWipeOutcome; + +/** + * Structured result of the non-destructive sign-out. + */ +typedef struct MarmotSignOutOutcome { + /** + * Relay-published KeyPackage events successfully deleted. `0` when + * KeyPackage deletion was not requested. + */ + uint32_t key_packages_deleted; + struct MarmotRelayFailure *key_package_failures; + uintptr_t key_package_failures_len; + struct MarmotLocalCleanupReport local_cleanup; +} MarmotSignOutOutcome; + +/** + * Owned list of plain strings. Shared root for every command that returns + * a list of strings (e.g. `marmot_account_nip65_relays`). + */ +typedef struct MarmotStringList { + char **items; + uintptr_t len; +} MarmotStringList; + +/** + * One published (or locally known) MLS KeyPackage. + */ +typedef struct MarmotAccountKeyPackage { + /** + * Account label, when known. Nullable. + */ + char *account_ref; + char *account_id_hex; + char *key_package_id; + char *key_package_ref_hex; + char *event_id_hex; + uint64_t published_at; + uint64_t key_package_bytes; + char **source_relays; + uintptr_t source_relays_len; + bool local; + bool relay; +} MarmotAccountKeyPackage; + +/** + * Owned list of key packages (`marmot_account_key_packages`). + */ +typedef struct MarmotAccountKeyPackageList { + struct MarmotAccountKeyPackage *items; + uintptr_t len; +} MarmotAccountKeyPackageList; + +/** + * One published relay list (NIP-65 or Marmot inbox) for an account. + */ +typedef struct MarmotRelayList { + /** + * Nostr event kind of the published list. + */ + uint64_t kind; + char **relays; + uintptr_t relays_len; +} MarmotRelayList; + +/** + * Per-account relay lists: the NIP-65 and inbox lists the account has + * published, plus the configured default/bootstrap sets + * (`marmot_account_relay_lists`). + */ +typedef struct MarmotAccountRelayLists { + /** + * Whether both required relay lists have been published. + */ + bool complete; + enum MarmotMissingRelayListKind *missing; + uintptr_t missing_len; + char **default_relays; + uintptr_t default_relays_len; + char **bootstrap_relays; + uintptr_t bootstrap_relays_len; + struct MarmotRelayList nip65; + struct MarmotRelayList inbox; +} MarmotAccountRelayLists; + +/** + * Nostr user profile metadata. All fields nullable. Used both as a + * return value (owned; free the root) and as a borrowed input to + * `marmot_publish_user_profile` (caller-owned; this library never frees + * input structs). + */ +typedef struct MarmotUserProfileMetadata { + char *name; + char *display_name; + char *about; + char *picture; + char *nip05; + char *lud16; +} MarmotUserProfileMetadata; + +/** + * Result of anchoring a live agent text stream start in the encrypted + * group history (`marmot_start_agent_text_stream`). + */ +typedef struct MarmotAgentStreamStart { + /** + * Hex-encoded 32-byte stream id (generated when the caller omitted one). + */ + char *stream_id_hex; + /** + * Number of relays the anchor was published to. + */ + uint32_t published; + /** + * Ids of the published anchor message(s). + */ + char **message_ids; + uintptr_t message_ids_len; +} MarmotAgentStreamStart; + +/** + * Local forensic audit-log recording settings. Recording is opt-in. Used + * both as a return value (owned; free the root) and as a borrowed input to + * `marmot_set_audit_log_settings` (caller-owned; this library never frees + * input structs). + */ +typedef struct MarmotAuditLogSettings { + bool enabled; + enum MarmotAuditDataMode data_mode; +} MarmotAuditLogSettings; + +/** + * Optional human source labels attached to tracker uploads. All fields + * nullable. + */ +typedef struct MarmotAuditLogUploadSource { + char *device_label; + char *platform; + char *app_version; +} MarmotAuditLogUploadSource; + +/** + * Tracker upload config supplied by the host app. Write-only across the + * boundary: `authorization_bearer_token` is accepted as input but the + * config returned by `marmot_set_audit_log_tracker_config` never echoes it + * back — secrets flow in, not out. Used both as a return value (owned; free + * the root) and as a borrowed input (caller-owned; this library never frees + * input structs). + */ +typedef struct MarmotAuditLogTrackerConfig { + /** + * Optional Goggles upload URL override. Nullable. + */ + char *endpoint; + /** + * Bearer token from the host app. Nullable. Always NULL on returned + * configs. + */ + char *authorization_bearer_token; + struct MarmotAuditLogUploadSource source; +} MarmotAuditLogTrackerConfig; + +/** + * One local JSONL forensic audit log file available for explicit upload + * or deletion. + */ +typedef struct MarmotAuditLogFile { + char *account_ref; + char *path; + char *file_name; + uint64_t size_bytes; + /** + * `modified_at_ms` is only meaningful when `has_modified_at_ms` is true. + */ + bool has_modified_at_ms; + uint64_t modified_at_ms; +} MarmotAuditLogFile; + +/** + * Owned list of audit log files (`marmot_audit_log_files`). + */ +typedef struct MarmotAuditLogFileList { + struct MarmotAuditLogFile *items; + uintptr_t len; +} MarmotAuditLogFileList; + +/** + * Result of POSTing one JSONL audit log to a forensic analyzer endpoint. + */ +typedef struct MarmotAuditLogUploadResult { + char *path; + /** + * HTTP status code returned by the analyzer endpoint. + */ + uint16_t status; + uint64_t bytes_sent; +} MarmotAuditLogUploadResult; + +/** + * Result of deleting one local JSONL audit log file. + */ +typedef struct MarmotAuditLogDeleteResult { + /** + * `true` when a live recorder was rotated and is already recording to a + * fresh file; `false` when the file was simply removed (no live recorder, + * or audit logging off). + */ + bool still_recording; +} MarmotAuditLogDeleteResult; + +/** + * Result of POSTing all local audit logs to the configured tracker. + * Disabled or unconfigured states are structured skips, not errors. + */ +typedef struct MarmotAuditLogTrackerUpdateResult { + /** + * Whether forensic audit logging was enabled when the update ran. + */ + bool enabled; + struct MarmotAuditLogUploadResult *uploaded; + uintptr_t uploaded_len; + /** + * Why the update was skipped, when it was. Nullable. + */ + char *skipped_reason; +} MarmotAuditLogTrackerUpdateResult; + +/** + * Group avatar reference. `image_key_hex` is the symmetric key that decrypts + * the avatar blob and `image_upload_key_hex` is the Blossom upload secret — + * both are key material. Host apps must not log or otherwise stringify this + * struct; there is no redaction at the C ABI. + */ +typedef struct MarmotChatListAvatar { + char *image_hash_hex; + /** + * Symmetric key that decrypts the avatar blob. Key material — do not log. + */ + char *image_key_hex; + char *image_nonce_hex; + /** + * Blossom upload secret. Key material — do not log. + */ + char *image_upload_key_hex; + /** + * MIME type of the avatar blob, when known. Nullable. + */ + char *media_type; +} MarmotChatListAvatar; + +/** + * A recognized Nostr entity reference inside message text. + */ +typedef struct MarmotMarkdownNostrEntity { + enum MarmotMarkdownNostrHrp hrp; + char *bech32; +} MarmotMarkdownNostrEntity; + +/** + * One inline-level Markdown node. Child inlines are owned `(ptr, len)` + * arrays freed by the parent. + */ +typedef enum MarmotMarkdownInline_Tag { + MARMOT_MARKDOWN_INLINE_TEXT, + MARMOT_MARKDOWN_INLINE_SOFT_BREAK, + MARMOT_MARKDOWN_INLINE_HARD_BREAK, + MARMOT_MARKDOWN_INLINE_CODE, + MARMOT_MARKDOWN_INLINE_EMPH, + MARMOT_MARKDOWN_INLINE_STRONG, + MARMOT_MARKDOWN_INLINE_STRIKETHROUGH, + MARMOT_MARKDOWN_INLINE_LINK, + MARMOT_MARKDOWN_INLINE_IMAGE, + MARMOT_MARKDOWN_INLINE_AUTOLINK, + MARMOT_MARKDOWN_INLINE_MATH, + MARMOT_MARKDOWN_INLINE_NOSTR_MENTION, + MARMOT_MARKDOWN_INLINE_NOSTR_URI, +} MarmotMarkdownInline_Tag; + +typedef struct MarmotMarkdownInline_Text_Body { + char *content; +} MarmotMarkdownInline_Text_Body; + +typedef struct MarmotMarkdownInline_Code_Body { + char *content; +} MarmotMarkdownInline_Code_Body; + +typedef struct MarmotMarkdownInline_Emph_Body { + struct MarmotMarkdownInline *children; + uintptr_t children_len; +} MarmotMarkdownInline_Emph_Body; + +typedef struct MarmotMarkdownInline_Strong_Body { + struct MarmotMarkdownInline *children; + uintptr_t children_len; +} MarmotMarkdownInline_Strong_Body; + +typedef struct MarmotMarkdownInline_Strikethrough_Body { + struct MarmotMarkdownInline *children; + uintptr_t children_len; +} MarmotMarkdownInline_Strikethrough_Body; + +typedef struct MarmotMarkdownInline_Link_Body { + char *dest; + /** + * Nullable. + */ + char *title; + struct MarmotMarkdownInline *children; + uintptr_t children_len; +} MarmotMarkdownInline_Link_Body; + +typedef struct MarmotMarkdownInline_Image_Body { + char *dest; + /** + * Nullable. + */ + char *title; + struct MarmotMarkdownInline *alt; + uintptr_t alt_len; +} MarmotMarkdownInline_Image_Body; + +typedef struct MarmotMarkdownInline_Autolink_Body { + char *url; + enum MarmotMarkdownAutolinkKind kind; +} MarmotMarkdownInline_Autolink_Body; + +typedef struct MarmotMarkdownInline_Math_Body { + char *content; +} MarmotMarkdownInline_Math_Body; + +typedef struct MarmotMarkdownInline_NostrMention_Body { + struct MarmotMarkdownNostrEntity entity; +} MarmotMarkdownInline_NostrMention_Body; + +typedef struct MarmotMarkdownInline_NostrUri_Body { + struct MarmotMarkdownNostrEntity entity; +} MarmotMarkdownInline_NostrUri_Body; + +typedef struct MarmotMarkdownInline { + MarmotMarkdownInline_Tag tag; + union { + MarmotMarkdownInline_Text_Body TEXT; + MarmotMarkdownInline_Code_Body CODE; + MarmotMarkdownInline_Emph_Body EMPH; + MarmotMarkdownInline_Strong_Body STRONG; + MarmotMarkdownInline_Strikethrough_Body STRIKETHROUGH; + MarmotMarkdownInline_Link_Body LINK; + MarmotMarkdownInline_Image_Body IMAGE; + MarmotMarkdownInline_Autolink_Body AUTOLINK; + MarmotMarkdownInline_Math_Body MATH; + MarmotMarkdownInline_NostrMention_Body NOSTR_MENTION; + MarmotMarkdownInline_NostrUri_Body NOSTR_URI; + }; +} MarmotMarkdownInline; + +/** + * List flavor: bullet or ordered. + */ +typedef enum MarmotMarkdownListKind_Tag { + /** + * `marker` is a single-character string: "-", "*", or "+". + */ + MARMOT_MARKDOWN_LIST_KIND_BULLET, + /** + * `delimiter` is a single-character string: "." or ")". + */ + MARMOT_MARKDOWN_LIST_KIND_ORDERED, +} MarmotMarkdownListKind_Tag; + +typedef struct MarmotMarkdownListKind_Bullet_Body { + char *marker; +} MarmotMarkdownListKind_Bullet_Body; + +typedef struct MarmotMarkdownListKind_Ordered_Body { + uint32_t start; + char *delimiter; +} MarmotMarkdownListKind_Ordered_Body; + +typedef struct MarmotMarkdownListKind { + MarmotMarkdownListKind_Tag tag; + union { + MarmotMarkdownListKind_Bullet_Body BULLET; + MarmotMarkdownListKind_Ordered_Body ORDERED; + }; +} MarmotMarkdownListKind; + +/** + * One list item: nested blocks plus an optional task-list checkbox. + */ +typedef struct MarmotMarkdownListItem { + struct MarmotMarkdownBlock *blocks; + uintptr_t blocks_len; + /** + * `has_checked` is false for plain bullets/ordered items; otherwise + * `checked` is false for `[ ]` and true for `[x]`. + */ + bool has_checked; + bool checked; +} MarmotMarkdownListItem; + +/** + * One table cell: a run of inline nodes. + */ +typedef struct MarmotMarkdownTableCell { + struct MarmotMarkdownInline *inlines; + uintptr_t inlines_len; +} MarmotMarkdownTableCell; + +/** + * One table body row (mirror of one `Vec` inside `rows`). + */ +typedef struct MarmotMarkdownTableRow { + struct MarmotMarkdownTableCell *cells; + uintptr_t cells_len; +} MarmotMarkdownTableRow; + +/** + * One block-level Markdown node. Child blocks and inlines are owned + * `(ptr, len)` arrays freed by the parent. + */ +typedef enum MarmotMarkdownBlock_Tag { + MARMOT_MARKDOWN_BLOCK_PARAGRAPH, + MARMOT_MARKDOWN_BLOCK_HEADING, + MARMOT_MARKDOWN_BLOCK_THEMATIC_BREAK, + MARMOT_MARKDOWN_BLOCK_CODE_BLOCK, + MARMOT_MARKDOWN_BLOCK_BLOCK_QUOTE, + /** + * Named `ListBlock` (not `List`) to match the sibling `*Block` variants. + */ + MARMOT_MARKDOWN_BLOCK_LIST_BLOCK, + MARMOT_MARKDOWN_BLOCK_TABLE, + MARMOT_MARKDOWN_BLOCK_MATH_BLOCK, +} MarmotMarkdownBlock_Tag; + +typedef struct MarmotMarkdownBlock_Paragraph_Body { + struct MarmotMarkdownInline *inlines; + uintptr_t inlines_len; +} MarmotMarkdownBlock_Paragraph_Body; + +typedef struct MarmotMarkdownBlock_Heading_Body { + uint8_t level; + struct MarmotMarkdownInline *inlines; + uintptr_t inlines_len; +} MarmotMarkdownBlock_Heading_Body; + +typedef struct MarmotMarkdownBlock_CodeBlock_Body { + enum MarmotMarkdownCodeBlockKind kind; + char *info; + char *content; +} MarmotMarkdownBlock_CodeBlock_Body; + +typedef struct MarmotMarkdownBlock_BlockQuote_Body { + struct MarmotMarkdownBlock *blocks; + uintptr_t blocks_len; +} MarmotMarkdownBlock_BlockQuote_Body; + +typedef struct MarmotMarkdownBlock_ListBlock_Body { + struct MarmotMarkdownListKind kind; + bool tight; + struct MarmotMarkdownListItem *items; + uintptr_t items_len; +} MarmotMarkdownBlock_ListBlock_Body; + +typedef struct MarmotMarkdownBlock_Table_Body { + enum MarmotMarkdownAlignment *alignments; + uintptr_t alignments_len; + struct MarmotMarkdownTableCell *header; + uintptr_t header_len; + struct MarmotMarkdownTableRow *rows; + uintptr_t rows_len; +} MarmotMarkdownBlock_Table_Body; + +typedef struct MarmotMarkdownBlock_MathBlock_Body { + char *content; +} MarmotMarkdownBlock_MathBlock_Body; + +typedef struct MarmotMarkdownBlock { + MarmotMarkdownBlock_Tag tag; + union { + MarmotMarkdownBlock_Paragraph_Body PARAGRAPH; + MarmotMarkdownBlock_Heading_Body HEADING; + MarmotMarkdownBlock_CodeBlock_Body CODE_BLOCK; + MarmotMarkdownBlock_BlockQuote_Body BLOCK_QUOTE; + MarmotMarkdownBlock_ListBlock_Body LIST_BLOCK; + MarmotMarkdownBlock_Table_Body TABLE; + MarmotMarkdownBlock_MathBlock_Body MATH_BLOCK; + }; +} MarmotMarkdownBlock; + +/** + * A parsed Markdown document: the ordered top-level blocks. + */ +typedef struct MarmotMarkdownDocument { + struct MarmotMarkdownBlock *blocks; + uintptr_t blocks_len; + /** + * True when the input exceeded the FFI Markdown safety cap and the + * blocks were parsed from a UTF-8-boundary prefix. + */ + bool truncated; +} MarmotMarkdownDocument; + +/** + * Preview of the most recent message in a conversation, shown on the + * chat-list row. + */ +typedef struct MarmotChatListMessagePreview { + char *message_id_hex; + char *sender; + /** + * Resolved display name of the sender, when known. Nullable. + */ + char *sender_display_name; + char *plaintext; + /** + * Parsed Markdown display tokens for the preview text. Owned by this + * struct and freed with it — never individually. + */ + struct MarmotMarkdownDocument content_tokens; + /** + * Inner Marmot app event kind of the previewed message. + */ + uint64_t kind; + uint64_t timeline_at; + bool deleted; +} MarmotChatListMessagePreview; + +/** + * One conversation row on the chat list: identity, display fields, unread + * counters, and the latest message preview. + */ +typedef struct MarmotChatListRow { + char *group_id_hex; + bool archived; + bool pending_confirmation; + char *title; + char *group_name; + /** + * Plain avatar URL, when set. Nullable. + */ + char *avatar_url; + /** + * Encrypted group avatar reference, when set. Nullable; owned by the row. + */ + struct MarmotChatListAvatar *avatar; + /** + * Most recent message preview, when the group has one. Nullable; owned + * by the row. + */ + struct MarmotChatListMessagePreview *last_message; + uint64_t unread_count; + bool has_unread; + uint64_t unread_mention_count; + bool unread_mention; + /** + * First unread message id, when known. Nullable. + */ + char *first_unread_message_id_hex; + /** + * Last read message id, when known. Nullable. + */ + char *last_read_message_id_hex; + /** + * `last_read_timeline_at` is only meaningful when + * `has_last_read_timeline_at` is true; otherwise it is zero. + */ + bool has_last_read_timeline_at; + uint64_t last_read_timeline_at; + uint64_t updated_at; + /** + * Whether the local account is still a member of this group, and if not, + * whether it left voluntarily or was removed. + */ + enum MarmotSelfMembership self_membership; +} MarmotChatListRow; + +/** + * Owned list of chat-list rows (`marmot_chat_list` and the chat-list + * subscription snapshot). + */ +typedef struct MarmotChatListRowList { + struct MarmotChatListRow *items; + uintptr_t len; +} MarmotChatListRowList; + +/** + * A normalized member reference (`marmot_normalize_member_ref`): the + * canonical hex account id plus its `npub` encoding. + */ +typedef struct MarmotMemberRef { + char *member_ref; + char *account_id_hex; + char *npub; +} MarmotMemberRef; + +/** + * One row of the group membership roster. + */ +typedef struct MarmotAppGroupMemberRecord { + char *member_id_hex; + /** + * Local account label when the member maps to a signed-in account. + * Nullable. + */ + char *account; + bool local; +} MarmotAppGroupMemberRecord; + +/** + * Owned list of membership roster rows (`marmot_group_members`). + */ +typedef struct MarmotAppGroupMemberRecordList { + struct MarmotAppGroupMemberRecord *items; + uintptr_t len; +} MarmotAppGroupMemberRecordList; + +/** + * One default blob endpoint for encrypted media uploads. Used both as an + * owned output (nested in the encrypted-media component) and as a borrowed + * input element of `marmot_replace_encrypted_media_blob_endpoints` + * (caller-owned; this library never frees input structs). + */ +typedef struct MarmotAppBlobEndpoint { + char *locator_kind; + char *base_url; +} MarmotAppBlobEndpoint; + +/** + * The group's `marmot.group.encrypted-media.v1` component: whether encrypted + * media is required, the media format, and which blob locators/endpoints the + * group allows. + */ +typedef struct MarmotAppGroupEncryptedMediaComponent { + uint32_t component_id; + char *component; + bool required; + char *media_format; + char **allowed_locator_kinds; + uintptr_t allowed_locator_kinds_len; + struct MarmotAppBlobEndpoint *default_blob_endpoints; + uintptr_t default_blob_endpoints_len; +} MarmotAppGroupEncryptedMediaComponent; + +/** + * One group as projected for the chat list and group-detail screens. + */ +typedef struct MarmotAppGroupRecord { + char *group_id_hex; + char *endpoint; + char *name; + char *description; + char **admins; + uintptr_t admins_len; + char **relays; + uintptr_t relays_len; + char *nostr_group_id_hex; + /** + * URL-based group avatar (`marmot.group.avatar-url.v1`), NULL when + * absent. When set it takes precedence over a Blossom image avatar. + */ + char *avatar_url; + char *avatar_dim; + char *avatar_thumbhash; + /** + * Blossom-hosted group image content hash (hex), NULL when absent; + * fetch + decrypt via the group image download command. An `avatar_url` + * when present takes precedence. + */ + char *image_hash_hex; + struct MarmotAppGroupEncryptedMediaComponent encrypted_media; + /** + * Per-group disappearing-message retention in seconds + * (`marmot.group.message-retention.v1`). `0` means messages never expire. + */ + uint64_t disappearing_message_secs; + bool archived; + bool pending_confirmation; + /** + * Whether the local account is still a member of this group, and if not, + * whether it left voluntarily or was removed. + */ + enum MarmotSelfMembership self_membership; + char *welcomer_account_id_hex; + char *via_welcome_message_id_hex; +} MarmotAppGroupRecord; + +/** + * One enriched member row for the group-detail screen: roster data plus + * admin/self flags, `npub`, and a cached display name when known. + */ +typedef struct MarmotGroupMemberDetails { + char *member_id_hex; + /** + * Local account label when the member maps to a signed-in account. + * Nullable. + */ + char *account; + bool local; + bool is_admin; + bool is_self; + char *npub; + /** + * Cached profile display name when known. Nullable. + */ + char *display_name; +} MarmotGroupMemberDetails; + +/** + * Group plus enriched member rows for detail screens + * (`marmot_group_details`). + */ +typedef struct MarmotGroupDetails { + struct MarmotAppGroupRecord group; + struct MarmotGroupMemberDetails *members; + uintptr_t members_len; +} MarmotGroupDetails; + +/** + * Per-member action availability for the group-management UI. + */ +typedef struct MarmotGroupMemberActionState { + char *member_id_hex; + bool is_self; + bool is_admin; + bool can_remove; + bool can_promote; + bool can_demote; +} MarmotGroupMemberActionState; + +/** + * Current caller permissions plus per-member action availability + * (`marmot_group_management_state`). + */ +typedef struct MarmotGroupManagementState { + char *my_account_id_hex; + bool is_self_admin; + bool is_last_admin; + bool can_invite; + bool can_leave; + bool requires_self_demote_before_leave; + struct MarmotGroupMemberActionState *member_actions; + uintptr_t member_actions_len; +} MarmotGroupManagementState; + +/** + * Publish outcome for send-shaped operations. + */ +typedef struct MarmotSendSummary { + uint32_t published; + char **message_ids; + uintptr_t message_ids_len; +} MarmotSendSummary; + +/** + * Result of declining a group invite: the updated group record plus the + * publish summary of the decline. + */ +typedef struct MarmotGroupInviteDeclineResult { + struct MarmotAppGroupRecord group; + struct MarmotSendSummary summary; +} MarmotGroupInviteDeclineResult; + +/** + * Combined result of a `*_detailed` group mutation: the publish summary + * plus the refreshed group details and management state. + */ +typedef struct MarmotGroupMutationResult { + struct MarmotSendSummary summary; + struct MarmotGroupDetails details; + struct MarmotGroupManagementState management_state; +} MarmotGroupMutationResult; + +/** + * MLS-level group state for the conversation's developer/debug view: the + * current epoch, live member count, and the app components the group + * requires. + */ +typedef struct MarmotAppGroupMlsState { + char *group_id_hex; + uint64_t epoch; + uint32_t member_count; + uint16_t *required_app_components; + uintptr_t required_app_components_len; +} MarmotAppGroupMlsState; + +/** + * A stored group that failed session-open hydration and was skipped so the + * rest of the account could open. Surfaced so the app can present a + * per-group recovery flow distinct from healthy and archived groups, and + * offer a non-destructive re-hydration retry. + */ +typedef struct MarmotAppQuarantinedGroup { + char *group_id_hex; + enum MarmotAppGroupHydrationQuarantineReason reason; +} MarmotAppQuarantinedGroup; + +/** + * Owned list of quarantined groups (`marmot_quarantined_groups`). + */ +typedef struct MarmotAppQuarantinedGroupList { + struct MarmotAppQuarantinedGroup *items; + uintptr_t len; +} MarmotAppQuarantinedGroupList; + +/** + * One place an encrypted media blob can be fetched from (e.g. a + * `blossom-v1` URL). Used both inside owned attachment references returned + * by this library and inside caller-owned input references (this library + * never frees input structs). + */ +typedef struct MarmotMediaLocator { + /** + * Locator scheme, e.g. `blossom-v1`. + */ + char *kind; + /** + * Scheme-specific location value, e.g. the blob URL. + */ + char *value; +} MarmotMediaLocator; + +/** + * Fully-downloadable reference to one encrypted media attachment. Used + * both as a return value (owned, freed via its parent root) and as a + * borrowed input to send/download commands (caller-owned; this library + * never frees input structs). + */ +typedef struct MarmotMediaAttachmentReference { + struct MarmotMediaLocator *locators; + uintptr_t locators_len; + /** + * SHA-256 of the uploaded ciphertext blob, hex-encoded. + */ + char *ciphertext_sha256; + /** + * SHA-256 of the original plaintext, hex-encoded. + */ + char *plaintext_sha256; + /** + * AEAD nonce, hex-encoded. + */ + char *nonce_hex; + char *file_name; + /** + * MIME type of the plaintext, e.g. `image/png`. + */ + char *media_type; + /** + * Encrypted-media format version, e.g. `encrypted-media-v1`. + */ + char *version; + /** + * Group epoch whose media secret encrypted this attachment. + */ + uint64_t source_epoch; + /** + * Pixel dimensions as `WxH`, when known. Nullable. + */ + char *dim; + /** + * Thumbhash preview string, when known. Nullable. + */ + char *thumbhash; +} MarmotMediaAttachmentReference; + +/** + * One plaintext attachment to encrypt and upload. Caller-owned input to + * `marmot_upload_media`; this library never frees input structs. + */ +typedef struct MarmotMediaUploadAttachmentRequest { + char *file_name; + /** + * MIME type of the plaintext, e.g. `image/png`. + */ + char *media_type; + /** + * Plaintext bytes to encrypt and upload. NULL with `plaintext_len == 0` + * is an empty payload. + */ + uint8_t *plaintext; + uintptr_t plaintext_len; + /** + * Pixel dimensions as `WxH`, when known. Nullable. + */ + char *dim; + /** + * Thumbhash preview string, when known. Nullable. + */ + char *thumbhash; +} MarmotMediaUploadAttachmentRequest; + +/** + * Batch upload request: encrypt plaintext attachments, upload the + * ciphertext blobs, and optionally send the resulting references into the + * group. Caller-owned input to `marmot_upload_media`; this library never + * frees input structs. + */ +typedef struct MarmotMediaUploadRequest { + struct MarmotMediaUploadAttachmentRequest *attachments; + uintptr_t attachments_len; + /** + * Optional chat caption to send alongside the attachments. Nullable. + */ + char *caption; + /** + * Whether to send the uploaded references into the group immediately. + */ + bool send; + /** + * Blossom server override. Nullable for the account default. + */ + char *blossom_server; +} MarmotMediaUploadRequest; + +/** + * One uploaded attachment inside an upload result: the sendable reference + * plus the encrypted blob size. + */ +typedef struct MarmotMediaUploadAttachmentResult { + struct MarmotMediaAttachmentReference reference; + uint64_t encrypted_size_bytes; +} MarmotMediaUploadAttachmentResult; + +/** + * Result of `marmot_upload_media`: one entry per uploaded attachment, plus + * the publish summary when the request asked to send. + */ +typedef struct MarmotMediaUploadResult { + struct MarmotMediaUploadAttachmentResult *attachments; + uintptr_t attachments_len; + /** + * Publish summary when the request asked to send. Nullable. + */ + struct MarmotSendSummary *sent; +} MarmotMediaUploadResult; + +/** + * Result of `marmot_download_media`: the decrypted plaintext plus its + * original metadata. + */ +typedef struct MarmotMediaDownloadResult { + /** + * Decrypted plaintext bytes. NULL with `plaintext_len == 0` when empty. + */ + uint8_t *plaintext; + uintptr_t plaintext_len; + char *file_name; + /** + * MIME type of the plaintext, e.g. `image/png`. + */ + char *media_type; + /** + * Plaintext size in bytes. + */ + uint64_t size_bytes; +} MarmotMediaDownloadResult; + +/** + * One media attachment projected from group message history. The embedded + * `reference` can be passed back to `marmot_download_media`. + */ +typedef struct MarmotMediaRecord { + char *message_id_hex; + /** + * Zero-based position of this attachment within its carrying message. + */ + uint32_t attachment_index; + /** + * Message direction, e.g. `incoming` or `outgoing`. + */ + char *direction; + char *group_id_hex; + char *sender; + struct MarmotMediaAttachmentReference reference; + /** + * Chat caption carried alongside the attachment, when any. Nullable. + */ + char *caption; + uint64_t recorded_at; + uint64_t received_at; +} MarmotMediaRecord; + +/** + * Owned list of media records (`marmot_list_media`). + */ +typedef struct MarmotMediaRecordList { + struct MarmotMediaRecord *items; + uintptr_t len; +} MarmotMediaRecordList; + +/** + * Result of pruning expired disappearing messages: the number of rows + * removed and the ciphertext hashes of media blobs that became orphaned. + */ +typedef struct MarmotSecureDeleteExpiredResult { + uint64_t pruned_messages; + /** + * SHA-256 hex digests of media ciphertexts no longer referenced by any + * surviving message. + */ + char **media_ciphertext_sha256; + uintptr_t media_ciphertext_sha256_len; +} MarmotSecureDeleteExpiredResult; + +/** + * One Nostr tag from an inner Marmot app event, e.g. `["e", ""]` or an + * `["imeta", …]` media descriptor. Host apps branch on the inner event + * `kind` plus these tags instead of a fixed payload enum. + */ +typedef struct MarmotMessageTag { + char **values; + uintptr_t values_len; +} MarmotMessageTag; + +/** + * One stored application message row. + */ +typedef struct MarmotAppMessageRecord { + char *message_id_hex; + char *direction; + char *group_id_hex; + char *sender; + char *plaintext; + /** + * Parsed Markdown of `plaintext` for chat-shaped kinds; empty for + * non-chat kinds. Owned by this record and freed with it. + */ + struct MarmotMarkdownDocument content_tokens; + /** + * Nostr `kind` of the inner Marmot app event (9 chat, 7 reaction, ...). + */ + uint64_t kind; + /** + * Nostr `tags` of the inner Marmot app event. + */ + struct MarmotMessageTag *tags; + uintptr_t tags_len; + uint64_t recorded_at; + uint64_t received_at; +} MarmotAppMessageRecord; + +/** + * Owned list of message records (`marmot_messages`). + */ +typedef struct MarmotAppMessageRecordList { + struct MarmotAppMessageRecord *items; + uintptr_t len; +} MarmotAppMessageRecordList; + +/** + * Per-account notification preferences. + */ +typedef struct MarmotNotificationSettings { + char *account_ref; + char *account_id_hex; + /** + * Whether locally rendered notifications are enabled. + */ + bool local_notifications_enabled; + /** + * Whether native push (APNs/FCM) delivery is enabled. + */ + bool native_push_enabled; +} MarmotNotificationSettings; + +/** + * A user referenced by a notification (sender or receiver). + */ +typedef struct MarmotNotificationUser { + char *account_id_hex; + /** + * Display name, when known. Nullable. + */ + char *display_name; + /** + * Profile picture URL, when known. Nullable. + */ + char *picture_url; +} MarmotNotificationUser; + +/** + * One notification-worthy event, delivered via the notification + * subscription stream and inside background collections. + */ +typedef struct MarmotNotificationUpdate { + /** + * Stable dedup key for this notification. + */ + char *notification_key; + /** + * Stable key identifying the conversation for grouping/threading. + */ + char *conversation_key; + enum MarmotNotificationTrigger trigger; + char *account_ref; + char *account_id_hex; + /** + * Opaque MLS group id, hex-encoded (variable length; not a 32-byte + * Nostr route id). + */ + char *group_id_hex; + /** + * Group display name, when known. Nullable. + */ + char *group_name; + bool is_dm; + /** + * Whether the receiving account was mentioned. + */ + bool is_mention; + /** + * Message id, hex-encoded, when known. Nullable. + */ + char *message_id_hex; + struct MarmotNotificationUser sender; + struct MarmotNotificationUser receiver; + /** + * Short displayable preview of the message text. Nullable. + */ + char *preview_text; + /** + * Reaction emoji when this update is a reaction. Nullable. + */ + char *reaction_emoji; + /** + * Preview of the message that was reacted to. Nullable. + */ + char *reacted_to_preview; + /** + * Event timestamp in milliseconds since the Unix epoch. + */ + int64_t timestamp_ms; + /** + * Whether the event originated from the receiving account itself. + */ + bool is_from_self; +} MarmotNotificationUpdate; + +/** + * Result of a background collection pass + * (`marmot_collect_notifications_after_wake`). + */ +typedef struct MarmotBackgroundNotificationCollection { + enum MarmotNotificationCollectionStatus status; + struct MarmotNotificationUpdate *notifications; + uintptr_t notifications_len; + /** + * Failure detail when `status` is `Failed`. Nullable. + */ + char *error; +} MarmotBackgroundNotificationCollection; + +/** + * One account's local native-push registration + * (`marmot_push_registration`, `marmot_upsert_push_registration`). + */ +typedef struct MarmotPushRegistration { + /** + * Account label the registration belongs to. + */ + char *account_ref; + char *account_id_hex; + enum MarmotPushPlatform platform; + /** + * Privacy-safe fingerprint of the raw device token; the raw token is + * never returned. + */ + char *token_fingerprint; + /** + * Push-server pubkey the token was encrypted to. + */ + char *server_pubkey_hex; + /** + * Optional preferred relay for push delivery. Nullable. + */ + char *relay_hint; + int64_t created_at_ms; + int64_t updated_at_ms; + /** + * When the token was last shared into a group token list. `has_*` + * false means never shared. + */ + bool has_last_shared_at_ms; + int64_t last_shared_at_ms; +} MarmotPushRegistration; + +/** + * Local-member registration state inside a group push-debug snapshot. + */ +typedef struct MarmotLocalPushRegistrationDebug { + /** + * Whether the account has a local push registration at all. + */ + bool registered; + /** + * Whether the registration is complete enough to share into groups. + */ + bool shareable; + bool local_notifications_enabled; + bool native_push_enabled; + /** + * The local member's leaf index in the group, when known. `has_*` + * false means unknown. + */ + bool has_local_leaf_index; + uint32_t local_leaf_index; + /** + * Whether a token for the local member is cached in the group's + * token list. + */ + bool local_token_cached; +} MarmotLocalPushRegistrationDebug; + +/** + * One member's token entry inside a group push-debug snapshot. + */ +typedef struct MarmotGroupPushTokenDebugEntry { + char *member_id_hex; + uint32_t leaf_index; + enum MarmotPushPlatform platform; + /** + * Privacy-safe fingerprint of the member's device token. + */ + char *token_fingerprint; + char *server_pubkey_hex; + bool has_relay_hint; + /** + * Whether the leaf index is an active leaf in the current group state. + */ + bool active_leaf; + /** + * Whether the token's member id matches the member at that leaf. + */ + bool member_matches_active_leaf; + bool is_local_member; + int64_t updated_at_ms; +} MarmotGroupPushTokenDebugEntry; + +/** + * Aggregated push-token diagnostics for one group + * (`marmot_group_push_debug_info`). + */ +typedef struct MarmotGroupPushDebugInfo { + uint32_t total_token_count; + /** + * Tokens whose leaf is active and matches the member. + */ + uint32_t active_token_count; + /** + * Tokens pointing at removed or mismatched leaves. + */ + uint32_t stale_token_count; + uint32_t missing_relay_hint_count; + /** + * When the group token list was last updated. `has_*` false means + * no token list has been seen. + */ + bool has_last_token_list_updated_at_ms; + int64_t last_token_list_updated_at_ms; + struct MarmotLocalPushRegistrationDebug local_registration; + struct MarmotGroupPushTokenDebugEntry *tokens; + uintptr_t tokens_len; +} MarmotGroupPushDebugInfo; + +/** + * Live relay-plane connection health for the diagnostics view + * (`marmot_relay_health`). + */ +typedef struct MarmotRelayHealth { + bool sdk_backed; + uint32_t total_relays; + uint32_t initialized; + uint32_t pending; + uint32_t connecting; + uint32_t connected; + uint32_t disconnected; + uint32_t terminated; + uint32_t banned; + uint32_t sleeping; + uint32_t connection_attempts; + uint32_t connection_successes; +} MarmotRelayHealth; + +/** + * Device-wide relay telemetry export settings. Export is opt-in and stays + * inert until `export_enabled` is true and runtime/default config supplies a + * valid OTLP endpoint, bearer token, and resource attributes. Used both as a + * return value (owned; free the root) and as a borrowed input to + * `marmot_set_relay_telemetry_settings` (caller-owned; this library never + * frees input structs). + */ +typedef struct MarmotRelayTelemetrySettings { + bool export_enabled; + uint64_t export_interval_seconds; +} MarmotRelayTelemetrySettings; + +/** + * OTLP resource attributes supplied by the platform shell. Nested inside + * `MarmotRelayTelemetryRuntimeConfig`; borrowed when used as input. + */ +typedef struct MarmotRelayTelemetryResource { + char *service_version; + char *service_instance_id; + char *deployment_environment; + char *tenant; + char *os_type; + char *os_version; + /** + * Device model identifier, when known. Nullable. + */ + char *device_model_identifier; +} MarmotRelayTelemetryResource; + +/** + * Non-persisted OTLP runtime metadata supplied by the host app: optional + * metrics URL override, bearer token from the host app's build-time secret, + * and resource attributes from the platform shell. Borrowed input to + * `marmot_set_relay_telemetry_runtime_config` (caller-owned; this library + * never frees input structs). `authorization_bearer_token` is the OTLP push + * credential and is never logged or echoed back by this library. + */ +typedef struct MarmotRelayTelemetryRuntimeConfig { + /** + * Metrics endpoint URL override. Nullable. + */ + char *otlp_endpoint; + /** + * OTLP push credential. Nullable. + */ + char *authorization_bearer_token; + /** + * Resource attributes from the platform shell. Nullable. + */ + struct MarmotRelayTelemetryResource *resource; +} MarmotRelayTelemetryRuntimeConfig; + +/** + * Timeline query filter for `marmot_timeline_messages`. Caller-owned input; + * this library never frees input structs. All fields optional: NULL strings + * and `has_x == false` scalars mean "unset". + */ +typedef struct MarmotTimelineMessageQuery { + /** + * Restrict to one group (opaque MLS group id, hex). Nullable. + */ + char *group_id_hex; + /** + * Substring search over plaintext. Nullable. + */ + char *search; + /** + * Only messages before this timeline timestamp; set `has_before`. + */ + bool has_before; + uint64_t before; + /** + * Cursor message id paired with `before`. Nullable. + */ + char *before_message_id; + /** + * Only messages after this timeline timestamp; set `has_after`. + */ + bool has_after; + uint64_t after; + /** + * Cursor message id paired with `after`. Nullable. + */ + char *after_message_id; + /** + * Page-size cap; set `has_limit`. + */ + bool has_limit; + uint32_t limit; +} MarmotTimelineMessageQuery; + +/** + * Inline preview of the message a timeline row replies to. + */ +typedef struct MarmotTimelineReplyPreview { + char *message_id_hex; + char *sender; + char *plaintext; + struct MarmotMarkdownDocument content_tokens; + uint64_t kind; + /** + * Raw `imeta` media JSON of the previewed message. Nullable. + */ + char *media_json; + /** + * Fully-resolved, downloadable media references for the previewed + * message, built from its `imeta` tags + its own `source_epoch` using + * the same resolution and validation as `marmot_list_media`. Empty when + * the previewed message has no media or its `imeta` is malformed. + */ + struct MarmotMediaAttachmentReference *media; + uintptr_t media_len; + /** + * Raw agent text-stream JSON of the previewed message. Nullable. + */ + char *agent_text_stream_json; + bool deleted; +} MarmotTimelineReplyPreview; + +/** + * Parsed view of a kind-1210 group system row (member added/removed, + * rename, retention change, …). + */ +typedef struct MarmotGroupSystemEvent { + char *system_type; + /** + * Human-readable fallback from the row content. Prefer rendering from + * `system_type` plus the structured fields so clients can localize and + * render the local account as "you". + */ + char *text; + /** + * Account that performed the action. Nullable. + */ + char *actor_account_id_hex; + /** + * Account the action targeted. Nullable. + */ + char *subject_account_id_hex; + /** + * New group name for rename events. Nullable. + */ + char *name; + /** + * Previous group name for rename events. Nullable. + */ + char *old_name; + /** + * Previous disappearing-message retention in seconds; `0` means off. + * Only meaningful when `has_old_retention_seconds`. + */ + bool has_old_retention_seconds; + uint64_t old_retention_seconds; + /** + * New disappearing-message retention in seconds; `0` means off. + * Only meaningful when `has_new_retention_seconds`. + */ + bool has_new_retention_seconds; + uint64_t new_retention_seconds; +} MarmotGroupSystemEvent; + +/** + * One emoji tally inside a reaction summary. + */ +typedef struct MarmotTimelineReactionEmoji { + char *emoji; + /** + * Number of distinct senders that reacted with this emoji + * (`== senders_len`), surfaced so clients render the tally without + * counting. This is the authenticated reaction count only; clients + * overlay their own optimistic react/unreact and "did I react" state on + * top. + */ + uint32_t count; + char **senders; + uintptr_t senders_len; +} MarmotTimelineReactionEmoji; + +/** + * One individual reaction event by one sender on one target message. + */ +typedef struct MarmotTimelineUserReaction { + char *reaction_message_id_hex; + char *target_message_id_hex; + char *sender; + char *emoji; + uint64_t reacted_at; +} MarmotTimelineUserReaction; + +/** + * Aggregated reactions on one timeline message. + */ +typedef struct MarmotTimelineReactionSummary { + /** + * Reaction tallies pre-sorted by `count` descending, ties broken by + * `emoji` ascending, so clients render a stable tally without + * re-sorting. + */ + struct MarmotTimelineReactionEmoji *by_emoji; + uintptr_t by_emoji_len; + struct MarmotTimelineUserReaction *user_reactions; + uintptr_t user_reactions_len; +} MarmotTimelineReactionSummary; + +/** + * One projected timeline message row. + */ +typedef struct MarmotTimelineMessageRecord { + char *message_id_hex; + /** + * Delivery marker for own (`direction == "sent"`) messages. An own send + * commits and projects locally *before* it publishes, so a message that + * was committed but not yet delivered (e.g. sent offline / relay + * unreachable) carries NULL here — render it as pending/failed. On + * delivery/convergence the same row is upserted with the published + * source event id, so flip the UI to delivered once this becomes + * non-NULL. To re-drive delivery of a stuck pending message without + * minting a duplicate, call `marmot_retry_group_convergence` rather + * than re-sending the text. For received messages this is the + * originating event id and is always non-NULL. + */ + char *source_message_id_hex; + char *direction; + char *group_id_hex; + char *sender; + char *plaintext; + struct MarmotMarkdownDocument content_tokens; + uint64_t kind; + struct MarmotMessageTag *tags; + uintptr_t tags_len; + uint64_t timeline_at; + uint64_t received_at; + /** + * Id of the message this row replies to. Nullable. + */ + char *reply_to_message_id_hex; + /** + * Inline preview of the replied-to message. Nullable. + */ + struct MarmotTimelineReplyPreview *reply_preview; + /** + * Raw `imeta` media JSON. Nullable. + */ + char *media_json; + /** + * Fully-resolved, downloadable media references for this message, built + * from its `imeta` tags + its own `source_epoch` using the same + * resolution and validation as `marmot_list_media` (a `list_media` + * record and this row's `media` resolve identically for the same + * message). Empty when the message has no media; a malformed `imeta` + * attachment is dropped while the message still appears as text. + */ + struct MarmotMediaAttachmentReference *media; + uintptr_t media_len; + /** + * Raw agent text-stream JSON. Nullable. + */ + char *agent_text_stream_json; + /** + * Parsed view of kind-1210 group system rows. NULL for chat, reactions, + * stream rows, and malformed/free-text kind-1210 assertions. + */ + struct MarmotGroupSystemEvent *group_system; + struct MarmotTimelineReactionSummary reactions; + bool deleted; + /** + * Id of the deletion event when `deleted`. Nullable. + */ + char *deleted_by_message_id_hex; + /** + * Set when convergence invalidated this message (it landed on a losing + * branch). The message is kept as a "did not reach the group" tombstone + * instead of disappearing; the value is the engine invalidation reason + * (e.g. `LosingBranch`). NULL for delivered messages. + */ + char *invalidation_status; +} MarmotTimelineMessageRecord; + +/** + * One page of timeline messages (`marmot_timeline_messages`, subscription + * snapshot/next/paginate). + */ +typedef struct MarmotTimelinePage { + struct MarmotTimelineMessageRecord *messages; + uintptr_t messages_len; + bool has_more_before; + bool has_more_after; +} MarmotTimelinePage; + +/** + * One freshly delivered MLS application message. + */ +typedef struct MarmotReceivedMessage { + char *message_id_hex; + char *group_id_hex; + char *sender; + /** + * Sender's display name, when known. Nullable. + */ + char *sender_display_name; + char *plaintext; + /** + * Parsed Markdown of `plaintext` for chat-shaped kinds; empty for + * non-chat kinds. Owned by this record and freed with it. + */ + struct MarmotMarkdownDocument content_tokens; + /** + * Nostr `kind` of the inner Marmot app event. + */ + uint64_t kind; + /** + * Nostr `tags` of the inner Marmot app event. + */ + struct MarmotMessageTag *tags; + uintptr_t tags_len; + /** + * Source-event timestamp (seconds since epoch) for the MLS-delivered + * message. Clients should sort the timeline by this value so chronology + * reflects send time, not delivery time. Zero means the timestamp was + * unavailable at decode time. + */ + uint64_t recorded_at; +} MarmotReceivedMessage; + +/** + * A received message together with the account it was delivered to. + */ +typedef struct MarmotRuntimeMessageReceived { + char *account_id_hex; + char *account_label; + struct MarmotReceivedMessage message; +} MarmotRuntimeMessageReceived; + +/** + * One incremental change to a subscribed timeline window: a row upsert + * (with the full replacement record) or a row removal. + */ +typedef enum MarmotTimelineMessageChange_Tag { + MARMOT_TIMELINE_MESSAGE_CHANGE_UPSERT, + MARMOT_TIMELINE_MESSAGE_CHANGE_REMOVE, +} MarmotTimelineMessageChange_Tag; + +typedef struct MarmotTimelineMessageChange_Upsert_Body { + enum MarmotTimelineUpdateTrigger trigger; + struct MarmotTimelineMessageRecord message; +} MarmotTimelineMessageChange_Upsert_Body; + +typedef struct MarmotTimelineMessageChange_Remove_Body { + char *message_id_hex; + enum MarmotTimelineRemoveReason reason; +} MarmotTimelineMessageChange_Remove_Body; + +typedef struct MarmotTimelineMessageChange { + MarmotTimelineMessageChange_Tag tag; + union { + MarmotTimelineMessageChange_Upsert_Body UPSERT; + MarmotTimelineMessageChange_Remove_Body REMOVE; + }; +} MarmotTimelineMessageChange; + +/** + * One projection update for a single group: the refreshed window plus the + * incremental changes that produced it, and the group's refreshed chat-list + * row when it changed. + */ +typedef struct MarmotTimelineProjectionUpdate { + char *group_id_hex; + struct MarmotTimelineMessageRecord *messages; + uintptr_t messages_len; + struct MarmotTimelineMessageChange *changes; + uintptr_t changes_len; + /** + * Refreshed chat-list row for this group. Nullable. + */ + struct MarmotChatListRow *chat_list_row; + enum MarmotChatListUpdateTrigger chat_list_trigger; +} MarmotTimelineProjectionUpdate; + +/** + * A projection update tagged with the account it belongs to, for + * runtime-wide subscriptions that span accounts. + */ +typedef struct MarmotRuntimeProjectionUpdate { + char *account_id_hex; + char *account_label; + struct MarmotTimelineProjectionUpdate update; +} MarmotRuntimeProjectionUpdate; + +/** + * Per-group event kind delivered inside [`MarmotEvent::GroupEvent`]. Mirrors + * the engine's typed group events with privacy-safe scalar fields only: ids + * are hex-encoded, epochs are `u64`, and the deeply-nested inner enums + * (group-state change, app-message invalidation reason) are surfaced as + * stable low-cardinality tag strings rather than re-modeled in full. No + * payloads, ciphertext, plaintext, or key material cross the boundary. + */ +typedef enum MarmotGroupEventKind_Tag { + MARMOT_GROUP_EVENT_KIND_GROUP_CREATED, + MARMOT_GROUP_EVENT_KIND_GROUP_JOINED, + MARMOT_GROUP_EVENT_KIND_MESSAGE_RECEIVED, + MARMOT_GROUP_EVENT_KIND_APP_MESSAGE_INVALIDATED, + MARMOT_GROUP_EVENT_KIND_GROUP_STATE_CHANGED, + MARMOT_GROUP_EVENT_KIND_GROUP_HYDRATION_QUARANTINED, + MARMOT_GROUP_EVENT_KIND_EPOCH_CHANGED, + MARMOT_GROUP_EVENT_KIND_FORK_RECOVERED, + MARMOT_GROUP_EVENT_KIND_COMMIT_ROLLED_BACK, + /** + * Explicit withdrawal of every `GroupStateChanged` notification whose + * `origin_commit_id_hex` matches `invalidated_commit_id_hex`: branch + * selection superseded that commit, so the changes it announced never + * canonically happened. `reason` is a stable low-cardinality tag + * (`superseded_by_branch_selection`). + */ + MARMOT_GROUP_EVENT_KIND_GROUP_STATE_INVALIDATED, + MARMOT_GROUP_EVENT_KIND_GROUP_UNRECOVERABLE, + MARMOT_GROUP_EVENT_KIND_PENDING_COMMIT_RECOVERED, + MARMOT_GROUP_EVENT_KIND_GROUP_HYDRATION_RECOVERED, +} MarmotGroupEventKind_Tag; + +typedef struct MarmotGroupEventKind_GroupJoined_Body { + char *via_welcome_hex; + /** + * Member id of the welcomer, when known. Nullable. + */ + char *welcomer_id_hex; +} MarmotGroupEventKind_GroupJoined_Body; + +typedef struct MarmotGroupEventKind_MessageReceived_Body { + char *sender_id_hex; + uint64_t epoch; +} MarmotGroupEventKind_MessageReceived_Body; + +typedef struct MarmotGroupEventKind_AppMessageInvalidated_Body { + char *message_id_hex; + uint64_t epoch; + /** + * Stable low-cardinality tag (e.g. `losing_branch`, `beyond_anchor`). + */ + char *reason; + /** + * Reference to the previously-decrypted payload, when one exists. + * Nullable. + */ + char *decrypted_payload_ref; +} MarmotGroupEventKind_AppMessageInvalidated_Body; + +typedef struct MarmotGroupEventKind_GroupStateChanged_Body { + uint64_t epoch; + /** + * Member id of the actor, when known. Nullable. + */ + char *actor_id_hex; + /** + * Stable low-cardinality tag (e.g. `member_added`, `group_renamed`). + */ + char *change; + /** + * Commit id this change originated from, when known. Nullable. + */ + char *origin_commit_id_hex; +} MarmotGroupEventKind_GroupStateChanged_Body; + +typedef struct MarmotGroupEventKind_GroupHydrationQuarantined_Body { + enum MarmotAppGroupHydrationQuarantineReason reason; +} MarmotGroupEventKind_GroupHydrationQuarantined_Body; + +typedef struct MarmotGroupEventKind_EpochChanged_Body { + uint64_t from; + uint64_t to; +} MarmotGroupEventKind_EpochChanged_Body; + +typedef struct MarmotGroupEventKind_ForkRecovered_Body { + uint64_t source_epoch; + uint64_t recovered_epoch; + char *invalidated_commit_id_hex; +} MarmotGroupEventKind_ForkRecovered_Body; + +typedef struct MarmotGroupEventKind_CommitRolledBack_Body { + char *invalidated_commit_id_hex; +} MarmotGroupEventKind_CommitRolledBack_Body; + +typedef struct MarmotGroupEventKind_GroupStateInvalidated_Body { + uint64_t epoch; + char *invalidated_commit_id_hex; + char *reason; +} MarmotGroupEventKind_GroupStateInvalidated_Body; + +typedef struct MarmotGroupEventKind_PendingCommitRecovered_Body { + uint64_t recovered_epoch; +} MarmotGroupEventKind_PendingCommitRecovered_Body; + +typedef struct MarmotGroupEventKind_GroupHydrationRecovered_Body { + uint64_t recovered_epoch; +} MarmotGroupEventKind_GroupHydrationRecovered_Body; + +typedef struct MarmotGroupEventKind { + MarmotGroupEventKind_Tag tag; + union { + MarmotGroupEventKind_GroupJoined_Body GROUP_JOINED; + MarmotGroupEventKind_MessageReceived_Body MESSAGE_RECEIVED; + MarmotGroupEventKind_AppMessageInvalidated_Body APP_MESSAGE_INVALIDATED; + MarmotGroupEventKind_GroupStateChanged_Body GROUP_STATE_CHANGED; + MarmotGroupEventKind_GroupHydrationQuarantined_Body GROUP_HYDRATION_QUARANTINED; + MarmotGroupEventKind_EpochChanged_Body EPOCH_CHANGED; + MarmotGroupEventKind_ForkRecovered_Body FORK_RECOVERED; + MarmotGroupEventKind_CommitRolledBack_Body COMMIT_ROLLED_BACK; + MarmotGroupEventKind_GroupStateInvalidated_Body GROUP_STATE_INVALIDATED; + MarmotGroupEventKind_PendingCommitRecovered_Body PENDING_COMMIT_RECOVERED; + MarmotGroupEventKind_GroupHydrationRecovered_Body GROUP_HYDRATION_RECOVERED; + }; +} MarmotGroupEventKind; + +/** + * Top-level event firehose item delivered by the events subscription's + * `next`. Agent streams collapse to a single "agent stream activity" + * variant — host apps do not differentiate them at the surface level for v1. + * Rich payloads are carried by value so hosts read them without extra + * dereferences. + */ +typedef enum MarmotEvent_Tag { + MARMOT_EVENT_GROUP_JOINED, + MARMOT_EVENT_GROUP_STATE_UPDATED, + MARMOT_EVENT_MESSAGE_RECEIVED, + MARMOT_EVENT_PROJECTION_UPDATED, + MARMOT_EVENT_GROUP_EVENT, + MARMOT_EVENT_ACCOUNT_ERROR, + MARMOT_EVENT_AGENT_STREAM_ACTIVITY, + /** + * A confirmed create/invite could not deliver a welcome to + * `recipient_hex`; that member is in the group but unjoinable until the + * welcome is re-delivered via `redeliver_welcome(message_id_hex)`. + */ + MARMOT_EVENT_WELCOME_DELIVERY_PENDING, +} MarmotEvent_Tag; + +typedef struct MarmotEvent_GroupJoined_Body { + char *account_id_hex; + char *account_label; + char *group_id_hex; +} MarmotEvent_GroupJoined_Body; + +typedef struct MarmotEvent_GroupStateUpdated_Body { + char *account_id_hex; + char *account_label; + char *group_id_hex; +} MarmotEvent_GroupStateUpdated_Body; + +typedef struct MarmotEvent_MessageReceived_Body { + struct MarmotRuntimeMessageReceived received; +} MarmotEvent_MessageReceived_Body; + +typedef struct MarmotEvent_ProjectionUpdated_Body { + struct MarmotRuntimeProjectionUpdate update; +} MarmotEvent_ProjectionUpdated_Body; + +typedef struct MarmotEvent_GroupEvent_Body { + char *account_id_hex; + char *account_label; + char *group_id_hex; + struct MarmotGroupEventKind event; +} MarmotEvent_GroupEvent_Body; + +typedef struct MarmotEvent_AccountError_Body { + char *account_id_hex; + char *account_label; + char *message; +} MarmotEvent_AccountError_Body; + +typedef struct MarmotEvent_AgentStreamActivity_Body { + char *account_id_hex; + char *account_label; +} MarmotEvent_AgentStreamActivity_Body; + +typedef struct MarmotEvent_WelcomeDeliveryPending_Body { + char *account_id_hex; + char *account_label; + char *group_id_hex; + char *message_id_hex; + char *recipient_hex; +} MarmotEvent_WelcomeDeliveryPending_Body; + +typedef struct MarmotEvent { + MarmotEvent_Tag tag; + union { + MarmotEvent_GroupJoined_Body GROUP_JOINED; + MarmotEvent_GroupStateUpdated_Body GROUP_STATE_UPDATED; + MarmotEvent_MessageReceived_Body MESSAGE_RECEIVED; + MarmotEvent_ProjectionUpdated_Body PROJECTION_UPDATED; + MarmotEvent_GroupEvent_Body GROUP_EVENT; + MarmotEvent_AccountError_Body ACCOUNT_ERROR; + MarmotEvent_AgentStreamActivity_Body AGENT_STREAM_ACTIVITY; + MarmotEvent_WelcomeDeliveryPending_Body WELCOME_DELIVERY_PENDING; + }; +} MarmotEvent; + +/** + * Callback invoked with each event (borrowed; valid only during the + * call) and finally with NULL when the stream closes. + */ +typedef void (*MarmotEventCallback)(const struct MarmotEvent *event, void *user_data); + +/** + * One timeline subscription update: a full replacement page or an + * incremental projection update. Variants carry rich payloads by value so + * hosts read them without extra dereferences. + */ +typedef enum MarmotTimelineSubscriptionUpdate_Tag { + MARMOT_TIMELINE_SUBSCRIPTION_UPDATE_PAGE, + MARMOT_TIMELINE_SUBSCRIPTION_UPDATE_PROJECTION, +} MarmotTimelineSubscriptionUpdate_Tag; + +typedef struct MarmotTimelineSubscriptionUpdate_Page_Body { + struct MarmotTimelinePage page; +} MarmotTimelineSubscriptionUpdate_Page_Body; + +typedef struct MarmotTimelineSubscriptionUpdate_Projection_Body { + struct MarmotRuntimeProjectionUpdate update; +} MarmotTimelineSubscriptionUpdate_Projection_Body; + +typedef struct MarmotTimelineSubscriptionUpdate { + MarmotTimelineSubscriptionUpdate_Tag tag; + union { + MarmotTimelineSubscriptionUpdate_Page_Body PAGE; + MarmotTimelineSubscriptionUpdate_Projection_Body PROJECTION; + }; +} MarmotTimelineSubscriptionUpdate; + +/** + * Callback invoked with each full timeline window (borrowed) and a final + * NULL page on close. + */ +typedef void (*MarmotTimelinePageCallback)(const struct MarmotTimelinePage *page, void *user_data); + +/** + * Callback invoked with each notification update (borrowed; valid only + * during the call) and finally with NULL when the stream closes. + */ +typedef void (*MarmotNotificationUpdateCallback)(const struct MarmotNotificationUpdate *update, + void *user_data); + +/** + * Owned list of group records (chats subscription snapshots). + */ +typedef struct MarmotAppGroupRecordList { + struct MarmotAppGroupRecord *items; + uintptr_t len; +} MarmotAppGroupRecordList; + +/** + * Callback invoked with each group record (borrowed; valid only during + * the call) and finally with NULL when the stream closes. Shared by the + * chats and group-state subscriptions. + */ +typedef void (*MarmotAppGroupRecordCallback)(const struct MarmotAppGroupRecord *record, + void *user_data); + +/** + * One incremental chat-list subscription update: either a fresh row to + * upsert or a group id whose row should be removed. + */ +typedef enum MarmotChatListSubscriptionUpdate_Tag { + /** + * Upsert this row into the chat list. + */ + MARMOT_CHAT_LIST_SUBSCRIPTION_UPDATE_ROW, + /** + * Remove the row for this group from the chat list. + */ + MARMOT_CHAT_LIST_SUBSCRIPTION_UPDATE_REMOVE_ROW, +} MarmotChatListSubscriptionUpdate_Tag; + +typedef struct MarmotChatListSubscriptionUpdate_Row_Body { + enum MarmotChatListUpdateTrigger trigger; + struct MarmotChatListRow row; +} MarmotChatListSubscriptionUpdate_Row_Body; + +typedef struct MarmotChatListSubscriptionUpdate_RemoveRow_Body { + enum MarmotChatListUpdateTrigger trigger; + char *group_id_hex; +} MarmotChatListSubscriptionUpdate_RemoveRow_Body; + +typedef struct MarmotChatListSubscriptionUpdate { + MarmotChatListSubscriptionUpdate_Tag tag; + union { + MarmotChatListSubscriptionUpdate_Row_Body ROW; + MarmotChatListSubscriptionUpdate_RemoveRow_Body REMOVE_ROW; + }; +} MarmotChatListSubscriptionUpdate; + +/** + * Callback invoked with each upserted chat-list row (borrowed; valid only + * during the call) and finally with NULL when the stream closes. Row + * removals are skipped — use `marmot_chat_list_subscription_next_update` + * polling to observe them. + */ +typedef void (*MarmotChatListRowCallback)(const struct MarmotChatListRow *row, void *user_data); + +/** + * A unified update from a messages subscription. Each variant carries enough + * context for host apps to update an in-memory timeline without holding + * onto the underlying runtime types. + */ +typedef enum MarmotMessageUpdate_Tag { + /** + * A raw message update: chat, reply, media, reaction, delete, or the + * kind-9 stream-final. Materialized timeline pages also include + * kind-1200 stream starts as timeline record rows. + */ + MARMOT_MESSAGE_UPDATE_MESSAGE, + /** + * A kind-1200 agent text stream start — the signal to open the QUIC + * preview for raw message subscribers. Its stream id, route, and brokers + * live on `received.message.tags`. + */ + MARMOT_MESSAGE_UPDATE_AGENT_STREAM_STARTED, +} MarmotMessageUpdate_Tag; + +typedef struct MarmotMessageUpdate_Message_Body { + struct MarmotRuntimeMessageReceived received; +} MarmotMessageUpdate_Message_Body; + +typedef struct MarmotMessageUpdate_AgentStreamStarted_Body { + struct MarmotRuntimeMessageReceived received; +} MarmotMessageUpdate_AgentStreamStarted_Body; + +typedef struct MarmotMessageUpdate { + MarmotMessageUpdate_Tag tag; + union { + MarmotMessageUpdate_Message_Body MESSAGE; + MarmotMessageUpdate_AgentStreamStarted_Body AGENT_STREAM_STARTED; + }; +} MarmotMessageUpdate; + +/** + * Callback invoked with each message update (borrowed; valid only during + * the call) and finally with NULL when the stream closes. + */ +typedef void (*MarmotMessageUpdateCallback)(const struct MarmotMessageUpdate *update, + void *user_data); + +/** + * One update from a live agent-text-stream watch. `Chunk.text` is an + * incremental fragment; `Finished.text` is the complete transcript. + */ +typedef enum MarmotAgentStreamUpdate_Tag { + /** + * Incremental transcript fragment. + */ + MARMOT_AGENT_STREAM_UPDATE_CHUNK, + /** + * Out-of-band status line from the agent. + */ + MARMOT_AGENT_STREAM_UPDATE_STATUS, + /** + * Progress note from the agent. + */ + MARMOT_AGENT_STREAM_UPDATE_PROGRESS, + /** + * Typed record frame from the agent. + */ + MARMOT_AGENT_STREAM_UPDATE_RECORD, + /** + * Terminal success; `text` is the complete transcript. + */ + MARMOT_AGENT_STREAM_UPDATE_FINISHED, + /** + * Terminal failure. + */ + MARMOT_AGENT_STREAM_UPDATE_FAILED, +} MarmotAgentStreamUpdate_Tag; + +typedef struct MarmotAgentStreamUpdate_Chunk_Body { + uint64_t seq; + char *text; +} MarmotAgentStreamUpdate_Chunk_Body; + +typedef struct MarmotAgentStreamUpdate_Status_Body { + uint64_t seq; + char *status; +} MarmotAgentStreamUpdate_Status_Body; + +typedef struct MarmotAgentStreamUpdate_Progress_Body { + uint64_t seq; + char *text; +} MarmotAgentStreamUpdate_Progress_Body; + +typedef struct MarmotAgentStreamUpdate_Record_Body { + uint64_t seq; + uint8_t record_type; + char *text; +} MarmotAgentStreamUpdate_Record_Body; + +typedef struct MarmotAgentStreamUpdate_Finished_Body { + char *text; + char *transcript_hash_hex; + uint64_t chunk_count; +} MarmotAgentStreamUpdate_Finished_Body; + +typedef struct MarmotAgentStreamUpdate_Failed_Body { + char *message; +} MarmotAgentStreamUpdate_Failed_Body; + +typedef struct MarmotAgentStreamUpdate { + MarmotAgentStreamUpdate_Tag tag; + union { + MarmotAgentStreamUpdate_Chunk_Body CHUNK; + MarmotAgentStreamUpdate_Status_Body STATUS; + MarmotAgentStreamUpdate_Progress_Body PROGRESS; + MarmotAgentStreamUpdate_Record_Body RECORD; + MarmotAgentStreamUpdate_Finished_Body FINISHED; + MarmotAgentStreamUpdate_Failed_Body FAILED; + }; +} MarmotAgentStreamUpdate; + +/** + * Callback invoked with each agent-stream update (borrowed; valid only + * during the call) and finally with NULL when the stream closes. + */ +typedef void (*MarmotAgentStreamUpdateCallback)(const struct MarmotAgentStreamUpdate *update, + void *user_data); + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +/** + * Create a Marmot client rooted at `root_path`, connected to + * `relay_urls` (`relay_urls_len` entries). On success writes the new + * handle to `out_client`. Uses the platform keychain-backed account + * store, matching the UniFFI constructor. + * + * # Safety + * `root_path` must be a valid NUL-terminated UTF-8 string; `relay_urls` + * must point to `relay_urls_len` valid strings (or be NULL with length + * 0); `out_client` must be a valid pointer. + */ +MarmotStatus marmot_client_new(const char *root_path, + const char *const *relay_urls, + uintptr_t relay_urls_len, + struct MarmotClient **out_client); + +/** + * Start the runtime (reconcile accounts, start workers, subscribe + * transport). Must be called before subscribing. + * + * # Safety + * `client` must be a live handle from `marmot_client_new`. + */ +MarmotStatus marmot_client_start(const struct MarmotClient *client); + +/** + * Shut the runtime down. Open subscriptions drain and report + * `MARMOT_STATUS_CLOSED` from their next read. + * + * # Safety + * `client` must be a live handle from `marmot_client_new`. + */ +MarmotStatus marmot_client_shutdown(const struct MarmotClient *client); + +/** + * Whether the runtime is currently shutting down. Writes to `out_stopping`. + * + * # Safety + * `client` must be a live handle; `out_stopping` must be valid. + */ +MarmotStatus marmot_client_is_stopping(const struct MarmotClient *client, bool *out_stopping); + +/** + * Destroy a client handle. Call `marmot_client_shutdown` first for a + * graceful stop. NULL is a no-op. The handle must not be used afterwards. + * + * # Safety + * `client` must be NULL or a live handle from `marmot_client_new` that + * has not been freed already. + */ +void marmot_client_free(struct MarmotClient *client); + +/** + * Return the detail message for the current thread's most recent failed + * `marmot_*` call, or NULL if there is none. The returned string is an + * owned copy: free it with `marmot_string_free`. Reading clears the slot. + */ +char *marmot_last_error_message(void); + +/** + * Free a string returned by this library (`marmot_last_error_message`, + * string out-params). NULL is a no-op. + * + * # Safety + * `s` must be NULL or a string returned by this library that has not + * been freed already. + */ +void marmot_string_free(char *s); + +/** + * Free a byte buffer returned by this library as a `(data, len)` pair (e.g. + * `marmot_download_group_blossom_image`). `(NULL, 0)` is a no-op. + * + * # Safety + * `data`/`len` must be exactly a pair returned by this library that has not + * been freed already. + */ +void marmot_bytes_free(uint8_t *data, uintptr_t len); + +/** + * List every account known to this device. + * + * # Safety + * `client` must be a live handle; `out_list` must be a valid pointer. + * Free the result with `marmot_account_summary_list_free`. + */ +MarmotStatus marmot_list_accounts(const struct MarmotClient *client, + struct MarmotAccountSummaryList **out_list); + +/** + * Per-account unread aggregates for the account-switcher badge. + * + * # Safety + * `client` must be a live handle; `out_list` must be a valid pointer. + * Free the result with `marmot_account_unread_list_free`. + */ +MarmotStatus marmot_account_unread_summary(const struct MarmotClient *client, + struct MarmotAccountUnreadList **out_list); + +/** + * Remove an account and its local state from this device. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string. + */ +MarmotStatus marmot_remove_account(const struct MarmotClient *client, const char *account_ref); + +/** + * Destructive sign-out: leave groups, delete relay KeyPackages, wipe + * local state. Every stage is reported in the outcome. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_outcome` a valid pointer. Free with `marmot_wipe_outcome_free`. + */ +MarmotStatus marmot_sign_out_and_wipe(const struct MarmotClient *client, + const char *account_ref, + struct MarmotWipeOutcome **out_outcome); + +/** + * Non-destructive sign-out: deactivate the account on this device, + * keeping all local state so it can sign back in later. When + * `delete_key_packages` is true, relay-published KeyPackages get + * NIP-09 deletions. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_outcome` a valid pointer. Free with `marmot_sign_out_outcome_free`. + */ +MarmotStatus marmot_sign_out(const struct MarmotClient *client, + const char *account_ref, + bool delete_key_packages, + struct MarmotSignOutOutcome **out_outcome); + +/** + * Create a brand-new Nostr identity, store its secret in the platform + * keychain, and publish initial relay lists + key package. + * + * # Safety + * `client` must be a live handle; relay arrays must hold `len` valid + * strings (or be NULL with len 0); `out_summary` must be valid. Free + * with `marmot_account_summary_free`. + */ +MarmotStatus marmot_create_identity(const struct MarmotClient *client, + const char *const *default_relays, + uintptr_t default_relays_len, + const char *const *bootstrap_relays, + uintptr_t bootstrap_relays_len, + struct MarmotAccountSummary **out_summary); + +/** + * Log in with an existing identity: an `nsec` (private key) for a + * local-signing account, or an `npub` to track a public identity. + * + * # Safety + * Same as `marmot_create_identity`, plus `identity` must be a valid + * string. Free with `marmot_account_summary_free`. + */ +MarmotStatus marmot_login(const struct MarmotClient *client, + const char *identity, + const char *const *default_relays, + uintptr_t default_relays_len, + const char *const *bootstrap_relays, + uintptr_t bootstrap_relays_len, + struct MarmotAccountSummary **out_summary); + +/** + * Re-activate a non-destructively signed-out local account. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_summary` valid. Free with `marmot_account_summary_free`. + */ +MarmotStatus marmot_sign_in_account(const struct MarmotClient *client, + const char *account_ref, + struct MarmotAccountSummary **out_summary); + +/** + * Publish NIP-65 + inbox relay lists for the account. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; relay + * arrays must hold `len` valid strings (or NULL with len 0). + */ +MarmotStatus marmot_publish_relay_lists(const struct MarmotClient *client, + const char *account_ref, + const char *const *default_relays, + uintptr_t default_relays_len, + const char *const *bootstrap_relays, + uintptr_t bootstrap_relays_len); + +/** + * The account's NIP-65 relay list. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_list` valid. Free with `marmot_string_list_free`. + */ +MarmotStatus marmot_account_nip65_relays(const struct MarmotClient *client, + const char *account_ref, + struct MarmotStringList **out_list); + +/** + * The account's inbox relay list. + * + * # Safety + * Same as `marmot_account_nip65_relays`. Free with + * `marmot_string_list_free`. + */ +MarmotStatus marmot_account_inbox_relays(const struct MarmotClient *client, + const char *account_ref, + struct MarmotStringList **out_list); + +/** + * Local + relay-published KeyPackages for the account. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; the + * relay array must hold `len` valid strings (or NULL with len 0); + * `out_list` valid. Free with `marmot_account_key_package_list_free`. + */ +MarmotStatus marmot_account_key_packages(const struct MarmotClient *client, + const char *account_ref, + const char *const *bootstrap_relays, + uintptr_t bootstrap_relays_len, + struct MarmotAccountKeyPackageList **out_list); + +/** + * Publish a fresh KeyPackage. Writes the number of relays that accepted + * the publish to `out_accepted`. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_accepted` valid. + */ +MarmotStatus marmot_publish_new_key_package(const struct MarmotClient *client, + const char *account_ref, + uint64_t *out_accepted); + +/** + * Re-publish the latest cached KeyPackage when possible, otherwise + * publish a fresh one. Writes the accepting-relay count. + * + * # Safety + * Same as `marmot_publish_new_key_package`. + */ +MarmotStatus marmot_republish_key_package(const struct MarmotClient *client, + const char *account_ref, + uint64_t *out_accepted); + +/** + * Publish a NIP-09 deletion for a KeyPackage event. Writes the + * accepting-relay count. + * + * # Safety + * `client` must be a live handle; `account_ref` and `event_id_hex` + * valid strings; the relay array must hold `len` valid strings (or + * NULL with len 0); `out_accepted` valid. + */ +MarmotStatus marmot_delete_account_key_package(const struct MarmotClient *client, + const char *account_ref, + const char *event_id_hex, + const char *const *relays, + uintptr_t relays_len, + uint64_t *out_accepted); + +/** + * Replace the account's NIP-65 relay list and publish it. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; relay + * arrays valid per `marmot_publish_relay_lists`; `out_lists` valid. + * Free with `marmot_account_relay_lists_free`. + */ +MarmotStatus marmot_set_account_nip65_relays(const struct MarmotClient *client, + const char *account_ref, + const char *const *relays, + uintptr_t relays_len, + const char *const *bootstrap_relays, + uintptr_t bootstrap_relays_len, + struct MarmotAccountRelayLists **out_lists); + +/** + * Replace the account's inbox relay list and publish it. + * + * # Safety + * Same as `marmot_set_account_nip65_relays`. Free with + * `marmot_account_relay_lists_free`. + */ +MarmotStatus marmot_set_account_inbox_relays(const struct MarmotClient *client, + const char *account_ref, + const char *const *relays, + uintptr_t relays_len, + const char *const *bootstrap_relays, + uintptr_t bootstrap_relays_len, + struct MarmotAccountRelayLists **out_lists); + +/** + * Export the account's raw private key as `nsec1…` bech32. + * + * SENSITIVE: the reveal is audit-logged and permanently marks the + * account's key-security byte as handled-insecurely. Free the returned + * string with `marmot_string_free` as soon as it has been displayed; + * the library cannot zero caller-held copies. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_nsec` valid. + */ +MarmotStatus marmot_reveal_nsec(const struct MarmotClient *client, + const char *account_ref, + char **out_nsec); + +/** + * Export the account's private key NIP-49-encrypted under `passphrase`. + * Free the result with `marmot_string_free`. + * + * # Safety + * `client` must be a live handle; `account_ref` and `passphrase` valid + * strings; `out_encrypted` valid. + */ +MarmotStatus marmot_export_encrypted_secret_key(const struct MarmotClient *client, + const char *account_ref, + const char *passphrase, + char **out_encrypted); + +/** + * Publish the account's kind:0 profile metadata. The returned profile is + * what was actually published (server-applied defaults reflected). + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `profile` a valid borrowed struct (never freed by the library); relay + * arrays valid per `marmot_publish_relay_lists`; `out_profile` valid. + * Free the result with `marmot_user_profile_metadata_free`. + */ +MarmotStatus marmot_publish_user_profile(const struct MarmotClient *client, + const char *account_ref, + const struct MarmotUserProfileMetadata *profile, + const char *const *default_relays, + uintptr_t default_relays_len, + const char *const *bootstrap_relays, + uintptr_t bootstrap_relays_len, + struct MarmotUserProfileMetadata **out_profile); + +/** + * Anchor a live agent text stream start in the encrypted group history. + * `quic_candidates` (`quic_candidates_len` entries) are the broker + * candidate URLs the host will publish to, such as + * `quic://quic-broker.ipf.dev:4450`; pass `stream_id_hex = NULL` to let + * the library generate a 32-byte stream id (the one actually used is in + * the result). + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `stream_id_hex` NULL or a valid string; `quic_candidates` + * must hold `quic_candidates_len` valid strings (or be NULL with len 0); + * `out_start` must be a valid pointer. Free the result with + * `marmot_agent_stream_start_free`. + */ +MarmotStatus marmot_start_agent_text_stream(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *stream_id_hex, + const char *const *quic_candidates, + uintptr_t quic_candidates_len, + struct MarmotAgentStreamStart **out_start); + +/** + * Local forensic audit-log recording settings. Recording is opt-in and only + * applies to account sessions opened after the setting is enabled. + * + * # Safety + * `client` must be a live handle; `out_settings` must be a valid pointer. + * Free the result with `marmot_audit_log_settings_free`. + */ +MarmotStatus marmot_audit_log_settings(const struct MarmotClient *client, + struct MarmotAuditLogSettings **out_settings); + +/** + * Persist local forensic audit-log recording settings and return the stored + * value. + * + * Blocking: toggling the switch is applied to any already-running account + * sessions in place — enabling starts a live recorder, disabling stops it + * and closes the file, no session reopen required. + * + * # Safety + * `client` must be a live handle; `settings` a valid borrowed struct (never + * freed by the library); `out_settings` a valid pointer. Free the result + * with `marmot_audit_log_settings_free`. + */ +MarmotStatus marmot_set_audit_log_settings(const struct MarmotClient *client, + const struct MarmotAuditLogSettings *settings, + struct MarmotAuditLogSettings **out_settings); + +/** + * Supply non-persisted audit tracker upload metadata: optional Goggles + * upload URL override, bearer token from the host app, and optional human + * source labels. + * + * The returned config confirms what was stored but never echoes the bearer + * token back across FFI: secrets flow in, not out. + * + * # Safety + * `client` must be a live handle; `config` a valid borrowed struct (never + * freed by the library) whose non-NULL string fields are valid + * NUL-terminated strings; `out_config` a valid pointer. Free the result + * with `marmot_audit_log_tracker_config_free`. + */ +MarmotStatus marmot_set_audit_log_tracker_config(const struct MarmotClient *client, + const struct MarmotAuditLogTrackerConfig *config, + struct MarmotAuditLogTrackerConfig **out_config); + +/** + * Local JSONL audit logs available for explicit forensic upload. + * + * # Safety + * `client` must be a live handle; `out_list` must be a valid pointer. + * Free the result with `marmot_audit_log_file_list_free`. + */ +MarmotStatus marmot_audit_log_files(const struct MarmotClient *client, + struct MarmotAuditLogFileList **out_list); + +/** + * POST one selected JSONL audit log to a forensic analyzer endpoint. + * + * # Safety + * `client` must be a live handle; `path` and `endpoint` valid strings; + * `out_result` a valid pointer. Free the result with + * `marmot_audit_log_upload_result_free`. + */ +MarmotStatus marmot_post_audit_log_file(const struct MarmotClient *client, + const char *path, + const char *endpoint, + struct MarmotAuditLogUploadResult **out_result); + +/** + * Delete one local JSONL audit log file (e.g. behind a "clear audit log" + * button). + * + * When forensic audit logging is on and a session for the file's account is + * live, the recorder rotates to a fresh file and keeps recording, so the + * result's `still_recording` is `true`. When audit logging is off, or no + * session is recording this file, it is simply removed and + * `still_recording` is `false`. Pass a `path` from + * `marmot_audit_log_files`. + * + * # Safety + * `client` must be a live handle; `path` a valid string; `out_result` a + * valid pointer. Free the result with + * `marmot_audit_log_delete_result_free`. + */ +MarmotStatus marmot_delete_audit_log_file(const struct MarmotClient *client, + const char *path, + struct MarmotAuditLogDeleteResult **out_result); + +/** + * POST all local audit logs to the configured tracker when audit logging is + * enabled. This is safe for host apps to call unconditionally; disabled or + * unconfigured states return a structured skip result. + * + * # Safety + * `client` must be a live handle; `out_result` a valid pointer. Free the + * result with `marmot_audit_log_tracker_update_result_free`. + */ +MarmotStatus marmot_post_audit_log_tracker_update(const struct MarmotClient *client, + struct MarmotAuditLogTrackerUpdateResult **out_result); + +/** + * Durable chat-list rows for fast app launch. Rows include the group + * title/avatar, last kind-9 preview, unread count, and read anchors. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_list` a valid pointer. Free the result with + * `marmot_chat_list_row_list_free`. + */ +MarmotStatus marmot_chat_list(const struct MarmotClient *client, + const char *account_ref, + bool include_archived, + struct MarmotChatListRowList **out_list); + +/** + * Establish the unread baseline the first time a user opens a group. + * Existing kind-9 history remains read; later remote kind-9 messages count + * until marked visible via `marmot_mark_timeline_message_read`. Writes NULL + * with `MARMOT_STATUS_OK` when the group has no chat-list row. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_row` a valid pointer. Free the result with + * `marmot_chat_list_row_free`. + */ +MarmotStatus marmot_initialize_chat_read_state(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotChatListRow **out_row); + +/** + * Mark a kind-9 timeline message visible/read. Own kind-9 messages can + * advance the marker too, which clears any earlier unread messages. Writes + * NULL with `MARMOT_STATUS_OK` when the group has no chat-list row. + * + * # Safety + * `client` must be a live handle; `account_ref`, `group_id_hex`, and + * `message_id_hex` valid strings; `out_row` a valid pointer. Free the + * result with `marmot_chat_list_row_free`. + */ +MarmotStatus marmot_mark_timeline_message_read(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *message_id_hex, + struct MarmotChatListRow **out_row); + +/** + * Best-effort cached display name for an account id. Returns the Nostr + * kind:0 display_name/name when the runtime has projected one, or the + * local account label if the id refers to one of our own accounts. + * Writes NULL (with `MARMOT_STATUS_OK`) when nothing is known yet — + * call `marmot_refresh_directory` to fetch. + * + * # Safety + * `client` must be a live handle; `account_id_hex` a valid string; + * `out_name` a valid pointer. Free a non-NULL result with + * `marmot_string_free`. + */ +MarmotStatus marmot_display_name(const struct MarmotClient *client, + const char *account_id_hex, + char **out_name); + +/** + * Convert a hex account id (Nostr public key) into its `npub…` bech32 + * form for display. Writes NULL (with `MARMOT_STATUS_OK`) if the hex + * isn't a valid public key. + * + * # Safety + * `client` must be a live handle; `account_id_hex` a valid string; + * `out_npub` a valid pointer. Free a non-NULL result with + * `marmot_string_free`. + */ +MarmotStatus marmot_npub(const struct MarmotClient *client, + const char *account_id_hex, + char **out_npub); + +/** + * Normalize a public-key reference (npub or hex) to canonical hex. + * Writes NULL (with `MARMOT_STATUS_OK`) if it isn't a valid public key. + * Used to resolve a scanned or deep-linked npub back to the account id + * the rest of the API expects. + * + * # Safety + * `client` must be a live handle; `reference` a valid string; + * `out_hex` a valid pointer. Free a non-NULL result with + * `marmot_string_free`. + */ +MarmotStatus marmot_account_id_hex(const struct MarmotClient *client, + const char *reference, + char **out_hex); + +/** + * Parse plaintext message content into the same Markdown AST returned on + * message and timeline records. Useful for draft previews and host-side + * fallback rendering. + * + * # Safety + * `client` must be a live handle; `text` a valid string; + * `out_document` a valid pointer. Free the result with + * `marmot_markdown_document_free`. + */ +MarmotStatus marmot_parse_markdown(const struct MarmotClient *client, + const char *text, + struct MarmotMarkdownDocument **out_document); + +/** + * Full cached Nostr kind:0 profile for an account id (name, display + * name, about, picture, nip05, lud16), if the runtime has one + * projected. The local account's own profile is cached immediately + * after `marmot_publish_user_profile`; other accounts' profiles + * populate via `marmot_refresh_directory`. Writes NULL (with + * `MARMOT_STATUS_OK`) when nothing is cached yet. + * + * # Safety + * `client` must be a live handle; `account_id_hex` a valid string; + * `out_profile` a valid pointer. Free a non-NULL result with + * `marmot_user_profile_metadata_free`. + */ +MarmotStatus marmot_user_profile(const struct MarmotClient *client, + const char *account_id_hex, + struct MarmotUserProfileMetadata **out_profile); + +/** + * Fetch and cache an account's own Nostr kind:0 profile from `relays`. + * After this resolves, `marmot_user_profile` / `marmot_display_name` + * return the freshly-fetched metadata (name, picture, etc.) for that + * account. + * + * # Safety + * `client` must be a live handle; `account_id_hex` a valid string; the + * relay array must hold `relays_len` valid strings (or be NULL with + * len 0). + */ +MarmotStatus marmot_refresh_profile(const struct MarmotClient *client, + const char *account_id_hex, + const char *const *relays, + uintptr_t relays_len); + +/** + * Create a new MLS group with `name` and the given members. Members are + * referenced by `npub` or hex account id. Writes the new group id as a hex + * string. + * + * # Safety + * `client` must be a live handle; `account_ref` and `name` valid strings; + * `member_refs` must hold `member_refs_len` valid strings (or be NULL with + * len 0); `description` NULL or a valid string; `out_group_id_hex` valid. + * Free the result with `marmot_string_free`. + */ +MarmotStatus marmot_create_group(const struct MarmotClient *client, + const char *account_ref, + const char *name, + const char *const *member_refs, + uintptr_t member_refs_len, + const char *description, + char **out_group_id_hex); + +/** + * Normalize a member reference for group-management UI. Accepts hex, + * `npub`, `nostr:npub...`, and `marmot://profile/...` references. + * + * # Safety + * `client` must be a live handle; `member_ref` a valid string; + * `out_member_ref` valid. Free with `marmot_member_ref_free`. + */ +MarmotStatus marmot_normalize_member_ref(const struct MarmotClient *client, + const char *member_ref, + struct MarmotMemberRef **out_member_ref); + +/** + * Membership roster for `group_id_hex`. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_list` valid. Free with + * `marmot_app_group_member_record_list_free`. + */ +MarmotStatus marmot_group_members(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotAppGroupMemberRecordList **out_list); + +/** + * Group plus enriched member rows for detail screens. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_details` valid. Free with `marmot_group_details_free`. + */ +MarmotStatus marmot_group_details(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotGroupDetails **out_details); + +/** + * Current caller permissions plus per-member action availability. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_state` valid. Free with + * `marmot_group_management_state_free`. + */ +MarmotStatus marmot_group_management_state(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotGroupManagementState **out_state); + +/** + * Invite members (by `npub` or hex account id) into the group. Requires + * the caller to be an admin. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `member_refs` must hold `member_refs_len` valid strings (or be + * NULL with len 0); `out_summary` valid. Free with + * `marmot_send_summary_free`. + */ +MarmotStatus marmot_invite_members(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *const *member_refs, + uintptr_t member_refs_len, + struct MarmotSendSummary **out_summary); + +/** + * Remove members from the group. Requires the caller to be an admin; + * preflight rejects self-removal and removing the last admin. + * + * # Safety + * Same as `marmot_invite_members`. Free with `marmot_send_summary_free`. + */ +MarmotStatus marmot_remove_members(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *const *member_refs, + uintptr_t member_refs_len, + struct MarmotSendSummary **out_summary); + +/** + * Leave the group as the active account. Admins must self-demote first. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_summary` valid. Free with `marmot_send_summary_free`. + */ +MarmotStatus marmot_leave_group(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotSendSummary **out_summary); + +/** + * Delete this group's local app data without performing an MLS leave. The + * caller should cancel any active UI subscriptions for the group before + * invoking the wipe. The runtime removes the active transport route, then + * transactionally drops the chat-list/account projection, plaintext app + * events, timeline rows, agent-stream projection rows, push-token rows, + * and cached encrypted-media epoch secrets. MLS/OpenMLS group state is + * left intact; a future fresh group delivery can recreate a local chat + * row. Writes true if any local rows or a live route were removed. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_removed` valid. + */ +MarmotStatus marmot_delete_group_local(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + bool *out_removed); + +/** + * Set the per-group disappearing-message retention. + * `disappearing_message_secs` of `0` disables expiry; any positive value + * is the retention window in seconds. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_summary` valid. Free with `marmot_send_summary_free`. + */ +MarmotStatus marmot_update_message_retention(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + uint64_t disappearing_message_secs, + struct MarmotSendSummary **out_summary); + +/** + * Accept a pending group invite; writes the now-confirmed group record. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_group` valid. Free with `marmot_app_group_record_free`. + */ +MarmotStatus marmot_accept_group_invite(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotAppGroupRecord **out_group); + +/** + * Decline a pending group invite; writes the updated group record plus + * the publish summary of the decline. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_result` valid. Free with + * `marmot_group_invite_decline_result_free`. + */ +MarmotStatus marmot_decline_group_invite(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotGroupInviteDeclineResult **out_result); + +/** + * Update the group's name and/or description. NULL leaves a field + * unchanged. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `name` and `description` NULL or valid strings; `out_summary` + * valid. Free with `marmot_send_summary_free`. + */ +MarmotStatus marmot_update_group_profile(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *name, + const char *description, + struct MarmotSendSummary **out_summary); + +/** + * Set (or clear, with `url` NULL) the group's URL-based avatar + * (`marmot.group.avatar-url.v1`). The URL is validated (https-only, no + * localhost/private hosts) and normalized before it is committed. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `url`, `dim`, and `thumbhash` NULL or valid strings; + * `out_summary` valid. Free with `marmot_send_summary_free`. + */ +MarmotStatus marmot_update_group_avatar_url(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *url, + const char *dim, + const char *thumbhash, + struct MarmotSendSummary **out_summary); + +/** + * Replace the group's encrypted-media default blob endpoints as a full + * `marmot.group.encrypted-media.v1` component update. Requires the caller + * to be an admin. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `endpoints` must point to `endpoints_len` valid caller-owned + * structs (or be NULL with len 0) — the library never frees or retains + * them; `out_summary` valid. Free with `marmot_send_summary_free`. + */ +MarmotStatus marmot_replace_encrypted_media_blob_endpoints(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const struct MarmotAppBlobEndpoint *endpoints, + uintptr_t endpoints_len, + struct MarmotSendSummary **out_summary); + +/** + * Fetch, verify, and decrypt the group's Blossom-hosted encrypted image, + * writing the plaintext bytes to `out_data`/`out_len`. The image hash comes + * from `MarmotAppGroupRecord.image_hash_hex`. Needs a relay/Blossom, so it + * fails offline. Free the buffer with `marmot_bytes_free`. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_data` and `out_len` valid pointers. + */ +MarmotStatus marmot_download_group_blossom_image(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + uint8_t **out_data, + uintptr_t *out_len); + +/** + * Grant admin rights to `member_ref` (npub or hex). Requires the caller + * to be an admin; publishes a group state update. + * + * # Safety + * `client` must be a live handle; `account_ref`, `group_id_hex`, and + * `member_ref` valid strings; `out_summary` valid. Free with + * `marmot_send_summary_free`. + */ +MarmotStatus marmot_promote_admin(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *member_ref, + struct MarmotSendSummary **out_summary); + +/** + * Revoke `member_ref`'s admin rights. + * + * # Safety + * Same as `marmot_promote_admin`. Free with `marmot_send_summary_free`. + */ +MarmotStatus marmot_demote_admin(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *member_ref, + struct MarmotSendSummary **out_summary); + +/** + * Step down as an admin of `group_id_hex` (demote the active account). + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_summary` valid. Free with `marmot_send_summary_free`. + */ +MarmotStatus marmot_self_demote_admin(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotSendSummary **out_summary); + +/** + * `marmot_invite_members` plus refreshed group details and management + * state in one round trip. + * + * # Safety + * Same as `marmot_invite_members`; `out_result` valid. Free with + * `marmot_group_mutation_result_free`. + */ +MarmotStatus marmot_invite_members_detailed(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *const *member_refs, + uintptr_t member_refs_len, + struct MarmotGroupMutationResult **out_result); + +/** + * `marmot_remove_members` plus refreshed group details and management + * state in one round trip. + * + * # Safety + * Same as `marmot_remove_members`; `out_result` valid. Free with + * `marmot_group_mutation_result_free`. + */ +MarmotStatus marmot_remove_members_detailed(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *const *member_refs, + uintptr_t member_refs_len, + struct MarmotGroupMutationResult **out_result); + +/** + * `marmot_promote_admin` plus refreshed group details and management + * state in one round trip. + * + * # Safety + * Same as `marmot_promote_admin`; `out_result` valid. Free with + * `marmot_group_mutation_result_free`. + */ +MarmotStatus marmot_promote_admin_detailed(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *member_ref, + struct MarmotGroupMutationResult **out_result); + +/** + * `marmot_demote_admin` plus refreshed group details and management state + * in one round trip. + * + * # Safety + * Same as `marmot_demote_admin`; `out_result` valid. Free with + * `marmot_group_mutation_result_free`. + */ +MarmotStatus marmot_demote_admin_detailed(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *member_ref, + struct MarmotGroupMutationResult **out_result); + +/** + * `marmot_self_demote_admin` plus refreshed group details and management + * state in one round trip. + * + * # Safety + * Same as `marmot_self_demote_admin`; `out_result` valid. Free with + * `marmot_group_mutation_result_free`. + */ +MarmotStatus marmot_self_demote_admin_detailed(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotGroupMutationResult **out_result); + +/** + * Current MLS state (epoch, member count, required components) for the + * conversation developer/debug view. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_state` valid. Free with `marmot_app_group_mls_state_free`. + */ +MarmotStatus marmot_group_mls_state(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotAppGroupMlsState **out_state); + +/** + * Stored groups that failed session-open hydration and were skipped so + * the rest of the account could open. These groups are not in the live + * roster and otherwise vanish from the account with no explanation; + * surface them in a per-group recovery flow distinct from healthy and + * archived groups, using `reason` to pick the per-reason guidance, and + * offer `marmot_retry_hydrate_quarantined_group`. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_list` valid. Free with `marmot_app_quarantined_group_list_free`. + */ +MarmotStatus marmot_quarantined_groups(const struct MarmotClient *client, + const char *account_ref, + struct MarmotAppQuarantinedGroupList **out_list); + +/** + * Re-attempt hydration of a single quarantined group. + * + * Non-destructive, user-initiated recovery for a transiently-bad group + * (e.g. a partial DB restore that has since completed). Writes `true` if + * the group recovered and is now a live chat (it leaves the quarantine + * list and reappears in the chat list), `false` if it is still unhealthy + * and stays quarantined. Errors with the unknown-group status if the id + * is not currently quarantined. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_recovered` valid. + */ +MarmotStatus marmot_retry_hydrate_quarantined_group(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + bool *out_recovered); + +/** + * Flag a group archived (or restore it). Local-only projection state — + * it does not change membership or publish anything. The chats list + * filters archived groups unless `include_archived` is set. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_group` valid. Free with `marmot_app_group_record_free`. + */ +MarmotStatus marmot_set_group_archived(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + bool archived, + struct MarmotAppGroupRecord **out_group); + +/** + * Send already-uploaded encrypted media attachments as a kind-9 chat + * carrying ordered NIP-92 `imeta` tags. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `attachments` must point to `attachments_len` valid borrowed + * attachment references (or be NULL with len 0); `caption` NULL or a valid + * string; `out_summary` valid. Input structs are never freed by the + * library. Free the result with `marmot_send_summary_free`. + */ +MarmotStatus marmot_send_media_attachments(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const struct MarmotMediaAttachmentReference *attachments, + uintptr_t attachments_len, + const char *caption, + struct MarmotSendSummary **out_summary); + +/** + * Backward-compatible single-attachment send helper. Prefer + * `marmot_send_media_attachments` for new callers so one chat can carry + * ordered mixed media attachments. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `reference` a valid borrowed attachment reference (never freed + * by the library); `caption` NULL or a valid string; `out_summary` valid. + * Free the result with `marmot_send_summary_free`. + */ +MarmotStatus marmot_send_media_reference(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const struct MarmotMediaAttachmentReference *reference, + const char *caption, + struct MarmotSendSummary **out_summary); + +/** + * Encrypt plaintext attachments, upload the ciphertext blobs, and + * optionally send the resulting media references into the group. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `request` a valid borrowed upload request (never freed by the + * library); `out_result` valid. Free the result with + * `marmot_media_upload_result_free`. + */ +MarmotStatus marmot_upload_media(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const struct MarmotMediaUploadRequest *request, + struct MarmotMediaUploadResult **out_result); + +/** + * Fetch an encrypted media blob and decrypt it using the group's + * encrypted media component secret. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `reference` a valid borrowed attachment reference (never freed + * by the library); `out_result` valid. Free the result with + * `marmot_media_download_result_free`. + */ +MarmotStatus marmot_download_media(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const struct MarmotMediaAttachmentReference *reference, + struct MarmotMediaDownloadResult **out_result); + +/** + * Typed media references projected from group message history. Each + * record's embedded `reference` can be passed back to + * `marmot_download_media`. When `has_limit` is false, `limit` is ignored + * and the full history is projected. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_list` valid. Free the result with + * `marmot_media_record_list_free`. + */ +MarmotStatus marmot_list_media(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + bool has_limit, + uint32_t limit, + struct MarmotMediaRecordList **out_list); + +/** + * Send a plain UTF-8 text message. Structured payloads (reactions, + * replies, deletes, media) go through dedicated methods. + * + * # Safety + * `client` must be a live handle; `account_ref`, `group_id_hex`, and + * `text` valid strings; `out_summary` a valid pointer. Free the result + * with `marmot_send_summary_free`. + */ +MarmotStatus marmot_send_text(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *text, + struct MarmotSendSummary **out_summary); + +/** + * Re-attempt publishing a group's pending (committed-but-undelivered) + * commit(s) without minting a new event. + * + * An own send commits and projects locally *before* it publishes, so a + * message sent while offline (or when the relay was unreachable) lands in + * the timeline with `source_message_id_hex == NULL` — committed, not yet + * delivered. Re-sending the same text would mint a fresh commit and event + * id, duplicating the bubble. This drives the existing pending commit to + * the relays via convergence instead, so the original timeline row flips + * to delivered (`source_message_id_hex != NULL`) on success and no new + * event is created. Returns the delivery summary; `published == 0` means + * nothing was pending or publishing is still failing. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_summary` a valid pointer. Free the result with + * `marmot_send_summary_free`. + */ +MarmotStatus marmot_retry_group_convergence(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotSendSummary **out_summary); + +/** + * React to `target_message_id` with `emoji` (an "add" reaction). + * + * # Safety + * `client` must be a live handle; `account_ref`, `group_id_hex`, + * `target_message_id`, and `emoji` valid strings; `out_summary` a valid + * pointer. Free the result with `marmot_send_summary_free`. + */ +MarmotStatus marmot_react_to_message(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *target_message_id, + const char *emoji, + struct MarmotSendSummary **out_summary); + +/** + * Remove this account's reaction from `target_message_id`. + * + * # Safety + * `client` must be a live handle; `account_ref`, `group_id_hex`, and + * `target_message_id` valid strings; `out_summary` a valid pointer. Free + * the result with `marmot_send_summary_free`. + */ +MarmotStatus marmot_unreact_from_message(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *target_message_id, + struct MarmotSendSummary **out_summary); + +/** + * Send `text` as a reply that quotes `target_message_id`. + * + * # Safety + * `client` must be a live handle; `account_ref`, `group_id_hex`, + * `target_message_id`, and `text` valid strings; `out_summary` a valid + * pointer. Free the result with `marmot_send_summary_free`. + */ +MarmotStatus marmot_reply_to_message(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *target_message_id, + const char *text, + struct MarmotSendSummary **out_summary); + +/** + * Mark `target_message_id` deleted for the whole group. This is a + * tombstone — the original stays in everyone's store; clients render a + * "message deleted" placeholder. + * + * # Safety + * `client` must be a live handle; `account_ref`, `group_id_hex`, and + * `target_message_id` valid strings; `out_summary` a valid pointer. Free + * the result with `marmot_send_summary_free`. + */ +MarmotStatus marmot_delete_message(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *target_message_id, + struct MarmotSendSummary **out_summary); + +/** + * Securely scrub and prune expired disappearing-message plaintext for a + * group according to its active retention component. The media hash list + * identifies pruned encrypted-media blobs so host apps can purge their own + * decrypted-media disk caches keyed by ciphertext hash. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_result` a valid pointer. Free the result with + * `marmot_secure_delete_expired_result_free`. + */ +MarmotStatus marmot_secure_delete_expired(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotSecureDeleteExpiredResult **out_result); + +/** + * Edit `target_message_id` by publishing a kind-1009 event that + * references it and carries the replacement plaintext in `content`. + * Recipients honour the edit only when its authenticated author matches + * the target's author; mismatched edits are ignored client-side. + * + * The chat-list preview deliberately does not bump on an edit — an edit + * to a stale message must not reorder a conversation back to the top of + * the list. Host apps that aggregate edit history (e.g. an "(edited · N)" + * affordance) read the kind-1009 versions back from the timeline + * projection and resolve the latest text per target message id. + * + * # Safety + * `client` must be a live handle; `account_ref`, `group_id_hex`, + * `target_message_id`, and `content` valid strings; `out_summary` a valid + * pointer. Free the result with `marmot_send_summary_free`. + */ +MarmotStatus marmot_edit_message(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *target_message_id, + const char *content, + struct MarmotSendSummary **out_summary); + +/** + * Initial history fetch for a group (or, when `group_id_hex` is NULL, + * the account-wide tail). Used to populate the conversation view before + * the subscription stream takes over. When `has_limit` is false, `limit` + * is ignored and no row cap is applied. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `group_id_hex` NULL or a valid string; `out_list` a valid pointer. + * Free the result with `marmot_app_message_record_list_free`. + */ +MarmotStatus marmot_messages(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + bool has_limit, + uint32_t limit, + struct MarmotAppMessageRecordList **out_list); + +/** + * The account's current notification preferences. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_settings` a valid pointer. Free the result with + * `marmot_notification_settings_free`. + */ +MarmotStatus marmot_notification_settings(const struct MarmotClient *client, + const char *account_ref, + struct MarmotNotificationSettings **out_settings); + +/** + * Enable or disable locally rendered notifications for the account. + * Returns the updated settings. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_settings` a valid pointer. Free the result with + * `marmot_notification_settings_free`. + */ +MarmotStatus marmot_set_local_notifications_enabled(const struct MarmotClient *client, + const char *account_ref, + bool enabled, + struct MarmotNotificationSettings **out_settings); + +/** + * Enable or disable native push (APNs/FCM) delivery for the account. + * Returns the updated settings. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_settings` a valid pointer. Free the result with + * `marmot_notification_settings_free`. + */ +MarmotStatus marmot_set_native_push_enabled(const struct MarmotClient *client, + const char *account_ref, + bool enabled, + struct MarmotNotificationSettings **out_settings); + +/** + * Catch up all accounts on missed events (explicit foreground catch-up). + * + * # Safety + * `client` must be a live handle. + */ +MarmotStatus marmot_catch_up_accounts(const struct MarmotClient *client); + +/** + * Run a bounded background notification collection pass after a platform + * wake (`source`), waiting at most `max_wait_ms` milliseconds. Infallible: + * collection failures are reported inside the returned record's `status` + * and `error` fields. + * + * # Safety + * `client` must be a live handle; `out_collection` a valid pointer. Free + * the result with `marmot_background_notification_collection_free`. + */ +MarmotStatus marmot_collect_notifications_after_wake(const struct MarmotClient *client, + uint32_t max_wait_ms, + enum MarmotNotificationWakeSource source, + struct MarmotBackgroundNotificationCollection **out_collection); + +/** + * The account's current local native-push registration, if any. Writes + * NULL with `MARMOT_STATUS_OK` when no registration exists — callers + * distinguish "absent" from failure by the status code. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_registration` a valid pointer. Free a non-NULL result with + * `marmot_push_registration_free`. + */ +MarmotStatus marmot_push_registration(const struct MarmotClient *client, + const char *account_ref, + struct MarmotPushRegistration **out_registration); + +/** + * Create or update the account's native-push registration for a device + * token. Only a privacy-safe fingerprint of `raw_token` is ever returned + * or stored in the registration record. + * + * # Safety + * `client` must be a live handle; `account_ref`, `raw_token`, and + * `server_pubkey_hex` valid strings; `relay_hint` a valid string or NULL; + * `out_registration` a valid pointer. Free the result with + * `marmot_push_registration_free`. + */ +MarmotStatus marmot_upsert_push_registration(const struct MarmotClient *client, + const char *account_ref, + enum MarmotPushPlatform platform, + const char *raw_token, + const char *server_pubkey_hex, + const char *relay_hint, + struct MarmotPushRegistration **out_registration); + +/** + * Remove the account's local native-push registration. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string. + */ +MarmotStatus marmot_clear_push_registration(const struct MarmotClient *client, + const char *account_ref); + +/** + * Aggregated push-token diagnostics for one group: token counts, the + * local member's registration state, and every cached member token entry. + * + * `group_id_hex` is the hex of the opaque MLS `GroupId` bytes (variable + * length), not the 32-byte Nostr routing id. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_info` a valid pointer. Free the result with + * `marmot_group_push_debug_info_free`. + */ +MarmotStatus marmot_group_push_debug_info(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotGroupPushDebugInfo **out_info); + +/** + * Per-account relay lists: the NIP-65 and inbox lists the account has + * published, plus the configured default/bootstrap sets. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_lists` a valid pointer. Free the result with + * `marmot_account_relay_lists_free`. + */ +MarmotStatus marmot_account_relay_lists(const struct MarmotClient *client, + const char *account_ref, + struct MarmotAccountRelayLists **out_lists); + +/** + * Live relay-plane connection health (connected / connecting / + * disconnected counts, etc.) for the relay diagnostics view. + * + * # Safety + * `client` must be a live handle; `out_health` a valid pointer. Free + * the result with `marmot_relay_health_free`. + */ +MarmotStatus marmot_relay_health(const struct MarmotClient *client, + struct MarmotRelayHealth **out_health); + +/** + * Device-wide relay telemetry export settings. Export is opt-in and stays + * inert until `export_enabled` is true and runtime/default config supplies + * a valid OTLP endpoint, bearer token, and resource attributes. + * + * # Safety + * `client` must be a live handle; `out_settings` a valid pointer. Free + * the result with `marmot_relay_telemetry_settings_free`. + */ +MarmotStatus marmot_relay_telemetry_settings(const struct MarmotClient *client, + struct MarmotRelayTelemetrySettings **out_settings); + +/** + * Stable random identifier for this app install, suitable for the OTLP + * `service.instance.id` resource attribute. Separate from audit-log device + * identity. + * + * # Safety + * `client` must be a live handle; `out_install_id` a valid pointer. Free + * the result with `marmot_string_free`. + */ +MarmotStatus marmot_telemetry_install_id(const struct MarmotClient *client, char **out_install_id); + +/** + * Supply non-persisted OTLP runtime metadata: optional metrics URL + * override, bearer token from the host app's build-time secret, and + * resource attributes from the platform shell. + * + * # Safety + * `client` must be a live handle; `config` must point to a valid + * caller-owned `MarmotRelayTelemetryRuntimeConfig` (never freed by the + * library). + */ +MarmotStatus marmot_set_relay_telemetry_runtime_config(const struct MarmotClient *client, + const struct MarmotRelayTelemetryRuntimeConfig *config); + +/** + * Persist device-wide relay telemetry export settings and return the + * normalized settings that were stored. + * + * # Safety + * `client` must be a live handle; `settings` must point to a valid + * caller-owned `MarmotRelayTelemetrySettings` (never freed by the + * library); `out_settings` a valid pointer. Free the result with + * `marmot_relay_telemetry_settings_free`. + */ +MarmotStatus marmot_set_relay_telemetry_settings(const struct MarmotClient *client, + const struct MarmotRelayTelemetrySettings *settings, + struct MarmotRelayTelemetrySettings **out_settings); + +/** + * Materialized conversation timeline for a group or account-wide tail. + * + * This is the app-facing aggregated view: kind-9 chat/reply/media rows, + * kind-1200 stream-start rows, stream-final metadata pointing back to the + * start, reaction summaries, delete tombstones, and pagination flags. Raw + * kind-7/kind-5 events remain available through `marmot_messages` for + * diagnostics. + * + * This call is **synchronous** and runs the store read on the calling + * thread; clients should not use it for scroll-back. Prefer the timeline + * subscription (`marmot_subscribe_timeline_messages`) plus its + * `paginate_backwards` / `paginate_forwards`, which own a bounded window + * and run off the caller thread. Retained for one-shot + * diagnostics/tooling only. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; `query` a + * valid borrowed struct (never freed by the library) whose non-NULL string + * fields are valid NUL-terminated strings; `out_page` a valid pointer. + * Free the result with `marmot_timeline_page_free`. + */ +MarmotStatus marmot_timeline_messages(const struct MarmotClient *client, + const char *account_ref, + const struct MarmotTimelineMessageQuery *query, + struct MarmotTimelinePage **out_page); + +/** + * Subscribe to the event firehose. Free with + * `marmot_events_subscription_free` (before freeing the client). + * + * # Safety + * `client` must be a live handle; `out_sub` must be a valid pointer. + */ +MarmotStatus marmot_subscribe_events(const struct MarmotClient *client, + struct MarmotEventsSubscription **out_sub); + +/** + * Block until the next event, the timeout, or stream close. + * `timeout_ms == 0` waits indefinitely. Returns `MARMOT_STATUS_OK` (out + * set; free with `marmot_event_free`), `MARMOT_STATUS_TIMEOUT`, or + * `MARMOT_STATUS_CLOSED` (out NULL for both). + * + * # Safety + * `sub` must be a live handle from `marmot_subscribe_events`; + * `out_event` must be a valid pointer. + */ +MarmotStatus marmot_events_subscription_next(const struct MarmotEventsSubscription *sub, + uint32_t timeout_ms, + struct MarmotEvent **out_event); + +/** + * Install a callback pump for this subscription. `callback` runs on a + * runtime worker thread with a borrowed event pointer (valid only during + * the call; do not store or free it) and a final NULL event on close. + * `callback` and `user_data` access must be thread-safe. Fails if a + * callback is already installed. + * + * # Safety + * `sub` must be a live handle; `callback` must be a valid function + * pointer. `user_data` must outlive every callback invocation. Note that + * `clear_callback` / `*_subscription_free` request cancellation without + * waiting (tokio `abort` is non-blocking), so a callback can still be + * running after they return — do not free `user_data` until your own + * synchronization proves no callback is in flight (see the module docs). + */ +MarmotStatus marmot_events_subscription_set_callback(const struct MarmotEventsSubscription *sub, + MarmotEventCallback callback, + void *user_data); + +/** + * Request cancellation of this subscription's callback pump, if any. This + * does not wait: a callback already running on a worker thread keeps + * executing and returns after this call does (tokio `abort` is + * non-blocking). It is not a synchronization point for freeing + * `user_data` — see the module docs. + * + * # Safety + * `sub` must be a live handle. + */ +MarmotStatus marmot_events_subscription_clear_callback(const struct MarmotEventsSubscription *sub); + +/** + * Free the subscription handle. Requests callback-pump cancellation + * without waiting (see the module docs: a callback may still be running + * after this returns, so do not free `user_data` here on that basis). + * NULL is a no-op. Free every handle before the client that created it. + * + * # Safety + * `sub` must be NULL or an unfreed pointer from + * `marmot_subscribe_events`. + */ +void marmot_events_subscription_free(struct MarmotEventsSubscription *sub); + +/** + * Subscribe to live materialized timeline updates for a group + * (`group_id_hex` non-NULL) or the account-wide tail (NULL). `has_limit` + * plus `limit` cap the initial window. Free with + * `marmot_timeline_subscription_free`. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `group_id_hex` NULL or a valid string; `out_sub` valid. + */ +MarmotStatus marmot_subscribe_timeline_messages(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + bool has_limit, + uint32_t limit, + struct MarmotTimelineSubscription **out_sub); + +/** + * Take the initial window snapshot. Yields the page exactly once: later + * calls (or a snapshot already consumed by this handle) write NULL with + * `MARMOT_STATUS_OK`. Free the page with `marmot_timeline_page_free`. + * + * # Safety + * `sub` must be a live handle; `out_page` valid. + */ +MarmotStatus marmot_timeline_subscription_snapshot(const struct MarmotTimelineSubscription *sub, + struct MarmotTimelinePage **out_page); + +/** + * Block until the next live update and return the resulting full window + * (already sorted, deduplicated, and capped — render directly). Same + * OK / TIMEOUT / CLOSED convention as every subscription `next`. Free + * with `marmot_timeline_page_free`. + * + * # Safety + * `sub` must be a live handle; `out_page` valid. + */ +MarmotStatus marmot_timeline_subscription_next(const struct MarmotTimelineSubscription *sub, + uint32_t timeout_ms, + struct MarmotTimelinePage **out_page); + +/** + * Block until the next raw delta (page replacement or projection + * update). Free with `marmot_timeline_subscription_update_free`. + * + * # Safety + * `sub` must be a live handle; `out_update` valid. + */ +MarmotStatus marmot_timeline_subscription_next_update(const struct MarmotTimelineSubscription *sub, + uint32_t timeout_ms, + struct MarmotTimelineSubscriptionUpdate **out_update); + +/** + * Extend the window toward older history by up to `count` messages and + * return the new window. Runs on the runtime off the caller's lock, so a + * concurrent blocking `next` on another thread is not blocked. Free with + * `marmot_timeline_page_free`. + * + * # Safety + * `sub` must be a live handle; `out_page` valid. + */ +MarmotStatus marmot_timeline_subscription_paginate_backwards(const struct MarmotTimelineSubscription *sub, + uint32_t count, + struct MarmotTimelinePage **out_page); + +/** + * Extend the window toward the live head by up to `count` messages and + * return the new window. Reaching the head re-anchors the window. Free + * with `marmot_timeline_page_free`. + * + * # Safety + * `sub` must be a live handle; `out_page` valid. + */ +MarmotStatus marmot_timeline_subscription_paginate_forwards(const struct MarmotTimelineSubscription *sub, + uint32_t count, + struct MarmotTimelinePage **out_page); + +/** + * Install a full-window callback pump (each call receives the borrowed + * authoritative window; final NULL page on close). Same rules as + * `marmot_events_subscription_set_callback`. + * + * # Safety + * Same as `marmot_events_subscription_set_callback`. + */ +MarmotStatus marmot_timeline_subscription_set_callback(const struct MarmotTimelineSubscription *sub, + MarmotTimelinePageCallback callback, + void *user_data); + +/** + * Cancel this subscription's callback pump, if any. + * + * # Safety + * `sub` must be a live handle. + */ +MarmotStatus marmot_timeline_subscription_clear_callback(const struct MarmotTimelineSubscription *sub); + +/** + * Free the subscription handle. Requests callback-pump cancellation + * without waiting (see the module docs: a callback may still be running + * after this returns, so do not free `user_data` here on that basis). + * NULL is a no-op. Free every handle before the client that created it. + * + * # Safety + * `sub` must be NULL or an unfreed pointer from + * `marmot_subscribe_timeline_messages`. + */ +void marmot_timeline_subscription_free(struct MarmotTimelineSubscription *sub); + +/** + * Subscribe to notification updates. Free with + * `marmot_notifications_subscription_free` (before freeing the client). + * + * # Safety + * `client` must be a live handle; `out_sub` must be a valid pointer. + */ +MarmotStatus marmot_subscribe_notifications(const struct MarmotClient *client, + struct MarmotNotificationsSubscription **out_sub); + +/** + * Block until the next notification update, the timeout, or stream + * close. `timeout_ms == 0` waits indefinitely. Returns + * `MARMOT_STATUS_OK` (out set; free with + * `marmot_notification_update_free`), `MARMOT_STATUS_TIMEOUT`, or + * `MARMOT_STATUS_CLOSED` (out NULL for both). + * + * # Safety + * `sub` must be a live handle from `marmot_subscribe_notifications`; + * `out_update` must be a valid pointer. + */ +MarmotStatus marmot_notifications_subscription_next(const struct MarmotNotificationsSubscription *sub, + uint32_t timeout_ms, + struct MarmotNotificationUpdate **out_update); + +/** + * Install a callback pump for this subscription. Same rules as + * `marmot_events_subscription_set_callback`: borrowed item pointer, + * runtime worker thread, final NULL on close, one callback at a time. + * + * # Safety + * Same as `marmot_events_subscription_set_callback`. + */ +MarmotStatus marmot_notifications_subscription_set_callback(const struct MarmotNotificationsSubscription *sub, + MarmotNotificationUpdateCallback callback, + void *user_data); + +/** + * Cancel this subscription's callback pump, if any. + * + * # Safety + * `sub` must be a live handle. + */ +MarmotStatus marmot_notifications_subscription_clear_callback(const struct MarmotNotificationsSubscription *sub); + +/** + * Free the subscription handle. Requests callback-pump cancellation + * without waiting (see the module docs: a callback may still be running + * after this returns, so do not free `user_data` here on that basis). + * NULL is a no-op. Free every handle before the client that created it. + * + * # Safety + * `sub` must be NULL or an unfreed pointer from + * `marmot_subscribe_notifications`. + */ +void marmot_notifications_subscription_free(struct MarmotNotificationsSubscription *sub); + +/** + * Subscribe to one account's chats list. Emits whenever a group's + * projection changes; `include_archived` widens the filter. Free with + * `marmot_chats_subscription_free`. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_sub` valid. + */ +MarmotStatus marmot_subscribe_chats(const struct MarmotClient *client, + const char *account_ref, + bool include_archived, + struct MarmotChatsSubscription **out_sub); + +/** + * Take the initial chats snapshot. Yields the populated list exactly + * once: later calls write an EMPTY list, still with `MARMOT_STATUS_OK`. + * Free the list with `marmot_app_group_record_list_free`. + * + * # Safety + * `sub` must be a live handle; `out_list` valid. + */ +MarmotStatus marmot_chats_subscription_snapshot(const struct MarmotChatsSubscription *sub, + struct MarmotAppGroupRecordList **out_list); + +/** + * Block until the next changed group record, the timeout, or stream + * close. Same OK / TIMEOUT / CLOSED convention as every subscription + * `next`. Free with `marmot_app_group_record_free`. + * + * # Safety + * `sub` must be a live handle; `out_record` valid. + */ +MarmotStatus marmot_chats_subscription_next(const struct MarmotChatsSubscription *sub, + uint32_t timeout_ms, + struct MarmotAppGroupRecord **out_record); + +/** + * Install a callback pump for this subscription. Same rules as + * `marmot_events_subscription_set_callback`. + * + * # Safety + * Same as `marmot_events_subscription_set_callback`. + */ +MarmotStatus marmot_chats_subscription_set_callback(const struct MarmotChatsSubscription *sub, + MarmotAppGroupRecordCallback callback, + void *user_data); + +/** + * Cancel this subscription's callback pump, if any. + * + * # Safety + * `sub` must be a live handle. + */ +MarmotStatus marmot_chats_subscription_clear_callback(const struct MarmotChatsSubscription *sub); + +/** + * Free the subscription handle. Requests callback-pump cancellation + * without waiting (see the module docs: a callback may still be running + * after this returns, so do not free `user_data` here on that basis). + * NULL is a no-op. Free every handle before the client that created it. + * + * # Safety + * `sub` must be NULL or an unfreed pointer from + * `marmot_subscribe_chats`. + */ +void marmot_chats_subscription_free(struct MarmotChatsSubscription *sub); + +/** + * Subscribe to one account's durable chat-list projection. Free with + * `marmot_chat_list_subscription_free`. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `out_sub` valid. + */ +MarmotStatus marmot_subscribe_chat_list(const struct MarmotClient *client, + const char *account_ref, + bool include_archived, + struct MarmotChatListSubscription **out_sub); + +/** + * Take the initial chat-list snapshot. Yields the populated list exactly + * once: later calls write an EMPTY list, still with `MARMOT_STATUS_OK`. + * Free the list with `marmot_chat_list_row_list_free`. + * + * # Safety + * `sub` must be a live handle; `out_list` valid. + */ +MarmotStatus marmot_chat_list_subscription_snapshot(const struct MarmotChatListSubscription *sub, + struct MarmotChatListRowList **out_list); + +/** + * Block until the next upserted row, the timeout, or stream close. + * Row-removal deltas are skipped internally — receive them via + * `marmot_chat_list_subscription_next_update` instead. Free with + * `marmot_chat_list_row_free`. + * + * # Safety + * `sub` must be a live handle; `out_row` valid. + */ +MarmotStatus marmot_chat_list_subscription_next(const struct MarmotChatListSubscription *sub, + uint32_t timeout_ms, + struct MarmotChatListRow **out_row); + +/** + * Block until the next raw chat-list delta (row upsert or removal). Free + * with `marmot_chat_list_subscription_update_free`. + * + * # Safety + * `sub` must be a live handle; `out_update` valid. + */ +MarmotStatus marmot_chat_list_subscription_next_update(const struct MarmotChatListSubscription *sub, + uint32_t timeout_ms, + struct MarmotChatListSubscriptionUpdate **out_update); + +/** + * Install an upserted-row callback pump for this subscription. Same + * rules as `marmot_events_subscription_set_callback`. + * + * # Safety + * Same as `marmot_events_subscription_set_callback`. + */ +MarmotStatus marmot_chat_list_subscription_set_callback(const struct MarmotChatListSubscription *sub, + MarmotChatListRowCallback callback, + void *user_data); + +/** + * Cancel this subscription's callback pump, if any. + * + * # Safety + * `sub` must be a live handle. + */ +MarmotStatus marmot_chat_list_subscription_clear_callback(const struct MarmotChatListSubscription *sub); + +/** + * Free the subscription handle. Requests callback-pump cancellation + * without waiting (see the module docs: a callback may still be running + * after this returns, so do not free `user_data` here on that basis). + * NULL is a no-op. Free every handle before the client that created it. + * + * # Safety + * `sub` must be NULL or an unfreed pointer from + * `marmot_subscribe_chat_list`. + */ +void marmot_chat_list_subscription_free(struct MarmotChatListSubscription *sub); + +/** + * Subscribe to messages for a specific group (`group_id_hex` non-NULL) + * or every message across the account (NULL). `has_limit` + `limit` cap + * the initial snapshot to the latest N rows; live updates continue after + * the snapshot. Free with `marmot_messages_subscription_free`. + * + * # Safety + * `client` must be a live handle; `account_ref` a valid string; + * `group_id_hex` NULL or a valid string; `out_sub` valid. + */ +MarmotStatus marmot_subscribe_messages(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + bool has_limit, + uint32_t limit, + struct MarmotMessagesSubscription **out_sub); + +/** + * Take the initial message-record snapshot. Yields the populated list + * exactly once: later calls write an EMPTY list, still with + * `MARMOT_STATUS_OK`. Free the list with + * `marmot_app_message_record_list_free`. + * + * # Safety + * `sub` must be a live handle; `out_list` valid. + */ +MarmotStatus marmot_messages_subscription_snapshot(const struct MarmotMessagesSubscription *sub, + struct MarmotAppMessageRecordList **out_list); + +/** + * Block until the next message update, the timeout, or stream close. + * Same OK / TIMEOUT / CLOSED convention as every subscription `next`. + * Free with `marmot_message_update_free`. + * + * # Safety + * `sub` must be a live handle; `out_update` valid. + */ +MarmotStatus marmot_messages_subscription_next(const struct MarmotMessagesSubscription *sub, + uint32_t timeout_ms, + struct MarmotMessageUpdate **out_update); + +/** + * Install a callback pump for this subscription. Same rules as + * `marmot_events_subscription_set_callback`. + * + * # Safety + * Same as `marmot_events_subscription_set_callback`. + */ +MarmotStatus marmot_messages_subscription_set_callback(const struct MarmotMessagesSubscription *sub, + MarmotMessageUpdateCallback callback, + void *user_data); + +/** + * Cancel this subscription's callback pump, if any. + * + * # Safety + * `sub` must be a live handle. + */ +MarmotStatus marmot_messages_subscription_clear_callback(const struct MarmotMessagesSubscription *sub); + +/** + * Free the subscription handle. Requests callback-pump cancellation + * without waiting (see the module docs: a callback may still be running + * after this returns, so do not free `user_data` here on that basis). + * NULL is a no-op. Free every handle before the client that created it. + * + * # Safety + * `sub` must be NULL or an unfreed pointer from + * `marmot_subscribe_messages`. + */ +void marmot_messages_subscription_free(struct MarmotMessagesSubscription *sub); + +/** + * Subscribe to member/profile/roster changes for one group. Free with + * `marmot_group_state_subscription_free`. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `out_sub` valid. + */ +MarmotStatus marmot_subscribe_group_state(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + struct MarmotGroupStateSubscription **out_sub); + +/** + * Take the initial group-record snapshot. Yields the record exactly + * once: later calls (or a snapshot already consumed by this handle) + * write NULL with `MARMOT_STATUS_OK`. Free the record with + * `marmot_app_group_record_free`. + * + * # Safety + * `sub` must be a live handle; `out_record` valid. + */ +MarmotStatus marmot_group_state_subscription_snapshot(const struct MarmotGroupStateSubscription *sub, + struct MarmotAppGroupRecord **out_record); + +/** + * Block until the group's next full record, the timeout, or stream + * close. Same OK / TIMEOUT / CLOSED convention as every subscription + * `next`. Free with `marmot_app_group_record_free`. + * + * # Safety + * `sub` must be a live handle; `out_record` valid. + */ +MarmotStatus marmot_group_state_subscription_next(const struct MarmotGroupStateSubscription *sub, + uint32_t timeout_ms, + struct MarmotAppGroupRecord **out_record); + +/** + * Install a callback pump for this subscription. Same rules as + * `marmot_events_subscription_set_callback`. + * + * # Safety + * Same as `marmot_events_subscription_set_callback`. + */ +MarmotStatus marmot_group_state_subscription_set_callback(const struct MarmotGroupStateSubscription *sub, + MarmotAppGroupRecordCallback callback, + void *user_data); + +/** + * Cancel this subscription's callback pump, if any. + * + * # Safety + * `sub` must be a live handle. + */ +MarmotStatus marmot_group_state_subscription_clear_callback(const struct MarmotGroupStateSubscription *sub); + +/** + * Free the subscription handle. Requests callback-pump cancellation + * without waiting (see the module docs: a callback may still be running + * after this returns, so do not free `user_data` here on that basis). + * NULL is a no-op. Free every handle before the client that created it. + * + * # Safety + * `sub` must be NULL or an unfreed pointer from + * `marmot_subscribe_group_state`. + */ +void marmot_group_state_subscription_free(struct MarmotGroupStateSubscription *sub); + +/** + * Watch a live agent text stream over the brokered QUIC channel. Pass + * `stream_id_hex = NULL` to follow the latest stream in the group (the + * common case when reacting to an AgentStreamStarted event). + * `server_cert_der` (+ `server_cert_der_len`) pins a self-signed broker + * certificate; pass NULL with length 0 to use platform trust. The bytes + * are copied — the caller keeps ownership. `insecure_local` is + * loopback-only for testing. Free with + * `marmot_agent_stream_subscription_free`. + * + * # Safety + * `client` must be a live handle; `account_ref` and `group_id_hex` valid + * strings; `stream_id_hex` NULL or a valid string; `server_cert_der` + * NULL with length 0, or a pointer to `server_cert_der_len` valid bytes; + * `out_sub` valid. + */ +MarmotStatus marmot_watch_agent_text_stream(const struct MarmotClient *client, + const char *account_ref, + const char *group_id_hex, + const char *stream_id_hex, + const uint8_t *server_cert_der, + uintptr_t server_cert_der_len, + bool insecure_local, + struct MarmotAgentStreamSubscription **out_sub); + +/** + * The resolved stream id this watch is following (hex). Writes an owned + * copy: free it with `marmot_string_free`. + * + * # Safety + * `sub` must be a live handle from `marmot_watch_agent_text_stream`; + * `out_stream_id_hex` must be a valid pointer. + */ +MarmotStatus marmot_agent_stream_subscription_stream_id_hex(const struct MarmotAgentStreamSubscription *sub, + char **out_stream_id_hex); + +/** + * Block until the next agent-stream update, the timeout, or stream + * close. `Chunk`s arrive incrementally; a terminal `Finished` / `Failed` + * precedes `MARMOT_STATUS_CLOSED`. Free with + * `marmot_agent_stream_update_free`. + * + * # Safety + * `sub` must be a live handle; `out_update` valid. + */ +MarmotStatus marmot_agent_stream_subscription_next(const struct MarmotAgentStreamSubscription *sub, + uint32_t timeout_ms, + struct MarmotAgentStreamUpdate **out_update); + +/** + * Install a callback pump for this subscription. Same rules as + * `marmot_events_subscription_set_callback`. + * + * # Safety + * Same as `marmot_events_subscription_set_callback`. + */ +MarmotStatus marmot_agent_stream_subscription_set_callback(const struct MarmotAgentStreamSubscription *sub, + MarmotAgentStreamUpdateCallback callback, + void *user_data); + +/** + * Cancel this subscription's callback pump, if any. + * + * # Safety + * `sub` must be a live handle. + */ +MarmotStatus marmot_agent_stream_subscription_clear_callback(const struct MarmotAgentStreamSubscription *sub); + +/** + * Free the subscription handle. Requests callback-pump cancellation + * without waiting (see the module docs: a callback may still be running + * after this returns, so do not free `user_data` here on that basis). + * NULL is a no-op. Free every handle before the client that created it. + * + * # Safety + * `sub` must be NULL or an unfreed pointer from + * `marmot_watch_agent_text_stream`. + */ +void marmot_agent_stream_subscription_free(struct MarmotAgentStreamSubscription *sub); + +/** + * Free a list returned by `marmot_list_accounts`. NULL is a no-op. + * + * # Safety + * `list` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_account_summary_list_free(struct MarmotAccountSummaryList *list); + +/** + * Free a single account summary root. NULL is a no-op. + * + * # Safety + * `summary` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_account_summary_free(struct MarmotAccountSummary *summary); + +/** + * Free a list returned by `marmot_account_unread_summary`. NULL is a no-op. + * + * # Safety + * `list` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_account_unread_list_free(struct MarmotAccountUnreadList *list); + +/** + * Free a send summary root. NULL is a no-op. + * + * # Safety + * `summary` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_send_summary_free(struct MarmotSendSummary *summary); + +/** + * Free a list returned by `marmot_account_key_packages`. NULL is a no-op. + * + * # Safety + * `list` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_account_key_package_list_free(struct MarmotAccountKeyPackageList *list); + +/** + * Free a profile returned by this library. Never call on structs you + * allocated yourself as inputs. NULL is a no-op. + * + * # Safety + * `profile` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_user_profile_metadata_free(struct MarmotUserProfileMetadata *profile); + +/** + * Free a wipe outcome root. NULL is a no-op. + * + * # Safety + * `outcome` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_wipe_outcome_free(struct MarmotWipeOutcome *outcome); + +/** + * Free a sign-out outcome root. NULL is a no-op. + * + * # Safety + * `outcome` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_sign_out_outcome_free(struct MarmotSignOutOutcome *outcome); + +/** + * Free an agent stream start root. NULL is a no-op. + * + * # Safety + * `start` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_agent_stream_start_free(struct MarmotAgentStreamStart *start); + +/** + * Free an agent stream update root (delivered by the agent-stream + * subscription). NULL is a no-op. + * + * # Safety + * `update` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_agent_stream_update_free(struct MarmotAgentStreamUpdate *update); + +/** + * Free a single audit log file root. NULL is a no-op. + * + * # Safety + * `file` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_audit_log_file_free(struct MarmotAuditLogFile *file); + +/** + * Free a list returned by `marmot_audit_log_files`. NULL is a no-op. + * + * # Safety + * `list` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_audit_log_file_list_free(struct MarmotAuditLogFileList *list); + +/** + * Free an upload result root. NULL is a no-op. + * + * # Safety + * `result` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_audit_log_upload_result_free(struct MarmotAuditLogUploadResult *result); + +/** + * Free a delete result root. NULL is a no-op. + * + * # Safety + * `result` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_audit_log_delete_result_free(struct MarmotAuditLogDeleteResult *result); + +/** + * Free a tracker update result root. NULL is a no-op. + * + * # Safety + * `result` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_audit_log_tracker_update_result_free(struct MarmotAuditLogTrackerUpdateResult *result); + +/** + * Free settings returned by this library. Never call on structs you + * allocated yourself as inputs. NULL is a no-op. + * + * # Safety + * `settings` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_audit_log_settings_free(struct MarmotAuditLogSettings *settings); + +/** + * Free a tracker config returned by this library. Never call on structs + * you allocated yourself as inputs. NULL is a no-op. + * + * # Safety + * `config` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_audit_log_tracker_config_free(struct MarmotAuditLogTrackerConfig *config); + +/** + * Free a single chat-list row root (e.g. from + * `marmot_initialize_chat_read_state` or `marmot_mark_timeline_message_read`). + * Never call on rows embedded inside a list or subscription update. NULL is + * a no-op. + * + * # Safety + * `row` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_chat_list_row_free(struct MarmotChatListRow *row); + +/** + * Free a chat-list row list returned by this library. NULL is a no-op. + * + * # Safety + * `list` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_chat_list_row_list_free(struct MarmotChatListRowList *list); + +/** + * Free a chat-list subscription update root (`*_next_update`). NULL is a + * no-op. + * + * # Safety + * `update` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_chat_list_subscription_update_free(struct MarmotChatListSubscriptionUpdate *update); + +/** + * Free a single message tag root. NULL is a no-op. Tags nested inside a + * message record are owned by that record and freed with it — never + * individually. + * + * # Safety + * `tag` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_message_tag_free(struct MarmotMessageTag *tag); + +/** + * Free a string list returned by this library. NULL is a no-op. + * + * # Safety + * `list` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_string_list_free(struct MarmotStringList *list); + +/** + * Free an event root returned by the events subscription. NULL is a no-op. + * + * # Safety + * `event` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_event_free(struct MarmotEvent *event); + +/** + * Free a group record root (`marmot_accept_group_invite`, + * `marmot_set_group_archived`, subscription snapshots). NULL is a no-op. + * + * # Safety + * `record` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_app_group_record_free(struct MarmotAppGroupRecord *record); + +/** + * Free a group record list root. NULL is a no-op. + * + * # Safety + * `list` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_app_group_record_list_free(struct MarmotAppGroupRecordList *list); + +/** + * Free a list returned by `marmot_group_members`. NULL is a no-op. + * + * # Safety + * `list` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_app_group_member_record_list_free(struct MarmotAppGroupMemberRecordList *list); + +/** + * Free a member reference root. NULL is a no-op. + * + * # Safety + * `member_ref` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_member_ref_free(struct MarmotMemberRef *member_ref); + +/** + * Free a group details root. NULL is a no-op. + * + * # Safety + * `details` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_group_details_free(struct MarmotGroupDetails *details); + +/** + * Free a group management state root. NULL is a no-op. + * + * # Safety + * `state` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_group_management_state_free(struct MarmotGroupManagementState *state); + +/** + * Free a group mutation result root. NULL is a no-op. + * + * # Safety + * `result` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_group_mutation_result_free(struct MarmotGroupMutationResult *result); + +/** + * Free a group invite decline result root. NULL is a no-op. + * + * # Safety + * `result` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_group_invite_decline_result_free(struct MarmotGroupInviteDeclineResult *result); + +/** + * Free a group MLS state root. NULL is a no-op. + * + * # Safety + * `state` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_app_group_mls_state_free(struct MarmotAppGroupMlsState *state); + +/** + * Free a list returned by `marmot_quarantined_groups`. NULL is a no-op. + * + * # Safety + * `list` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_app_quarantined_group_list_free(struct MarmotAppQuarantinedGroupList *list); + +/** + * Free a document returned by `marmot_parse_markdown`. Never call on + * documents embedded by value inside another struct. NULL is a no-op. + * + * # Safety + * `document` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_markdown_document_free(struct MarmotMarkdownDocument *document); + +/** + * Free an upload result root returned by `marmot_upload_media`. NULL is a + * no-op. + * + * # Safety + * `result` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_media_upload_result_free(struct MarmotMediaUploadResult *result); + +/** + * Free a download result root returned by `marmot_download_media`. NULL is + * a no-op. + * + * # Safety + * `result` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_media_download_result_free(struct MarmotMediaDownloadResult *result); + +/** + * Free a list returned by `marmot_list_media`. NULL is a no-op. + * + * # Safety + * `list` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_media_record_list_free(struct MarmotMediaRecordList *list); + +/** + * Free a single message record root (e.g. a subscription snapshot row). + * NULL is a no-op. + * + * # Safety + * `record` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_app_message_record_free(struct MarmotAppMessageRecord *record); + +/** + * Free a list returned by `marmot_messages`. NULL is a no-op. + * + * # Safety + * `list` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_app_message_record_list_free(struct MarmotAppMessageRecordList *list); + +/** + * Free a secure-delete result root. NULL is a no-op. + * + * # Safety + * `result` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_secure_delete_expired_result_free(struct MarmotSecureDeleteExpiredResult *result); + +/** + * Free a runtime-message-received root. Never call on values embedded by + * value inside another struct. NULL is a no-op. + * + * # Safety + * `received` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_runtime_message_received_free(struct MarmotRuntimeMessageReceived *received); + +/** + * Free a message-subscription update root. NULL is a no-op. + * + * # Safety + * `update` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_message_update_free(struct MarmotMessageUpdate *update); + +/** + * Free a notification settings root. NULL is a no-op. + * + * # Safety + * `settings` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_notification_settings_free(struct MarmotNotificationSettings *settings); + +/** + * Free a notification update root (delivered by the notification + * subscription). NULL is a no-op. + * + * # Safety + * `update` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_notification_update_free(struct MarmotNotificationUpdate *update); + +/** + * Free a background collection root. NULL is a no-op. + * + * # Safety + * `collection` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_background_notification_collection_free(struct MarmotBackgroundNotificationCollection *collection); + +/** + * Free a push registration root. NULL is a no-op. + * + * # Safety + * `registration` must be NULL or an unfreed pointer returned by this + * library. + */ +void marmot_push_registration_free(struct MarmotPushRegistration *registration); + +/** + * Free a group push-debug info root. NULL is a no-op. + * + * # Safety + * `info` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_group_push_debug_info_free(struct MarmotGroupPushDebugInfo *info); + +/** + * Free a telemetry settings root returned by this library. Never call on + * structs you allocated yourself as inputs. NULL is a no-op. + * + * # Safety + * `settings` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_relay_telemetry_settings_free(struct MarmotRelayTelemetrySettings *settings); + +/** + * Free a telemetry resource root returned by this library. Never call on + * structs you allocated yourself as inputs. NULL is a no-op. + * + * # Safety + * `resource` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_relay_telemetry_resource_free(struct MarmotRelayTelemetryResource *resource); + +/** + * Free a runtime config root returned by this library. Never call on + * structs you allocated yourself as inputs. NULL is a no-op. + * + * # Safety + * `config` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_relay_telemetry_runtime_config_free(struct MarmotRelayTelemetryRuntimeConfig *config); + +/** + * Free an account relay lists root. NULL is a no-op. + * + * # Safety + * `lists` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_account_relay_lists_free(struct MarmotAccountRelayLists *lists); + +/** + * Free a relay health root. NULL is a no-op. + * + * # Safety + * `health` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_relay_health_free(struct MarmotRelayHealth *health); + +/** + * Free a single message record root. NULL is a no-op. Records nested inside + * a page or update are owned by that root and freed with it — never + * individually. + * + * # Safety + * `record` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_timeline_message_record_free(struct MarmotTimelineMessageRecord *record); + +/** + * Free a timeline page root. NULL is a no-op. + * + * # Safety + * `page` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_timeline_page_free(struct MarmotTimelinePage *page); + +/** + * Free a subscription update root (timeline subscription `next_update`). + * NULL is a no-op. + * + * # Safety + * `update` must be NULL or an unfreed pointer returned by this library. + */ +void marmot_timeline_subscription_update_free(struct MarmotTimelineSubscriptionUpdate *update); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus diff --git a/crates/marmot-c/marmot-c.pc.in b/crates/marmot-c/marmot-c.pc.in new file mode 100644 index 00000000..e4a464f6 --- /dev/null +++ b/crates/marmot-c/marmot-c.pc.in @@ -0,0 +1,12 @@ +# pkg-config template for marmot-c; instantiated by c-bindings.sh with the +# staged output/ layout. Relocatable: prefix resolves relative to this file. +prefix=${pcfiledir}/../.. +libdir=${prefix}/lib +includedir=${prefix}/include + +Name: marmot-c +Description: C ABI bindings for the Marmot app runtime +Version: @VERSION@ +Libs: -L${libdir} -lmarmot_c +Libs.private: -lm -lpthread -ldl +Cflags: -I${includedir} diff --git a/crates/marmot-c/src/commands/account.rs b/crates/marmot-c/src/commands/account.rs new file mode 100644 index 00000000..1ea266d2 --- /dev/null +++ b/crates/marmot-c/src/commands/account.rs @@ -0,0 +1,559 @@ +//! Account lifecycle, key-package, relay-list, and profile commands. + +use std::ffi::c_char; + +use crate::memory::{required_str, str_array}; +use crate::status::MarmotStatus; +use crate::types::account::{ + MarmotAccountKeyPackageList, MarmotAccountSummary, MarmotAccountSummaryList, + MarmotAccountUnreadList, MarmotSignOutOutcome, MarmotUserProfileMetadata, MarmotWipeOutcome, +}; +use crate::types::common::MarmotStringList; +use crate::types::relay::MarmotAccountRelayLists; +use crate::{MarmotClient, client_ref, ffi_guard}; + +use super::{deliver, deliver_scalar, deliver_string, deliver_unit}; + +/// Shorthand used by every command wrapper: validate + read an argument, +/// or return the argument's status code from the enclosing function. +macro_rules! try_arg { + ($expr:expr) => { + match $expr { + Ok(value) => value, + Err(status) => return status, + } + }; +} +pub(crate) use try_arg; + +/// List every account known to this device. +/// +/// # Safety +/// `client` must be a live handle; `out_list` must be a valid pointer. +/// Free the result with `marmot_account_summary_list_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_list_accounts( + client: *const MarmotClient, + out_list: *mut *mut MarmotAccountSummaryList, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + unsafe { deliver(client.marmot.list_accounts(), out_list) } + }) +} + +/// Per-account unread aggregates for the account-switcher badge. +/// +/// # Safety +/// `client` must be a live handle; `out_list` must be a valid pointer. +/// Free the result with `marmot_account_unread_list_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_account_unread_summary( + client: *const MarmotClient, + out_list: *mut *mut MarmotAccountUnreadList, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + unsafe { deliver(client.marmot.account_unread_summary(), out_list) } + }) +} + +/// Remove an account and its local state from this device. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_remove_account( + client: *const MarmotClient, + account_ref: *const c_char, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + deliver_unit(client.block_on(client.marmot.remove_account(account_ref))) + }) +} + +/// Destructive sign-out: leave groups, delete relay KeyPackages, wipe +/// local state. Every stage is reported in the outcome. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_outcome` a valid pointer. Free with `marmot_wipe_outcome_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_sign_out_and_wipe( + client: *const MarmotClient, + account_ref: *const c_char, + out_outcome: *mut *mut MarmotWipeOutcome, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { + deliver( + client.block_on(client.marmot.sign_out_and_wipe(account_ref)), + out_outcome, + ) + } + }) +} + +/// Non-destructive sign-out: deactivate the account on this device, +/// keeping all local state so it can sign back in later. When +/// `delete_key_packages` is true, relay-published KeyPackages get +/// NIP-09 deletions. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_outcome` a valid pointer. Free with `marmot_sign_out_outcome_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_sign_out( + client: *const MarmotClient, + account_ref: *const c_char, + delete_key_packages: bool, + out_outcome: *mut *mut MarmotSignOutOutcome, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { + deliver( + client.block_on(client.marmot.sign_out(account_ref, delete_key_packages)), + out_outcome, + ) + } + }) +} + +/// Create a brand-new Nostr identity, store its secret in the platform +/// keychain, and publish initial relay lists + key package. +/// +/// # Safety +/// `client` must be a live handle; relay arrays must hold `len` valid +/// strings (or be NULL with len 0); `out_summary` must be valid. Free +/// with `marmot_account_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_create_identity( + client: *const MarmotClient, + default_relays: *const *const c_char, + default_relays_len: usize, + bootstrap_relays: *const *const c_char, + bootstrap_relays_len: usize, + out_summary: *mut *mut MarmotAccountSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let default_relays = try_arg!(unsafe { str_array(default_relays, default_relays_len) }); + let bootstrap_relays = + try_arg!(unsafe { str_array(bootstrap_relays, bootstrap_relays_len) }); + unsafe { + deliver( + client.block_on( + client + .marmot + .create_identity(default_relays, bootstrap_relays), + ), + out_summary, + ) + } + }) +} + +/// Log in with an existing identity: an `nsec` (private key) for a +/// local-signing account, or an `npub` to track a public identity. +/// +/// # Safety +/// Same as `marmot_create_identity`, plus `identity` must be a valid +/// string. Free with `marmot_account_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_login( + client: *const MarmotClient, + identity: *const c_char, + default_relays: *const *const c_char, + default_relays_len: usize, + bootstrap_relays: *const *const c_char, + bootstrap_relays_len: usize, + out_summary: *mut *mut MarmotAccountSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let identity = try_arg!(unsafe { required_str(identity) }); + let default_relays = try_arg!(unsafe { str_array(default_relays, default_relays_len) }); + let bootstrap_relays = + try_arg!(unsafe { str_array(bootstrap_relays, bootstrap_relays_len) }); + unsafe { + deliver( + client.block_on( + client + .marmot + .login(identity, default_relays, bootstrap_relays), + ), + out_summary, + ) + } + }) +} + +/// Re-activate a non-destructively signed-out local account. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_summary` valid. Free with `marmot_account_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_sign_in_account( + client: *const MarmotClient, + account_ref: *const c_char, + out_summary: *mut *mut MarmotAccountSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { + deliver( + client.block_on(client.marmot.sign_in_account(account_ref)), + out_summary, + ) + } + }) +} + +/// Publish NIP-65 + inbox relay lists for the account. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; relay +/// arrays must hold `len` valid strings (or NULL with len 0). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_publish_relay_lists( + client: *const MarmotClient, + account_ref: *const c_char, + default_relays: *const *const c_char, + default_relays_len: usize, + bootstrap_relays: *const *const c_char, + bootstrap_relays_len: usize, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let default_relays = try_arg!(unsafe { str_array(default_relays, default_relays_len) }); + let bootstrap_relays = + try_arg!(unsafe { str_array(bootstrap_relays, bootstrap_relays_len) }); + deliver_unit(client.block_on(client.marmot.publish_relay_lists( + account_ref, + default_relays, + bootstrap_relays, + ))) + }) +} + +/// The account's NIP-65 relay list. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_list` valid. Free with `marmot_string_list_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_account_nip65_relays( + client: *const MarmotClient, + account_ref: *const c_char, + out_list: *mut *mut MarmotStringList, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { deliver(client.marmot.account_nip65_relays(account_ref), out_list) } + }) +} + +/// The account's inbox relay list. +/// +/// # Safety +/// Same as `marmot_account_nip65_relays`. Free with +/// `marmot_string_list_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_account_inbox_relays( + client: *const MarmotClient, + account_ref: *const c_char, + out_list: *mut *mut MarmotStringList, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { deliver(client.marmot.account_inbox_relays(account_ref), out_list) } + }) +} + +/// Local + relay-published KeyPackages for the account. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; the +/// relay array must hold `len` valid strings (or NULL with len 0); +/// `out_list` valid. Free with `marmot_account_key_package_list_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_account_key_packages( + client: *const MarmotClient, + account_ref: *const c_char, + bootstrap_relays: *const *const c_char, + bootstrap_relays_len: usize, + out_list: *mut *mut MarmotAccountKeyPackageList, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let bootstrap_relays = + try_arg!(unsafe { str_array(bootstrap_relays, bootstrap_relays_len) }); + unsafe { + deliver( + client.block_on( + client + .marmot + .account_key_packages(account_ref, bootstrap_relays), + ), + out_list, + ) + } + }) +} + +/// Publish a fresh KeyPackage. Writes the number of relays that accepted +/// the publish to `out_accepted`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_accepted` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_publish_new_key_package( + client: *const MarmotClient, + account_ref: *const c_char, + out_accepted: *mut u64, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { + deliver_scalar( + client.block_on(client.marmot.publish_new_key_package(account_ref)), + out_accepted, + ) + } + }) +} + +/// Re-publish the latest cached KeyPackage when possible, otherwise +/// publish a fresh one. Writes the accepting-relay count. +/// +/// # Safety +/// Same as `marmot_publish_new_key_package`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_republish_key_package( + client: *const MarmotClient, + account_ref: *const c_char, + out_accepted: *mut u64, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { + deliver_scalar( + client.block_on(client.marmot.republish_key_package(account_ref)), + out_accepted, + ) + } + }) +} + +/// Publish a NIP-09 deletion for a KeyPackage event. Writes the +/// accepting-relay count. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `event_id_hex` +/// valid strings; the relay array must hold `len` valid strings (or +/// NULL with len 0); `out_accepted` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_delete_account_key_package( + client: *const MarmotClient, + account_ref: *const c_char, + event_id_hex: *const c_char, + relays: *const *const c_char, + relays_len: usize, + out_accepted: *mut u64, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let event_id_hex = try_arg!(unsafe { required_str(event_id_hex) }); + let relays = try_arg!(unsafe { str_array(relays, relays_len) }); + unsafe { + deliver_scalar( + client.block_on(client.marmot.delete_account_key_package( + account_ref, + event_id_hex, + relays, + )), + out_accepted, + ) + } + }) +} + +/// Replace the account's NIP-65 relay list and publish it. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; relay +/// arrays valid per `marmot_publish_relay_lists`; `out_lists` valid. +/// Free with `marmot_account_relay_lists_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_set_account_nip65_relays( + client: *const MarmotClient, + account_ref: *const c_char, + relays: *const *const c_char, + relays_len: usize, + bootstrap_relays: *const *const c_char, + bootstrap_relays_len: usize, + out_lists: *mut *mut MarmotAccountRelayLists, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let relays = try_arg!(unsafe { str_array(relays, relays_len) }); + let bootstrap_relays = + try_arg!(unsafe { str_array(bootstrap_relays, bootstrap_relays_len) }); + unsafe { + deliver( + client.block_on(client.marmot.set_account_nip65_relays( + account_ref, + relays, + bootstrap_relays, + )), + out_lists, + ) + } + }) +} + +/// Replace the account's inbox relay list and publish it. +/// +/// # Safety +/// Same as `marmot_set_account_nip65_relays`. Free with +/// `marmot_account_relay_lists_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_set_account_inbox_relays( + client: *const MarmotClient, + account_ref: *const c_char, + relays: *const *const c_char, + relays_len: usize, + bootstrap_relays: *const *const c_char, + bootstrap_relays_len: usize, + out_lists: *mut *mut MarmotAccountRelayLists, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let relays = try_arg!(unsafe { str_array(relays, relays_len) }); + let bootstrap_relays = + try_arg!(unsafe { str_array(bootstrap_relays, bootstrap_relays_len) }); + unsafe { + deliver( + client.block_on(client.marmot.set_account_inbox_relays( + account_ref, + relays, + bootstrap_relays, + )), + out_lists, + ) + } + }) +} + +/// Export the account's raw private key as `nsec1…` bech32. +/// +/// SENSITIVE: the reveal is audit-logged and permanently marks the +/// account's key-security byte as handled-insecurely. Free the returned +/// string with `marmot_string_free` as soon as it has been displayed; +/// the library cannot zero caller-held copies. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_nsec` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_reveal_nsec( + client: *const MarmotClient, + account_ref: *const c_char, + out_nsec: *mut *mut c_char, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { deliver_string(client.marmot.reveal_nsec(account_ref), out_nsec) } + }) +} + +/// Export the account's private key NIP-49-encrypted under `passphrase`. +/// Free the result with `marmot_string_free`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `passphrase` valid +/// strings; `out_encrypted` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_export_encrypted_secret_key( + client: *const MarmotClient, + account_ref: *const c_char, + passphrase: *const c_char, + out_encrypted: *mut *mut c_char, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let passphrase = try_arg!(unsafe { required_str(passphrase) }); + unsafe { + deliver_string( + client + .marmot + .export_encrypted_secret_key(account_ref, passphrase), + out_encrypted, + ) + } + }) +} + +/// Publish the account's kind:0 profile metadata. The returned profile is +/// what was actually published (server-applied defaults reflected). +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `profile` a valid borrowed struct (never freed by the library); relay +/// arrays valid per `marmot_publish_relay_lists`; `out_profile` valid. +/// Free the result with `marmot_user_profile_metadata_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_publish_user_profile( + client: *const MarmotClient, + account_ref: *const c_char, + profile: *const MarmotUserProfileMetadata, + default_relays: *const *const c_char, + default_relays_len: usize, + bootstrap_relays: *const *const c_char, + bootstrap_relays_len: usize, + out_profile: *mut *mut MarmotUserProfileMetadata, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + if profile.is_null() { + crate::status::set_last_error("profile argument was NULL"); + return MarmotStatus::NullPointer; + } + let profile = try_arg!(unsafe { (*profile).to_ffi() }); + let default_relays = try_arg!(unsafe { str_array(default_relays, default_relays_len) }); + let bootstrap_relays = + try_arg!(unsafe { str_array(bootstrap_relays, bootstrap_relays_len) }); + unsafe { + deliver( + client.block_on(client.marmot.publish_user_profile( + account_ref, + profile, + default_relays, + bootstrap_relays, + )), + out_profile, + ) + } + }) +} diff --git a/crates/marmot-c/src/commands/agent_stream.rs b/crates/marmot-c/src/commands/agent_stream.rs new file mode 100644 index 00000000..8b77b061 --- /dev/null +++ b/crates/marmot-c/src/commands/agent_stream.rs @@ -0,0 +1,59 @@ +//! Live agent text stream anchor command. +//! +//! Only the start/anchor command lives here; the watch side +//! (`marmot_watch_agent_text_stream` and its subscription handle) lives in +//! the subscriptions layer (`crate::subscriptions`) with the other +//! long-lived stream handles. + +use std::ffi::c_char; + +use crate::memory::{optional_str, required_str, str_array}; +use crate::status::MarmotStatus; +use crate::types::agent_stream::MarmotAgentStreamStart; +use crate::{MarmotClient, client_ref, ffi_guard}; + +use super::account::try_arg; +use super::deliver; + +/// Anchor a live agent text stream start in the encrypted group history. +/// `quic_candidates` (`quic_candidates_len` entries) are the broker +/// candidate URLs the host will publish to, such as +/// `quic://quic-broker.ipf.dev:4450`; pass `stream_id_hex = NULL` to let +/// the library generate a 32-byte stream id (the one actually used is in +/// the result). +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `stream_id_hex` NULL or a valid string; `quic_candidates` +/// must hold `quic_candidates_len` valid strings (or be NULL with len 0); +/// `out_start` must be a valid pointer. Free the result with +/// `marmot_agent_stream_start_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_start_agent_text_stream( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + stream_id_hex: *const c_char, + quic_candidates: *const *const c_char, + quic_candidates_len: usize, + out_start: *mut *mut MarmotAgentStreamStart, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let stream_id_hex = try_arg!(unsafe { optional_str(stream_id_hex) }); + let quic_candidates = try_arg!(unsafe { str_array(quic_candidates, quic_candidates_len) }); + unsafe { + deliver( + client.block_on(client.marmot.start_agent_text_stream( + account_ref, + group_id_hex, + stream_id_hex, + quic_candidates, + )), + out_start, + ) + } + }) +} diff --git a/crates/marmot-c/src/commands/audit.rs b/crates/marmot-c/src/commands/audit.rs new file mode 100644 index 00000000..aa2b7747 --- /dev/null +++ b/crates/marmot-c/src/commands/audit.rs @@ -0,0 +1,195 @@ +//! Forensic audit-log settings, listing, upload, and tracker commands. + +use std::ffi::c_char; + +use crate::memory::required_str; +use crate::status::MarmotStatus; +use crate::types::audit::{ + MarmotAuditLogDeleteResult, MarmotAuditLogFileList, MarmotAuditLogSettings, + MarmotAuditLogTrackerConfig, MarmotAuditLogTrackerUpdateResult, MarmotAuditLogUploadResult, +}; +use crate::{MarmotClient, client_ref, ffi_guard}; + +use super::account::try_arg; +use super::deliver; + +/// Local forensic audit-log recording settings. Recording is opt-in and only +/// applies to account sessions opened after the setting is enabled. +/// +/// # Safety +/// `client` must be a live handle; `out_settings` must be a valid pointer. +/// Free the result with `marmot_audit_log_settings_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_audit_log_settings( + client: *const MarmotClient, + out_settings: *mut *mut MarmotAuditLogSettings, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + unsafe { deliver(client.marmot.audit_log_settings(), out_settings) } + }) +} + +/// Persist local forensic audit-log recording settings and return the stored +/// value. +/// +/// Blocking: toggling the switch is applied to any already-running account +/// sessions in place — enabling starts a live recorder, disabling stops it +/// and closes the file, no session reopen required. +/// +/// # Safety +/// `client` must be a live handle; `settings` a valid borrowed struct (never +/// freed by the library); `out_settings` a valid pointer. Free the result +/// with `marmot_audit_log_settings_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_set_audit_log_settings( + client: *const MarmotClient, + settings: *const MarmotAuditLogSettings, + out_settings: *mut *mut MarmotAuditLogSettings, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + if settings.is_null() { + crate::status::set_last_error("settings argument was NULL"); + return MarmotStatus::NullPointer; + } + let settings = try_arg!(unsafe { (*settings).to_ffi() }); + unsafe { + deliver( + client.block_on(client.marmot.set_audit_log_settings(settings)), + out_settings, + ) + } + }) +} + +/// Supply non-persisted audit tracker upload metadata: optional Goggles +/// upload URL override, bearer token from the host app, and optional human +/// source labels. +/// +/// The returned config confirms what was stored but never echoes the bearer +/// token back across FFI: secrets flow in, not out. +/// +/// # Safety +/// `client` must be a live handle; `config` a valid borrowed struct (never +/// freed by the library) whose non-NULL string fields are valid +/// NUL-terminated strings; `out_config` a valid pointer. Free the result +/// with `marmot_audit_log_tracker_config_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_set_audit_log_tracker_config( + client: *const MarmotClient, + config: *const MarmotAuditLogTrackerConfig, + out_config: *mut *mut MarmotAuditLogTrackerConfig, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + if config.is_null() { + crate::status::set_last_error("config argument was NULL"); + return MarmotStatus::NullPointer; + } + let config = try_arg!(unsafe { (*config).to_ffi() }); + unsafe { + deliver( + client.marmot.set_audit_log_tracker_config(config), + out_config, + ) + } + }) +} + +/// Local JSONL audit logs available for explicit forensic upload. +/// +/// # Safety +/// `client` must be a live handle; `out_list` must be a valid pointer. +/// Free the result with `marmot_audit_log_file_list_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_audit_log_files( + client: *const MarmotClient, + out_list: *mut *mut MarmotAuditLogFileList, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + unsafe { deliver(client.marmot.audit_log_files(), out_list) } + }) +} + +/// POST one selected JSONL audit log to a forensic analyzer endpoint. +/// +/// # Safety +/// `client` must be a live handle; `path` and `endpoint` valid strings; +/// `out_result` a valid pointer. Free the result with +/// `marmot_audit_log_upload_result_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_post_audit_log_file( + client: *const MarmotClient, + path: *const c_char, + endpoint: *const c_char, + out_result: *mut *mut MarmotAuditLogUploadResult, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let path = try_arg!(unsafe { required_str(path) }); + let endpoint = try_arg!(unsafe { required_str(endpoint) }); + unsafe { + deliver( + client.block_on(client.marmot.post_audit_log_file(path, endpoint)), + out_result, + ) + } + }) +} + +/// Delete one local JSONL audit log file (e.g. behind a "clear audit log" +/// button). +/// +/// When forensic audit logging is on and a session for the file's account is +/// live, the recorder rotates to a fresh file and keeps recording, so the +/// result's `still_recording` is `true`. When audit logging is off, or no +/// session is recording this file, it is simply removed and +/// `still_recording` is `false`. Pass a `path` from +/// `marmot_audit_log_files`. +/// +/// # Safety +/// `client` must be a live handle; `path` a valid string; `out_result` a +/// valid pointer. Free the result with +/// `marmot_audit_log_delete_result_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_delete_audit_log_file( + client: *const MarmotClient, + path: *const c_char, + out_result: *mut *mut MarmotAuditLogDeleteResult, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let path = try_arg!(unsafe { required_str(path) }); + unsafe { + deliver( + client.block_on(client.marmot.delete_audit_log_file(path)), + out_result, + ) + } + }) +} + +/// POST all local audit logs to the configured tracker when audit logging is +/// enabled. This is safe for host apps to call unconditionally; disabled or +/// unconfigured states return a structured skip result. +/// +/// # Safety +/// `client` must be a live handle; `out_result` a valid pointer. Free the +/// result with `marmot_audit_log_tracker_update_result_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_post_audit_log_tracker_update( + client: *const MarmotClient, + out_result: *mut *mut MarmotAuditLogTrackerUpdateResult, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + unsafe { + deliver( + client.block_on(client.marmot.post_audit_log_tracker_update()), + out_result, + ) + } + }) +} diff --git a/crates/marmot-c/src/commands/chat_list.rs b/crates/marmot-c/src/commands/chat_list.rs new file mode 100644 index 00000000..9a6225ff --- /dev/null +++ b/crates/marmot-c/src/commands/chat_list.rs @@ -0,0 +1,100 @@ +//! Durable chat-list and chat read-state commands. + +use std::ffi::c_char; + +use crate::memory::required_str; +use crate::status::MarmotStatus; +use crate::types::chat_list::{MarmotChatListRow, MarmotChatListRowList}; +use crate::{MarmotClient, client_ref, ffi_guard}; + +use super::account::try_arg; +use super::{deliver, deliver_opt}; + +/// Durable chat-list rows for fast app launch. Rows include the group +/// title/avatar, last kind-9 preview, unread count, and read anchors. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_list` a valid pointer. Free the result with +/// `marmot_chat_list_row_list_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chat_list( + client: *const MarmotClient, + account_ref: *const c_char, + include_archived: bool, + out_list: *mut *mut MarmotChatListRowList, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { + deliver( + client.marmot.chat_list(account_ref, include_archived), + out_list, + ) + } + }) +} + +/// Establish the unread baseline the first time a user opens a group. +/// Existing kind-9 history remains read; later remote kind-9 messages count +/// until marked visible via `marmot_mark_timeline_message_read`. Writes NULL +/// with `MARMOT_STATUS_OK` when the group has no chat-list row. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_row` a valid pointer. Free the result with +/// `marmot_chat_list_row_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_initialize_chat_read_state( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_row: *mut *mut MarmotChatListRow, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver_opt( + client + .marmot + .initialize_chat_read_state(account_ref, group_id_hex), + out_row, + ) + } + }) +} + +/// Mark a kind-9 timeline message visible/read. Own kind-9 messages can +/// advance the marker too, which clears any earlier unread messages. Writes +/// NULL with `MARMOT_STATUS_OK` when the group has no chat-list row. +/// +/// # Safety +/// `client` must be a live handle; `account_ref`, `group_id_hex`, and +/// `message_id_hex` valid strings; `out_row` a valid pointer. Free the +/// result with `marmot_chat_list_row_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_mark_timeline_message_read( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + message_id_hex: *const c_char, + out_row: *mut *mut MarmotChatListRow, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let message_id_hex = try_arg!(unsafe { required_str(message_id_hex) }); + unsafe { + deliver_opt( + client + .marmot + .mark_timeline_message_read(account_ref, group_id_hex, message_id_hex), + out_row, + ) + } + }) +} diff --git a/crates/marmot-c/src/commands/directory.rs b/crates/marmot-c/src/commands/directory.rs new file mode 100644 index 00000000..e0a375ce --- /dev/null +++ b/crates/marmot-c/src/commands/directory.rs @@ -0,0 +1,161 @@ +//! Directory, identity-resolution, profile, and Markdown-preview commands. + +use std::ffi::c_char; + +use crate::memory::{free_c_string, owned_opt_c_string, required_str, str_array}; +use crate::status::MarmotStatus; +use crate::types::account::MarmotUserProfileMetadata; +use crate::types::markdown::MarmotMarkdownDocument; +use crate::{MarmotClient, client_ref, ffi_guard, write_out}; + +use super::account::try_arg; +use super::{deliver, deliver_opt, deliver_unit}; + +/// Write an infallible `Option` through the out-pointer: `Some` +/// becomes an owned C string, `None` writes NULL. Either way the status +/// is `MARMOT_STATUS_OK` once the arguments checked out. +unsafe fn deliver_opt_string(value: Option, out: *mut *mut c_char) -> MarmotStatus { + let ptr = owned_opt_c_string(value); + match unsafe { write_out(out, ptr) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_c_string(ptr) }; + status + } + } +} + +/// Best-effort cached display name for an account id. Returns the Nostr +/// kind:0 display_name/name when the runtime has projected one, or the +/// local account label if the id refers to one of our own accounts. +/// Writes NULL (with `MARMOT_STATUS_OK`) when nothing is known yet — +/// call `marmot_refresh_directory` to fetch. +/// +/// # Safety +/// `client` must be a live handle; `account_id_hex` a valid string; +/// `out_name` a valid pointer. Free a non-NULL result with +/// `marmot_string_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_display_name( + client: *const MarmotClient, + account_id_hex: *const c_char, + out_name: *mut *mut c_char, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_id_hex = try_arg!(unsafe { required_str(account_id_hex) }); + unsafe { deliver_opt_string(client.marmot.display_name(account_id_hex), out_name) } + }) +} + +/// Convert a hex account id (Nostr public key) into its `npub…` bech32 +/// form for display. Writes NULL (with `MARMOT_STATUS_OK`) if the hex +/// isn't a valid public key. +/// +/// # Safety +/// `client` must be a live handle; `account_id_hex` a valid string; +/// `out_npub` a valid pointer. Free a non-NULL result with +/// `marmot_string_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_npub( + client: *const MarmotClient, + account_id_hex: *const c_char, + out_npub: *mut *mut c_char, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_id_hex = try_arg!(unsafe { required_str(account_id_hex) }); + unsafe { deliver_opt_string(client.marmot.npub(account_id_hex), out_npub) } + }) +} + +/// Normalize a public-key reference (npub or hex) to canonical hex. +/// Writes NULL (with `MARMOT_STATUS_OK`) if it isn't a valid public key. +/// Used to resolve a scanned or deep-linked npub back to the account id +/// the rest of the API expects. +/// +/// # Safety +/// `client` must be a live handle; `reference` a valid string; +/// `out_hex` a valid pointer. Free a non-NULL result with +/// `marmot_string_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_account_id_hex( + client: *const MarmotClient, + reference: *const c_char, + out_hex: *mut *mut c_char, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let reference = try_arg!(unsafe { required_str(reference) }); + unsafe { deliver_opt_string(client.marmot.account_id_hex(reference), out_hex) } + }) +} + +/// Parse plaintext message content into the same Markdown AST returned on +/// message and timeline records. Useful for draft previews and host-side +/// fallback rendering. +/// +/// # Safety +/// `client` must be a live handle; `text` a valid string; +/// `out_document` a valid pointer. Free the result with +/// `marmot_markdown_document_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_parse_markdown( + client: *const MarmotClient, + text: *const c_char, + out_document: *mut *mut MarmotMarkdownDocument, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let text = try_arg!(unsafe { required_str(text) }); + unsafe { deliver(Ok(client.marmot.parse_markdown(text)), out_document) } + }) +} + +/// Full cached Nostr kind:0 profile for an account id (name, display +/// name, about, picture, nip05, lud16), if the runtime has one +/// projected. The local account's own profile is cached immediately +/// after `marmot_publish_user_profile`; other accounts' profiles +/// populate via `marmot_refresh_directory`. Writes NULL (with +/// `MARMOT_STATUS_OK`) when nothing is cached yet. +/// +/// # Safety +/// `client` must be a live handle; `account_id_hex` a valid string; +/// `out_profile` a valid pointer. Free a non-NULL result with +/// `marmot_user_profile_metadata_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_user_profile( + client: *const MarmotClient, + account_id_hex: *const c_char, + out_profile: *mut *mut MarmotUserProfileMetadata, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_id_hex = try_arg!(unsafe { required_str(account_id_hex) }); + unsafe { deliver_opt(client.marmot.user_profile(account_id_hex), out_profile) } + }) +} + +/// Fetch and cache an account's own Nostr kind:0 profile from `relays`. +/// After this resolves, `marmot_user_profile` / `marmot_display_name` +/// return the freshly-fetched metadata (name, picture, etc.) for that +/// account. +/// +/// # Safety +/// `client` must be a live handle; `account_id_hex` a valid string; the +/// relay array must hold `relays_len` valid strings (or be NULL with +/// len 0). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_refresh_profile( + client: *const MarmotClient, + account_id_hex: *const c_char, + relays: *const *const c_char, + relays_len: usize, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_id_hex = try_arg!(unsafe { required_str(account_id_hex) }); + let relays = try_arg!(unsafe { str_array(relays, relays_len) }); + deliver_unit(client.block_on(client.marmot.refresh_profile(account_id_hex, relays))) + }) +} diff --git a/crates/marmot-c/src/commands/group.rs b/crates/marmot-c/src/commands/group.rs new file mode 100644 index 00000000..f3fcf5d4 --- /dev/null +++ b/crates/marmot-c/src/commands/group.rs @@ -0,0 +1,902 @@ +//! Group lifecycle, membership, admin, profile, and MLS-state commands. + +use std::ffi::c_char; + +use crate::memory::{optional_str, required_str, str_array}; +use crate::status::MarmotStatus; +use crate::types::account::MarmotSendSummary; +use crate::types::group::{ + MarmotAppBlobEndpoint, MarmotAppGroupMemberRecordList, MarmotAppGroupMlsState, + MarmotAppGroupRecord, MarmotAppQuarantinedGroupList, MarmotGroupDetails, + MarmotGroupInviteDeclineResult, MarmotGroupManagementState, MarmotGroupMutationResult, + MarmotMemberRef, +}; +use crate::{MarmotClient, client_ref, ffi_guard}; + +use super::account::try_arg; +use super::{deliver, deliver_bytes, deliver_scalar, deliver_string}; + +/// Create a new MLS group with `name` and the given members. Members are +/// referenced by `npub` or hex account id. Writes the new group id as a hex +/// string. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `name` valid strings; +/// `member_refs` must hold `member_refs_len` valid strings (or be NULL with +/// len 0); `description` NULL or a valid string; `out_group_id_hex` valid. +/// Free the result with `marmot_string_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_create_group( + client: *const MarmotClient, + account_ref: *const c_char, + name: *const c_char, + member_refs: *const *const c_char, + member_refs_len: usize, + description: *const c_char, + out_group_id_hex: *mut *mut c_char, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let name = try_arg!(unsafe { required_str(name) }); + let member_refs = try_arg!(unsafe { str_array(member_refs, member_refs_len) }); + let description = try_arg!(unsafe { optional_str(description) }); + unsafe { + deliver_string( + client.block_on(client.marmot.create_group( + account_ref, + name, + member_refs, + description, + )), + out_group_id_hex, + ) + } + }) +} + +/// Normalize a member reference for group-management UI. Accepts hex, +/// `npub`, `nostr:npub...`, and `marmot://profile/...` references. +/// +/// # Safety +/// `client` must be a live handle; `member_ref` a valid string; +/// `out_member_ref` valid. Free with `marmot_member_ref_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_normalize_member_ref( + client: *const MarmotClient, + member_ref: *const c_char, + out_member_ref: *mut *mut MarmotMemberRef, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let member_ref = try_arg!(unsafe { required_str(member_ref) }); + unsafe { + deliver( + client.marmot.normalize_member_ref(member_ref), + out_member_ref, + ) + } + }) +} + +/// Membership roster for `group_id_hex`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_list` valid. Free with +/// `marmot_app_group_member_record_list_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_members( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_list: *mut *mut MarmotAppGroupMemberRecordList, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on(client.marmot.group_members(account_ref, group_id_hex)), + out_list, + ) + } + }) +} + +/// Group plus enriched member rows for detail screens. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_details` valid. Free with `marmot_group_details_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_details( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_details: *mut *mut MarmotGroupDetails, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on(client.marmot.group_details(account_ref, group_id_hex)), + out_details, + ) + } + }) +} + +/// Current caller permissions plus per-member action availability. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_state` valid. Free with +/// `marmot_group_management_state_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_management_state( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_state: *mut *mut MarmotGroupManagementState, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on( + client + .marmot + .group_management_state(account_ref, group_id_hex), + ), + out_state, + ) + } + }) +} + +/// Invite members (by `npub` or hex account id) into the group. Requires +/// the caller to be an admin. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `member_refs` must hold `member_refs_len` valid strings (or be +/// NULL with len 0); `out_summary` valid. Free with +/// `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_invite_members( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + member_refs: *const *const c_char, + member_refs_len: usize, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let member_refs = try_arg!(unsafe { str_array(member_refs, member_refs_len) }); + unsafe { + deliver( + client.block_on(client.marmot.invite_members( + account_ref, + group_id_hex, + member_refs, + )), + out_summary, + ) + } + }) +} + +/// Remove members from the group. Requires the caller to be an admin; +/// preflight rejects self-removal and removing the last admin. +/// +/// # Safety +/// Same as `marmot_invite_members`. Free with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_remove_members( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + member_refs: *const *const c_char, + member_refs_len: usize, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let member_refs = try_arg!(unsafe { str_array(member_refs, member_refs_len) }); + unsafe { + deliver( + client.block_on(client.marmot.remove_members( + account_ref, + group_id_hex, + member_refs, + )), + out_summary, + ) + } + }) +} + +/// Leave the group as the active account. Admins must self-demote first. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_summary` valid. Free with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_leave_group( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on(client.marmot.leave_group(account_ref, group_id_hex)), + out_summary, + ) + } + }) +} + +/// Delete this group's local app data without performing an MLS leave. The +/// caller should cancel any active UI subscriptions for the group before +/// invoking the wipe. The runtime removes the active transport route, then +/// transactionally drops the chat-list/account projection, plaintext app +/// events, timeline rows, agent-stream projection rows, push-token rows, +/// and cached encrypted-media epoch secrets. MLS/OpenMLS group state is +/// left intact; a future fresh group delivery can recreate a local chat +/// row. Writes true if any local rows or a live route were removed. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_removed` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_delete_group_local( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_removed: *mut bool, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver_scalar( + client.block_on(client.marmot.delete_group_local(account_ref, group_id_hex)), + out_removed, + ) + } + }) +} + +/// Set the per-group disappearing-message retention. +/// `disappearing_message_secs` of `0` disables expiry; any positive value +/// is the retention window in seconds. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_summary` valid. Free with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_update_message_retention( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + disappearing_message_secs: u64, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on(client.marmot.update_message_retention( + account_ref, + group_id_hex, + disappearing_message_secs, + )), + out_summary, + ) + } + }) +} + +/// Accept a pending group invite; writes the now-confirmed group record. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_group` valid. Free with `marmot_app_group_record_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_accept_group_invite( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_group: *mut *mut MarmotAppGroupRecord, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on(client.marmot.accept_group_invite(account_ref, group_id_hex)), + out_group, + ) + } + }) +} + +/// Decline a pending group invite; writes the updated group record plus +/// the publish summary of the decline. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_result` valid. Free with +/// `marmot_group_invite_decline_result_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_decline_group_invite( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_result: *mut *mut MarmotGroupInviteDeclineResult, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on( + client + .marmot + .decline_group_invite(account_ref, group_id_hex), + ), + out_result, + ) + } + }) +} + +/// Update the group's name and/or description. NULL leaves a field +/// unchanged. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `name` and `description` NULL or valid strings; `out_summary` +/// valid. Free with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_update_group_profile( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + name: *const c_char, + description: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let name = try_arg!(unsafe { optional_str(name) }); + let description = try_arg!(unsafe { optional_str(description) }); + unsafe { + deliver( + client.block_on(client.marmot.update_group_profile( + account_ref, + group_id_hex, + name, + description, + )), + out_summary, + ) + } + }) +} + +/// Set (or clear, with `url` NULL) the group's URL-based avatar +/// (`marmot.group.avatar-url.v1`). The URL is validated (https-only, no +/// localhost/private hosts) and normalized before it is committed. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `url`, `dim`, and `thumbhash` NULL or valid strings; +/// `out_summary` valid. Free with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_update_group_avatar_url( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + url: *const c_char, + dim: *const c_char, + thumbhash: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let url = try_arg!(unsafe { optional_str(url) }); + let dim = try_arg!(unsafe { optional_str(dim) }); + let thumbhash = try_arg!(unsafe { optional_str(thumbhash) }); + unsafe { + deliver( + client.block_on(client.marmot.update_group_avatar_url( + account_ref, + group_id_hex, + url, + dim, + thumbhash, + )), + out_summary, + ) + } + }) +} + +/// Replace the group's encrypted-media default blob endpoints as a full +/// `marmot.group.encrypted-media.v1` component update. Requires the caller +/// to be an admin. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `endpoints` must point to `endpoints_len` valid caller-owned +/// structs (or be NULL with len 0) — the library never frees or retains +/// them; `out_summary` valid. Free with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_replace_encrypted_media_blob_endpoints( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + endpoints: *const MarmotAppBlobEndpoint, + endpoints_len: usize, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let endpoints = if endpoints_len == 0 { + Vec::new() + } else { + if endpoints.is_null() { + crate::status::set_last_error("endpoints argument was NULL with non-zero length"); + return MarmotStatus::NullPointer; + } + let borrowed = unsafe { std::slice::from_raw_parts(endpoints, endpoints_len) }; + let mut converted = Vec::with_capacity(endpoints_len); + for endpoint in borrowed { + converted.push(try_arg!(unsafe { endpoint.to_ffi() })); + } + converted + }; + unsafe { + deliver( + client.block_on(client.marmot.replace_encrypted_media_blob_endpoints( + account_ref, + group_id_hex, + endpoints, + )), + out_summary, + ) + } + }) +} + +/// Fetch, verify, and decrypt the group's Blossom-hosted encrypted image, +/// writing the plaintext bytes to `out_data`/`out_len`. The image hash comes +/// from `MarmotAppGroupRecord.image_hash_hex`. Needs a relay/Blossom, so it +/// fails offline. Free the buffer with `marmot_bytes_free`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_data` and `out_len` valid pointers. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_download_group_blossom_image( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_data: *mut *mut u8, + out_len: *mut usize, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver_bytes( + client.block_on( + client + .marmot + .download_group_blossom_image(account_ref, group_id_hex), + ), + out_data, + out_len, + ) + } + }) +} + +/// Grant admin rights to `member_ref` (npub or hex). Requires the caller +/// to be an admin; publishes a group state update. +/// +/// # Safety +/// `client` must be a live handle; `account_ref`, `group_id_hex`, and +/// `member_ref` valid strings; `out_summary` valid. Free with +/// `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_promote_admin( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + member_ref: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let member_ref = try_arg!(unsafe { required_str(member_ref) }); + unsafe { + deliver( + client.block_on( + client + .marmot + .promote_admin(account_ref, group_id_hex, member_ref), + ), + out_summary, + ) + } + }) +} + +/// Revoke `member_ref`'s admin rights. +/// +/// # Safety +/// Same as `marmot_promote_admin`. Free with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_demote_admin( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + member_ref: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let member_ref = try_arg!(unsafe { required_str(member_ref) }); + unsafe { + deliver( + client.block_on( + client + .marmot + .demote_admin(account_ref, group_id_hex, member_ref), + ), + out_summary, + ) + } + }) +} + +/// Step down as an admin of `group_id_hex` (demote the active account). +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_summary` valid. Free with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_self_demote_admin( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on(client.marmot.self_demote_admin(account_ref, group_id_hex)), + out_summary, + ) + } + }) +} + +/// `marmot_invite_members` plus refreshed group details and management +/// state in one round trip. +/// +/// # Safety +/// Same as `marmot_invite_members`; `out_result` valid. Free with +/// `marmot_group_mutation_result_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_invite_members_detailed( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + member_refs: *const *const c_char, + member_refs_len: usize, + out_result: *mut *mut MarmotGroupMutationResult, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let member_refs = try_arg!(unsafe { str_array(member_refs, member_refs_len) }); + unsafe { + deliver( + client.block_on(client.marmot.invite_members_detailed( + account_ref, + group_id_hex, + member_refs, + )), + out_result, + ) + } + }) +} + +/// `marmot_remove_members` plus refreshed group details and management +/// state in one round trip. +/// +/// # Safety +/// Same as `marmot_remove_members`; `out_result` valid. Free with +/// `marmot_group_mutation_result_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_remove_members_detailed( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + member_refs: *const *const c_char, + member_refs_len: usize, + out_result: *mut *mut MarmotGroupMutationResult, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let member_refs = try_arg!(unsafe { str_array(member_refs, member_refs_len) }); + unsafe { + deliver( + client.block_on(client.marmot.remove_members_detailed( + account_ref, + group_id_hex, + member_refs, + )), + out_result, + ) + } + }) +} + +/// `marmot_promote_admin` plus refreshed group details and management +/// state in one round trip. +/// +/// # Safety +/// Same as `marmot_promote_admin`; `out_result` valid. Free with +/// `marmot_group_mutation_result_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_promote_admin_detailed( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + member_ref: *const c_char, + out_result: *mut *mut MarmotGroupMutationResult, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let member_ref = try_arg!(unsafe { required_str(member_ref) }); + unsafe { + deliver( + client.block_on(client.marmot.promote_admin_detailed( + account_ref, + group_id_hex, + member_ref, + )), + out_result, + ) + } + }) +} + +/// `marmot_demote_admin` plus refreshed group details and management state +/// in one round trip. +/// +/// # Safety +/// Same as `marmot_demote_admin`; `out_result` valid. Free with +/// `marmot_group_mutation_result_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_demote_admin_detailed( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + member_ref: *const c_char, + out_result: *mut *mut MarmotGroupMutationResult, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let member_ref = try_arg!(unsafe { required_str(member_ref) }); + unsafe { + deliver( + client.block_on(client.marmot.demote_admin_detailed( + account_ref, + group_id_hex, + member_ref, + )), + out_result, + ) + } + }) +} + +/// `marmot_self_demote_admin` plus refreshed group details and management +/// state in one round trip. +/// +/// # Safety +/// Same as `marmot_self_demote_admin`; `out_result` valid. Free with +/// `marmot_group_mutation_result_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_self_demote_admin_detailed( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_result: *mut *mut MarmotGroupMutationResult, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on( + client + .marmot + .self_demote_admin_detailed(account_ref, group_id_hex), + ), + out_result, + ) + } + }) +} + +/// Current MLS state (epoch, member count, required components) for the +/// conversation developer/debug view. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_state` valid. Free with `marmot_app_group_mls_state_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_mls_state( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_state: *mut *mut MarmotAppGroupMlsState, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on(client.marmot.group_mls_state(account_ref, group_id_hex)), + out_state, + ) + } + }) +} + +/// Stored groups that failed session-open hydration and were skipped so +/// the rest of the account could open. These groups are not in the live +/// roster and otherwise vanish from the account with no explanation; +/// surface them in a per-group recovery flow distinct from healthy and +/// archived groups, using `reason` to pick the per-reason guidance, and +/// offer `marmot_retry_hydrate_quarantined_group`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_list` valid. Free with `marmot_app_quarantined_group_list_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_quarantined_groups( + client: *const MarmotClient, + account_ref: *const c_char, + out_list: *mut *mut MarmotAppQuarantinedGroupList, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { + deliver( + client.block_on(client.marmot.quarantined_groups(account_ref)), + out_list, + ) + } + }) +} + +/// Re-attempt hydration of a single quarantined group. +/// +/// Non-destructive, user-initiated recovery for a transiently-bad group +/// (e.g. a partial DB restore that has since completed). Writes `true` if +/// the group recovered and is now a live chat (it leaves the quarantine +/// list and reappears in the chat list), `false` if it is still unhealthy +/// and stays quarantined. Errors with the unknown-group status if the id +/// is not currently quarantined. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_recovered` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_retry_hydrate_quarantined_group( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_recovered: *mut bool, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver_scalar( + client.block_on( + client + .marmot + .retry_hydrate_quarantined_group(account_ref, group_id_hex), + ), + out_recovered, + ) + } + }) +} + +/// Flag a group archived (or restore it). Local-only projection state — +/// it does not change membership or publish anything. The chats list +/// filters archived groups unless `include_archived` is set. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_group` valid. Free with `marmot_app_group_record_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_set_group_archived( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + archived: bool, + out_group: *mut *mut MarmotAppGroupRecord, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on(client.marmot.set_group_archived( + account_ref, + group_id_hex, + archived, + )), + out_group, + ) + } + }) +} diff --git a/crates/marmot-c/src/commands/media.rs b/crates/marmot-c/src/commands/media.rs new file mode 100644 index 00000000..1708c842 --- /dev/null +++ b/crates/marmot-c/src/commands/media.rs @@ -0,0 +1,219 @@ +//! Encrypted-media upload/download/send/list commands. + +use std::ffi::c_char; + +use crate::memory::{optional_str, required_str}; +use crate::status::MarmotStatus; +use crate::types::account::MarmotSendSummary; +use crate::types::media::{ + MarmotMediaAttachmentReference, MarmotMediaDownloadResult, MarmotMediaRecordList, + MarmotMediaUploadRequest, MarmotMediaUploadResult, +}; +use crate::{MarmotClient, client_ref, ffi_guard}; + +use super::account::try_arg; +use super::deliver; + +/// Send already-uploaded encrypted media attachments as a kind-9 chat +/// carrying ordered NIP-92 `imeta` tags. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `attachments` must point to `attachments_len` valid borrowed +/// attachment references (or be NULL with len 0); `caption` NULL or a valid +/// string; `out_summary` valid. Input structs are never freed by the +/// library. Free the result with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_send_media_attachments( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + attachments: *const MarmotMediaAttachmentReference, + attachments_len: usize, + caption: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let attachments = if attachments.is_null() { + if attachments_len != 0 { + crate::status::set_last_error( + "media attachment array was NULL with nonzero length", + ); + return MarmotStatus::NullPointer; + } + Vec::new() + } else { + let borrowed = unsafe { std::slice::from_raw_parts(attachments, attachments_len) }; + let mut converted = Vec::with_capacity(attachments_len); + for attachment in borrowed { + converted.push(try_arg!(unsafe { attachment.to_ffi() })); + } + converted + }; + let caption = try_arg!(unsafe { optional_str(caption) }); + unsafe { + deliver( + client.block_on(client.marmot.send_media_attachments( + account_ref, + group_id_hex, + attachments, + caption, + )), + out_summary, + ) + } + }) +} + +/// Backward-compatible single-attachment send helper. Prefer +/// `marmot_send_media_attachments` for new callers so one chat can carry +/// ordered mixed media attachments. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `reference` a valid borrowed attachment reference (never freed +/// by the library); `caption` NULL or a valid string; `out_summary` valid. +/// Free the result with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_send_media_reference( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + reference: *const MarmotMediaAttachmentReference, + caption: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + if reference.is_null() { + crate::status::set_last_error("reference argument was NULL"); + return MarmotStatus::NullPointer; + } + let reference = try_arg!(unsafe { (*reference).to_ffi() }); + let caption = try_arg!(unsafe { optional_str(caption) }); + unsafe { + deliver( + client.block_on(client.marmot.send_media_reference( + account_ref, + group_id_hex, + reference, + caption, + )), + out_summary, + ) + } + }) +} + +/// Encrypt plaintext attachments, upload the ciphertext blobs, and +/// optionally send the resulting media references into the group. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `request` a valid borrowed upload request (never freed by the +/// library); `out_result` valid. Free the result with +/// `marmot_media_upload_result_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_upload_media( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + request: *const MarmotMediaUploadRequest, + out_result: *mut *mut MarmotMediaUploadResult, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + if request.is_null() { + crate::status::set_last_error("request argument was NULL"); + return MarmotStatus::NullPointer; + } + let request = try_arg!(unsafe { (*request).to_ffi() }); + unsafe { + deliver( + client.block_on( + client + .marmot + .upload_media(account_ref, group_id_hex, request), + ), + out_result, + ) + } + }) +} + +/// Fetch an encrypted media blob and decrypt it using the group's +/// encrypted media component secret. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `reference` a valid borrowed attachment reference (never freed +/// by the library); `out_result` valid. Free the result with +/// `marmot_media_download_result_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_download_media( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + reference: *const MarmotMediaAttachmentReference, + out_result: *mut *mut MarmotMediaDownloadResult, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + if reference.is_null() { + crate::status::set_last_error("reference argument was NULL"); + return MarmotStatus::NullPointer; + } + let reference = try_arg!(unsafe { (*reference).to_ffi() }); + unsafe { + deliver( + client.block_on( + client + .marmot + .download_media(account_ref, group_id_hex, reference), + ), + out_result, + ) + } + }) +} + +/// Typed media references projected from group message history. Each +/// record's embedded `reference` can be passed back to +/// `marmot_download_media`. When `has_limit` is false, `limit` is ignored +/// and the full history is projected. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_list` valid. Free the result with +/// `marmot_media_record_list_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_list_media( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + has_limit: bool, + limit: u32, + out_list: *mut *mut MarmotMediaRecordList, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let limit = has_limit.then_some(limit); + unsafe { + deliver::<_, MarmotMediaRecordList>( + client.marmot.list_media(account_ref, group_id_hex, limit), + out_list, + ) + } + }) +} diff --git a/crates/marmot-c/src/commands/message.rs b/crates/marmot-c/src/commands/message.rs new file mode 100644 index 00000000..190930d2 --- /dev/null +++ b/crates/marmot-c/src/commands/message.rs @@ -0,0 +1,327 @@ +//! Message history read plus send/react/reply/edit/delete commands. + +use std::ffi::c_char; + +use crate::memory::{optional_str, required_str}; +use crate::status::MarmotStatus; +use crate::types::account::MarmotSendSummary; +use crate::types::message::{MarmotAppMessageRecordList, MarmotSecureDeleteExpiredResult}; +use crate::{MarmotClient, client_ref, ffi_guard}; + +use super::account::try_arg; +use super::deliver; + +/// Send a plain UTF-8 text message. Structured payloads (reactions, +/// replies, deletes, media) go through dedicated methods. +/// +/// # Safety +/// `client` must be a live handle; `account_ref`, `group_id_hex`, and +/// `text` valid strings; `out_summary` a valid pointer. Free the result +/// with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_send_text( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + text: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let text = try_arg!(unsafe { required_str(text) }); + unsafe { + deliver( + client.block_on(client.marmot.send_text(account_ref, group_id_hex, text)), + out_summary, + ) + } + }) +} + +/// Re-attempt publishing a group's pending (committed-but-undelivered) +/// commit(s) without minting a new event. +/// +/// An own send commits and projects locally *before* it publishes, so a +/// message sent while offline (or when the relay was unreachable) lands in +/// the timeline with `source_message_id_hex == NULL` — committed, not yet +/// delivered. Re-sending the same text would mint a fresh commit and event +/// id, duplicating the bubble. This drives the existing pending commit to +/// the relays via convergence instead, so the original timeline row flips +/// to delivered (`source_message_id_hex != NULL`) on success and no new +/// event is created. Returns the delivery summary; `published == 0` means +/// nothing was pending or publishing is still failing. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_summary` a valid pointer. Free the result with +/// `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_retry_group_convergence( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on( + client + .marmot + .retry_group_convergence(account_ref, group_id_hex), + ), + out_summary, + ) + } + }) +} + +/// React to `target_message_id` with `emoji` (an "add" reaction). +/// +/// # Safety +/// `client` must be a live handle; `account_ref`, `group_id_hex`, +/// `target_message_id`, and `emoji` valid strings; `out_summary` a valid +/// pointer. Free the result with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_react_to_message( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + target_message_id: *const c_char, + emoji: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let target_message_id = try_arg!(unsafe { required_str(target_message_id) }); + let emoji = try_arg!(unsafe { required_str(emoji) }); + unsafe { + deliver( + client.block_on(client.marmot.react_to_message( + account_ref, + group_id_hex, + target_message_id, + emoji, + )), + out_summary, + ) + } + }) +} + +/// Remove this account's reaction from `target_message_id`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref`, `group_id_hex`, and +/// `target_message_id` valid strings; `out_summary` a valid pointer. Free +/// the result with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_unreact_from_message( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + target_message_id: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let target_message_id = try_arg!(unsafe { required_str(target_message_id) }); + unsafe { + deliver( + client.block_on(client.marmot.unreact_from_message( + account_ref, + group_id_hex, + target_message_id, + )), + out_summary, + ) + } + }) +} + +/// Send `text` as a reply that quotes `target_message_id`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref`, `group_id_hex`, +/// `target_message_id`, and `text` valid strings; `out_summary` a valid +/// pointer. Free the result with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_reply_to_message( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + target_message_id: *const c_char, + text: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let target_message_id = try_arg!(unsafe { required_str(target_message_id) }); + let text = try_arg!(unsafe { required_str(text) }); + unsafe { + deliver( + client.block_on(client.marmot.reply_to_message( + account_ref, + group_id_hex, + target_message_id, + text, + )), + out_summary, + ) + } + }) +} + +/// Mark `target_message_id` deleted for the whole group. This is a +/// tombstone — the original stays in everyone's store; clients render a +/// "message deleted" placeholder. +/// +/// # Safety +/// `client` must be a live handle; `account_ref`, `group_id_hex`, and +/// `target_message_id` valid strings; `out_summary` a valid pointer. Free +/// the result with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_delete_message( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + target_message_id: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let target_message_id = try_arg!(unsafe { required_str(target_message_id) }); + unsafe { + deliver( + client.block_on(client.marmot.delete_message( + account_ref, + group_id_hex, + target_message_id, + )), + out_summary, + ) + } + }) +} + +/// Securely scrub and prune expired disappearing-message plaintext for a +/// group according to its active retention component. The media hash list +/// identifies pruned encrypted-media blobs so host apps can purge their own +/// decrypted-media disk caches keyed by ciphertext hash. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_result` a valid pointer. Free the result with +/// `marmot_secure_delete_expired_result_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_secure_delete_expired( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_result: *mut *mut MarmotSecureDeleteExpiredResult, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on( + client + .marmot + .secure_delete_expired(account_ref, group_id_hex), + ), + out_result, + ) + } + }) +} + +/// Edit `target_message_id` by publishing a kind-1009 event that +/// references it and carries the replacement plaintext in `content`. +/// Recipients honour the edit only when its authenticated author matches +/// the target's author; mismatched edits are ignored client-side. +/// +/// The chat-list preview deliberately does not bump on an edit — an edit +/// to a stale message must not reorder a conversation back to the top of +/// the list. Host apps that aggregate edit history (e.g. an "(edited · N)" +/// affordance) read the kind-1009 versions back from the timeline +/// projection and resolve the latest text per target message id. +/// +/// # Safety +/// `client` must be a live handle; `account_ref`, `group_id_hex`, +/// `target_message_id`, and `content` valid strings; `out_summary` a valid +/// pointer. Free the result with `marmot_send_summary_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_edit_message( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + target_message_id: *const c_char, + content: *const c_char, + out_summary: *mut *mut MarmotSendSummary, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + let target_message_id = try_arg!(unsafe { required_str(target_message_id) }); + let content = try_arg!(unsafe { required_str(content) }); + unsafe { + deliver( + client.block_on(client.marmot.edit_message( + account_ref, + group_id_hex, + target_message_id, + content, + )), + out_summary, + ) + } + }) +} + +/// Initial history fetch for a group (or, when `group_id_hex` is NULL, +/// the account-wide tail). Used to populate the conversation view before +/// the subscription stream takes over. When `has_limit` is false, `limit` +/// is ignored and no row cap is applied. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `group_id_hex` NULL or a valid string; `out_list` a valid pointer. +/// Free the result with `marmot_app_message_record_list_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_messages( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + has_limit: bool, + limit: u32, + out_list: *mut *mut MarmotAppMessageRecordList, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { optional_str(group_id_hex) }); + let limit = has_limit.then_some(limit); + unsafe { + deliver( + client.marmot.messages(account_ref, group_id_hex, limit), + out_list, + ) + } + }) +} diff --git a/crates/marmot-c/src/commands/mod.rs b/crates/marmot-c/src/commands/mod.rs new file mode 100644 index 00000000..d39ce898 --- /dev/null +++ b/crates/marmot-c/src/commands/mod.rs @@ -0,0 +1,199 @@ +//! `extern "C"` command wrappers over `marmot_uniffi::Marmot`. +//! +//! One module per `marmot-uniffi` commands module, same names. Every +//! wrapper follows the same shape: +//! +//! - `ffi_guard` around the body (no unwinding into C). +//! - Borrowed inputs read with `required_str` / `optional_str` / +//! `str_array` / input-struct `to_ffi`; caller memory is never freed. +//! - Async runtime methods run via `client.block_on`. +//! - Results are converted to `#[repr(C)]` mirrors, moved to the heap +//! with `memory::boxed`, and written through the out-pointer; the +//! caller releases them with the type's `marmot_*_free`. +//! - Errors map through `status::status_from_error`. + +pub mod account; +pub mod agent_stream; +pub mod audit; +pub mod chat_list; +pub mod directory; +pub mod group; +pub mod media; +pub mod message; +pub mod notification; +pub mod push; +pub mod relay; +pub mod subscription; +pub mod timeline; + +use std::ffi::c_char; + +use marmot_uniffi::MarmotKitError; + +use crate::MarmotStatus; +use crate::memory::{CFree, boxed, owned_c_string}; +use crate::status::status_from_error; +use crate::write_out; + +/// Deliver a fallible record result: convert to the mirror, move it to the +/// heap, write it through the out-pointer. +pub(crate) unsafe fn deliver( + result: Result, + out: *mut *mut TMirror, +) -> MarmotStatus +where + TMirror: From + CFree, +{ + match result { + Ok(value) => { + let root = boxed(TMirror::from(value)); + match unsafe { write_out(out, root) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { crate::memory::free_boxed(root) }; + status + } + } + } + Err(err) => status_from_error(&err), + } +} + +/// Deliver a fallible `Option` result: `None` writes NULL and still +/// returns `MARMOT_STATUS_OK` — callers distinguish "absent" from failure +/// by the status code. +pub(crate) unsafe fn deliver_opt( + result: Result, MarmotKitError>, + out: *mut *mut TMirror, +) -> MarmotStatus +where + TMirror: From + CFree, +{ + match result { + Ok(value) => { + let root = value.map_or(std::ptr::null_mut(), |v| boxed(TMirror::from(v))); + match unsafe { write_out(out, root) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { crate::memory::free_boxed(root) }; + status + } + } + } + Err(err) => status_from_error(&err), + } +} + +/// Deliver a fallible scalar result (u64/bool/...) by value. +pub(crate) unsafe fn deliver_scalar( + result: Result, + out: *mut T, +) -> MarmotStatus { + match result { + Ok(value) => match unsafe { write_out(out, value) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => status, + }, + Err(err) => status_from_error(&err), + } +} + +/// Deliver a fallible `String` result as an owned C string. +pub(crate) unsafe fn deliver_string( + result: Result, + out: *mut *mut c_char, +) -> MarmotStatus { + match result { + Ok(value) => { + let ptr = owned_c_string(value); + match unsafe { write_out(out, ptr) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { crate::memory::free_c_string(ptr) }; + status + } + } + } + Err(err) => status_from_error(&err), + } +} + +/// Deliver an infallible unit result for fallible unit commands. +pub(crate) fn deliver_unit(result: Result<(), MarmotKitError>) -> MarmotStatus { + match result { + Ok(()) => MarmotStatus::Ok, + Err(err) => status_from_error(&err), + } +} + +/// Deliver a fallible `Vec` result as an owned `(ptr, len)` byte buffer. +/// The caller frees it with `marmot_bytes_free`. Empty is `(NULL, 0)`. +pub(crate) unsafe fn deliver_bytes( + result: Result, MarmotKitError>, + out_data: *mut *mut u8, + out_len: *mut usize, +) -> MarmotStatus { + match result { + Ok(bytes) => { + if out_data.is_null() || out_len.is_null() { + crate::status::set_last_error("out-pointer argument was NULL"); + return MarmotStatus::NullPointer; + } + let (ptr, len) = crate::memory::owned_vec(bytes); + unsafe { + out_data.write(ptr); + out_len.write(len); + } + MarmotStatus::Ok + } + Err(err) => status_from_error(&err), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deliver_bytes_writes_pair() { + let _guard = crate::memory::audit::test_lock(); + let mut data: *mut u8 = std::ptr::null_mut(); + let mut len: usize = 999; + let status = unsafe { deliver_bytes(Ok(vec![1, 2, 3]), &mut data, &mut len) }; + assert_eq!(status, MarmotStatus::Ok); + assert!(!data.is_null()); + assert_eq!(len, 3); + assert_eq!(unsafe { std::slice::from_raw_parts(data, len) }, &[1, 2, 3]); + unsafe { crate::memory::free_vec(data, len) }; + } + + #[test] + fn deliver_bytes_empty_is_null() { + let _guard = crate::memory::audit::test_lock(); + let mut data: *mut u8 = (&mut 0u8) as *mut u8; + let mut len: usize = 999; + let status = unsafe { deliver_bytes(Ok(Vec::new()), &mut data, &mut len) }; + assert_eq!(status, MarmotStatus::Ok); + assert!(data.is_null()); + assert_eq!(len, 0); + } + + #[test] + fn deliver_bytes_rejects_null_out() { + let status = + unsafe { deliver_bytes(Ok(vec![1]), std::ptr::null_mut(), std::ptr::null_mut()) }; + assert_eq!(status, MarmotStatus::NullPointer); + } + + #[test] + fn deliver_bytes_maps_error() { + let status = unsafe { + deliver_bytes( + Err(MarmotKitError::RuntimeStopping), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert_eq!(status, MarmotStatus::RuntimeStopping); + } +} diff --git a/crates/marmot-c/src/commands/notification.rs b/crates/marmot-c/src/commands/notification.rs new file mode 100644 index 00000000..019f8184 --- /dev/null +++ b/crates/marmot-c/src/commands/notification.rs @@ -0,0 +1,135 @@ +//! Notification settings, catch-up, and background-collection commands. + +use std::ffi::c_char; + +use crate::memory::required_str; +use crate::status::MarmotStatus; +use crate::types::notification::{ + MarmotBackgroundNotificationCollection, MarmotNotificationSettings, + MarmotNotificationWakeSource, +}; +use crate::{MarmotClient, client_ref, ffi_guard}; + +use super::account::try_arg; +use super::{deliver, deliver_unit}; + +/// The account's current notification preferences. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_settings` a valid pointer. Free the result with +/// `marmot_notification_settings_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_notification_settings( + client: *const MarmotClient, + account_ref: *const c_char, + out_settings: *mut *mut MarmotNotificationSettings, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { + deliver( + client.marmot.notification_settings(account_ref), + out_settings, + ) + } + }) +} + +/// Enable or disable locally rendered notifications for the account. +/// Returns the updated settings. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_settings` a valid pointer. Free the result with +/// `marmot_notification_settings_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_set_local_notifications_enabled( + client: *const MarmotClient, + account_ref: *const c_char, + enabled: bool, + out_settings: *mut *mut MarmotNotificationSettings, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { + deliver( + client + .marmot + .set_local_notifications_enabled(account_ref, enabled), + out_settings, + ) + } + }) +} + +/// Enable or disable native push (APNs/FCM) delivery for the account. +/// Returns the updated settings. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_settings` a valid pointer. Free the result with +/// `marmot_notification_settings_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_set_native_push_enabled( + client: *const MarmotClient, + account_ref: *const c_char, + enabled: bool, + out_settings: *mut *mut MarmotNotificationSettings, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { + deliver( + client.block_on(client.marmot.set_native_push_enabled(account_ref, enabled)), + out_settings, + ) + } + }) +} + +/// Catch up all accounts on missed events (explicit foreground catch-up). +/// +/// # Safety +/// `client` must be a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_catch_up_accounts(client: *const MarmotClient) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + deliver_unit(client.block_on(client.marmot.catch_up_accounts())) + }) +} + +/// Run a bounded background notification collection pass after a platform +/// wake (`source`), waiting at most `max_wait_ms` milliseconds. Infallible: +/// collection failures are reported inside the returned record's `status` +/// and `error` fields. +/// +/// # Safety +/// `client` must be a live handle; `out_collection` a valid pointer. Free +/// the result with `marmot_background_notification_collection_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_collect_notifications_after_wake( + client: *const MarmotClient, + max_wait_ms: u32, + source: MarmotNotificationWakeSource, + out_collection: *mut *mut MarmotBackgroundNotificationCollection, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let source = source.to_ffi(); + unsafe { + deliver( + client.block_on( + client + .marmot + .collect_notifications_after_wake(max_wait_ms, source), + ), + out_collection, + ) + } + }) +} diff --git a/crates/marmot-c/src/commands/push.rs b/crates/marmot-c/src/commands/push.rs new file mode 100644 index 00000000..5b2c8365 --- /dev/null +++ b/crates/marmot-c/src/commands/push.rs @@ -0,0 +1,127 @@ +//! Native-push registration and group push-debug commands. + +use std::ffi::c_char; + +use crate::memory::{optional_str, required_str}; +use crate::status::MarmotStatus; +use crate::types::push::{MarmotGroupPushDebugInfo, MarmotPushPlatform, MarmotPushRegistration}; +use crate::{MarmotClient, client_ref, ffi_guard}; + +use super::account::try_arg; +use super::{deliver, deliver_opt, deliver_unit}; + +/// The account's current local native-push registration, if any. Writes +/// NULL with `MARMOT_STATUS_OK` when no registration exists — callers +/// distinguish "absent" from failure by the status code. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_registration` a valid pointer. Free a non-NULL result with +/// `marmot_push_registration_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_push_registration( + client: *const MarmotClient, + account_ref: *const c_char, + out_registration: *mut *mut MarmotPushRegistration, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { + deliver_opt( + client.marmot.push_registration(account_ref), + out_registration, + ) + } + }) +} + +/// Create or update the account's native-push registration for a device +/// token. Only a privacy-safe fingerprint of `raw_token` is ever returned +/// or stored in the registration record. +/// +/// # Safety +/// `client` must be a live handle; `account_ref`, `raw_token`, and +/// `server_pubkey_hex` valid strings; `relay_hint` a valid string or NULL; +/// `out_registration` a valid pointer. Free the result with +/// `marmot_push_registration_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_upsert_push_registration( + client: *const MarmotClient, + account_ref: *const c_char, + platform: MarmotPushPlatform, + raw_token: *const c_char, + server_pubkey_hex: *const c_char, + relay_hint: *const c_char, + out_registration: *mut *mut MarmotPushRegistration, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let raw_token = try_arg!(unsafe { required_str(raw_token) }); + let server_pubkey_hex = try_arg!(unsafe { required_str(server_pubkey_hex) }); + let relay_hint = try_arg!(unsafe { optional_str(relay_hint) }); + unsafe { + deliver( + client.block_on(client.marmot.upsert_push_registration( + account_ref, + platform.to_ffi(), + raw_token, + server_pubkey_hex, + relay_hint, + )), + out_registration, + ) + } + }) +} + +/// Remove the account's local native-push registration. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_clear_push_registration( + client: *const MarmotClient, + account_ref: *const c_char, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + deliver_unit(client.block_on(client.marmot.clear_push_registration(account_ref))) + }) +} + +/// Aggregated push-token diagnostics for one group: token counts, the +/// local member's registration state, and every cached member token entry. +/// +/// `group_id_hex` is the hex of the opaque MLS `GroupId` bytes (variable +/// length), not the 32-byte Nostr routing id. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_info` a valid pointer. Free the result with +/// `marmot_group_push_debug_info_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_push_debug_info( + client: *const MarmotClient, + account_ref: *const c_char, + group_id_hex: *const c_char, + out_info: *mut *mut MarmotGroupPushDebugInfo, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + let group_id_hex = try_arg!(unsafe { required_str(group_id_hex) }); + unsafe { + deliver( + client.block_on( + client + .marmot + .group_push_debug_info(account_ref, group_id_hex), + ), + out_info, + ) + } + }) +} diff --git a/crates/marmot-c/src/commands/relay.rs b/crates/marmot-c/src/commands/relay.rs new file mode 100644 index 00000000..e4424268 --- /dev/null +++ b/crates/marmot-c/src/commands/relay.rs @@ -0,0 +1,146 @@ +//! Relay-list, relay-health, and relay-telemetry commands. + +use std::ffi::c_char; + +use crate::memory::required_str; +use crate::status::MarmotStatus; +use crate::types::relay::{ + MarmotAccountRelayLists, MarmotRelayHealth, MarmotRelayTelemetryRuntimeConfig, + MarmotRelayTelemetrySettings, +}; +use crate::{MarmotClient, client_ref, ffi_guard}; + +use super::account::try_arg; +use super::{deliver, deliver_string, deliver_unit}; + +/// Per-account relay lists: the NIP-65 and inbox lists the account has +/// published, plus the configured default/bootstrap sets. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_lists` a valid pointer. Free the result with +/// `marmot_account_relay_lists_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_account_relay_lists( + client: *const MarmotClient, + account_ref: *const c_char, + out_lists: *mut *mut MarmotAccountRelayLists, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + unsafe { deliver(client.marmot.account_relay_lists(account_ref), out_lists) } + }) +} + +/// Live relay-plane connection health (connected / connecting / +/// disconnected counts, etc.) for the relay diagnostics view. +/// +/// # Safety +/// `client` must be a live handle; `out_health` a valid pointer. Free +/// the result with `marmot_relay_health_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_relay_health( + client: *const MarmotClient, + out_health: *mut *mut MarmotRelayHealth, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + unsafe { + deliver( + Ok(client.block_on(client.marmot.relay_health())), + out_health, + ) + } + }) +} + +/// Device-wide relay telemetry export settings. Export is opt-in and stays +/// inert until `export_enabled` is true and runtime/default config supplies +/// a valid OTLP endpoint, bearer token, and resource attributes. +/// +/// # Safety +/// `client` must be a live handle; `out_settings` a valid pointer. Free +/// the result with `marmot_relay_telemetry_settings_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_relay_telemetry_settings( + client: *const MarmotClient, + out_settings: *mut *mut MarmotRelayTelemetrySettings, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + unsafe { deliver(client.marmot.relay_telemetry_settings(), out_settings) } + }) +} + +/// Stable random identifier for this app install, suitable for the OTLP +/// `service.instance.id` resource attribute. Separate from audit-log device +/// identity. +/// +/// # Safety +/// `client` must be a live handle; `out_install_id` a valid pointer. Free +/// the result with `marmot_string_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_telemetry_install_id( + client: *const MarmotClient, + out_install_id: *mut *mut c_char, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + unsafe { deliver_string(client.marmot.telemetry_install_id(), out_install_id) } + }) +} + +/// Supply non-persisted OTLP runtime metadata: optional metrics URL +/// override, bearer token from the host app's build-time secret, and +/// resource attributes from the platform shell. +/// +/// # Safety +/// `client` must be a live handle; `config` must point to a valid +/// caller-owned `MarmotRelayTelemetryRuntimeConfig` (never freed by the +/// library). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_set_relay_telemetry_runtime_config( + client: *const MarmotClient, + config: *const MarmotRelayTelemetryRuntimeConfig, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + if config.is_null() { + crate::status::set_last_error("config argument was NULL"); + return MarmotStatus::NullPointer; + } + let config = try_arg!(unsafe { (*config).to_ffi() }); + deliver_unit(client.block_on(client.marmot.set_relay_telemetry_runtime_config(config))) + }) +} + +/// Persist device-wide relay telemetry export settings and return the +/// normalized settings that were stored. +/// +/// # Safety +/// `client` must be a live handle; `settings` must point to a valid +/// caller-owned `MarmotRelayTelemetrySettings` (never freed by the +/// library); `out_settings` a valid pointer. Free the result with +/// `marmot_relay_telemetry_settings_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_set_relay_telemetry_settings( + client: *const MarmotClient, + settings: *const MarmotRelayTelemetrySettings, + out_settings: *mut *mut MarmotRelayTelemetrySettings, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + if settings.is_null() { + crate::status::set_last_error("settings argument was NULL"); + return MarmotStatus::NullPointer; + } + let settings = unsafe { (*settings).to_ffi() }; + unsafe { + deliver( + client.block_on(client.marmot.set_relay_telemetry_settings(settings)), + out_settings, + ) + } + }) +} diff --git a/crates/marmot-c/src/commands/subscription.rs b/crates/marmot-c/src/commands/subscription.rs new file mode 100644 index 00000000..9660a6d6 --- /dev/null +++ b/crates/marmot-c/src/commands/subscription.rs @@ -0,0 +1 @@ +// Populated by the commands fan-out; see mod.rs for the wrapper pattern. diff --git a/crates/marmot-c/src/commands/timeline.rs b/crates/marmot-c/src/commands/timeline.rs new file mode 100644 index 00000000..b3494fb4 --- /dev/null +++ b/crates/marmot-c/src/commands/timeline.rs @@ -0,0 +1,59 @@ +//! Materialized timeline read command. +//! +//! `subscribe_timeline_messages` is a subscription-returning method and is +//! owned by the subscriptions layer (`src/subscriptions.rs`), not wrapped +//! here. + +use std::ffi::c_char; + +use crate::memory::required_str; +use crate::status::MarmotStatus; +use crate::types::timeline::{MarmotTimelineMessageQuery, MarmotTimelinePage}; +use crate::{MarmotClient, client_ref, ffi_guard}; + +use super::account::try_arg; +use super::deliver; + +/// Materialized conversation timeline for a group or account-wide tail. +/// +/// This is the app-facing aggregated view: kind-9 chat/reply/media rows, +/// kind-1200 stream-start rows, stream-final metadata pointing back to the +/// start, reaction summaries, delete tombstones, and pagination flags. Raw +/// kind-7/kind-5 events remain available through `marmot_messages` for +/// diagnostics. +/// +/// This call is **synchronous** and runs the store read on the calling +/// thread; clients should not use it for scroll-back. Prefer the timeline +/// subscription (`marmot_subscribe_timeline_messages`) plus its +/// `paginate_backwards` / `paginate_forwards`, which own a bounded window +/// and run off the caller thread. Retained for one-shot +/// diagnostics/tooling only. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; `query` a +/// valid borrowed struct (never freed by the library) whose non-NULL string +/// fields are valid NUL-terminated strings; `out_page` a valid pointer. +/// Free the result with `marmot_timeline_page_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_timeline_messages( + client: *const MarmotClient, + account_ref: *const c_char, + query: *const MarmotTimelineMessageQuery, + out_page: *mut *mut MarmotTimelinePage, +) -> MarmotStatus { + ffi_guard(|| { + let client = try_arg!(unsafe { client_ref(client) }); + let account_ref = try_arg!(unsafe { required_str(account_ref) }); + if query.is_null() { + crate::status::set_last_error("query argument was NULL"); + return MarmotStatus::NullPointer; + } + let query = try_arg!(unsafe { (*query).to_ffi() }); + unsafe { + deliver( + client.marmot.timeline_messages(account_ref, query), + out_page, + ) + } + }) +} diff --git a/crates/marmot-c/src/lib.rs b/crates/marmot-c/src/lib.rs new file mode 100644 index 00000000..7162666f --- /dev/null +++ b/crates/marmot-c/src/lib.rs @@ -0,0 +1,268 @@ +//! C ABI bindings for the Marmot app runtime. +//! +//! This crate exposes the same surface as `marmot-uniffi` (the single +//! source of truth for FFI DTO shapes) through a stable C ABI: +//! +//! - Opaque handles: [`MarmotClient`] plus one handle per subscription. +//! - Every runtime record/enum has a `#[repr(C)]` mirror in [`types`], +//! converted from the `…Ffi` types and released by a deep-free. +//! - Blocking calls: async runtime methods run on an embedded multi-thread +//! tokio runtime via `block_on`. Push-based surfaces (subscriptions, +//! agent streams) additionally offer callback registration. +//! - Errors: fallible functions return [`MarmotStatus`]; detail text for +//! the current thread's most recent failure comes from +//! [`marmot_last_error_message`]. +//! +//! The C header `include/marmot.h` is generated with cbindgen (`just +//! c-header`) and checked in; CI diff-gates it against this source. + +use std::ffi::c_char; +use std::panic::AssertUnwindSafe; +use std::sync::Arc; + +use marmot_uniffi::Marmot; + +pub mod commands; +pub mod memory; +pub mod status; +pub mod subscriptions; +pub mod types; + +pub use status::MarmotStatus; + +use memory::{owned_c_string, required_str, str_array}; +use status::set_last_error; + +/// Opaque handle to a running Marmot client: the app runtime plus the +/// tokio runtime that drives it. Create with `marmot_client_new`, destroy +/// with `marmot_client_free`. +pub struct MarmotClient { + pub(crate) runtime: tokio::runtime::Runtime, + pub(crate) marmot: Arc, +} + +impl MarmotClient { + /// Run an async runtime call to completion on the embedded runtime. + pub(crate) fn block_on(&self, fut: F) -> F::Output { + self.runtime.block_on(fut) + } +} + +/// Catch panics at the ABI boundary: unwinding into C is undefined +/// behavior, so a caught panic becomes `MARMOT_STATUS_PANIC_CAUGHT`. +pub(crate) fn ffi_guard(body: impl FnOnce() -> MarmotStatus) -> MarmotStatus { + match std::panic::catch_unwind(AssertUnwindSafe(body)) { + Ok(status) => status, + Err(panic) => { + let detail = panic + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| panic.downcast_ref::().cloned()) + .unwrap_or_else(|| "panic at FFI boundary".to_string()); + set_last_error(detail); + MarmotStatus::PanicCaught + } + } +} + +/// Borrow-check a client handle argument. +pub(crate) unsafe fn client_ref<'a>( + client: *const MarmotClient, +) -> Result<&'a MarmotClient, MarmotStatus> { + if client.is_null() { + set_last_error("client handle was NULL"); + return Err(MarmotStatus::NullPointer); + } + Ok(unsafe { &*client }) +} + +/// Write a value through a required out-pointer. +pub(crate) unsafe fn write_out(out: *mut T, value: T) -> Result<(), MarmotStatus> { + if out.is_null() { + set_last_error("out-pointer argument was NULL"); + return Err(MarmotStatus::NullPointer); + } + unsafe { out.write(value) }; + Ok(()) +} + +/// Create a Marmot client rooted at `root_path`, connected to +/// `relay_urls` (`relay_urls_len` entries). On success writes the new +/// handle to `out_client`. Uses the platform keychain-backed account +/// store, matching the UniFFI constructor. +/// +/// # Safety +/// `root_path` must be a valid NUL-terminated UTF-8 string; `relay_urls` +/// must point to `relay_urls_len` valid strings (or be NULL with length +/// 0); `out_client` must be a valid pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_client_new( + root_path: *const c_char, + relay_urls: *const *const c_char, + relay_urls_len: usize, + out_client: *mut *mut MarmotClient, +) -> MarmotStatus { + ffi_guard(|| { + let root_path = match unsafe { required_str(root_path) } { + Ok(v) => v, + Err(status) => return status, + }; + let relay_urls = match unsafe { str_array(relay_urls, relay_urls_len) } { + Ok(v) => v, + Err(status) => return status, + }; + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(err) => { + set_last_error(format!("failed to build tokio runtime: {err}")); + return MarmotStatus::Runtime; + } + }; + // Constructor is sync but spawns onto the runtime internally, so + // enter the runtime context for the duration of the call. + let guard = runtime.enter(); + let marmot = match Marmot::new(root_path, relay_urls) { + Ok(m) => m, + Err(err) => { + drop(guard); + return status::status_from_error(&err); + } + }; + drop(guard); + let client = memory::boxed(MarmotClient { runtime, marmot }); + match unsafe { write_out(out_client, client) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + // Out-pointer was NULL; reclaim the handle we just made. + unsafe { free_client(client) }; + status + } + } + }) +} + +/// Start the runtime (reconcile accounts, start workers, subscribe +/// transport). Must be called before subscribing. +/// +/// # Safety +/// `client` must be a live handle from `marmot_client_new`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_client_start(client: *const MarmotClient) -> MarmotStatus { + ffi_guard(|| { + let client = match unsafe { client_ref(client) } { + Ok(c) => c, + Err(status) => return status, + }; + match client.block_on(client.marmot.start()) { + Ok(()) => MarmotStatus::Ok, + Err(err) => status::status_from_error(&err), + } + }) +} + +/// Shut the runtime down. Open subscriptions drain and report +/// `MARMOT_STATUS_CLOSED` from their next read. +/// +/// # Safety +/// `client` must be a live handle from `marmot_client_new`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_client_shutdown(client: *const MarmotClient) -> MarmotStatus { + ffi_guard(|| { + let client = match unsafe { client_ref(client) } { + Ok(c) => c, + Err(status) => return status, + }; + client.block_on(client.marmot.shutdown()); + MarmotStatus::Ok + }) +} + +/// Whether the runtime is currently shutting down. Writes to `out_stopping`. +/// +/// # Safety +/// `client` must be a live handle; `out_stopping` must be valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_client_is_stopping( + client: *const MarmotClient, + out_stopping: *mut bool, +) -> MarmotStatus { + ffi_guard(|| { + let client = match unsafe { client_ref(client) } { + Ok(c) => c, + Err(status) => return status, + }; + let stopping = client.marmot.is_stopping(); + match unsafe { write_out(out_stopping, stopping) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => status, + } + }) +} + +pub(crate) unsafe fn free_client(client: *mut MarmotClient) { + if client.is_null() { + return; + } + #[cfg(feature = "alloc-audit")] + memory::audit::on_free(); + let client = unsafe { Box::from_raw(client) }; + // Dropping a tokio runtime from within one of its own worker threads + // aborts; the shutdown_background escape hatch keeps free safe to call + // from any thread (e.g. a callback thread, though callers shouldn't). + let MarmotClient { runtime, marmot } = *client; + drop(marmot); + runtime.shutdown_background(); +} + +/// Destroy a client handle. Call `marmot_client_shutdown` first for a +/// graceful stop. NULL is a no-op. The handle must not be used afterwards. +/// +/// # Safety +/// `client` must be NULL or a live handle from `marmot_client_new` that +/// has not been freed already. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_client_free(client: *mut MarmotClient) { + let _ = ffi_guard(|| { + unsafe { free_client(client) }; + MarmotStatus::Ok + }); +} + +/// Return the detail message for the current thread's most recent failed +/// `marmot_*` call, or NULL if there is none. The returned string is an +/// owned copy: free it with `marmot_string_free`. Reading clears the slot. +#[unsafe(no_mangle)] +pub extern "C" fn marmot_last_error_message() -> *mut c_char { + match std::panic::catch_unwind(status::take_last_error) { + Ok(Some(message)) => owned_c_string(message), + _ => std::ptr::null_mut(), + } +} + +/// Free a string returned by this library (`marmot_last_error_message`, +/// string out-params). NULL is a no-op. +/// +/// # Safety +/// `s` must be NULL or a string returned by this library that has not +/// been freed already. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_string_free(s: *mut c_char) { + let _ = ffi_guard(|| { + unsafe { memory::free_c_string(s) }; + MarmotStatus::Ok + }); +} + +/// Free a byte buffer returned by this library as a `(data, len)` pair (e.g. +/// `marmot_download_group_blossom_image`). `(NULL, 0)` is a no-op. +/// +/// # Safety +/// `data`/`len` must be exactly a pair returned by this library that has not +/// been freed already. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_bytes_free(data: *mut u8, len: usize) { + memory::free_guard(|| unsafe { memory::free_vec(data, len) }); +} diff --git a/crates/marmot-c/src/memory.rs b/crates/marmot-c/src/memory.rs new file mode 100644 index 00000000..5fd075de --- /dev/null +++ b/crates/marmot-c/src/memory.rs @@ -0,0 +1,291 @@ +//! Allocation and ownership helpers for the C ABI. +//! +//! Every heap object that crosses the boundary is created and destroyed +//! through this module so the `alloc-audit` test feature can prove that +//! deep-free implementations release everything they allocate. +//! +//! Ownership rules (documented in `marmot.h`): +//! - Strings returned as bare `char *` are freed with `marmot_string_free`. +//! - Structs returned by pointer are freed only with their type's +//! `marmot_*_free` function, which deep-frees every field. Callers never +//! free fields individually. +//! - `(ptr, len)` array fields are owned by their parent struct. + +use std::ffi::{CString, c_char}; + +/// Deep-free for a `#[repr(C)]` mirror type: release every owned pointer +/// reachable from `self` without freeing `self`'s own storage. +/// +/// # Safety +/// Must be called at most once per value, and only on values produced by +/// this crate's conversion code. +pub(crate) trait CFree { + unsafe fn free_in_place(&mut self); +} + +/// Scalar fields need no freeing; blanket impls keep generated code uniform. +macro_rules! no_free { + ($($ty:ty),* $(,)?) => { + $(impl CFree for $ty { + unsafe fn free_in_place(&mut self) {} + })* + }; +} +no_free!(bool, u8, u16, u32, u64, i8, i16, i32, i64, usize, f32, f64); + +/// Owned C strings inside `(ptr, len)` arrays (e.g. `Vec` mirrors). +impl CFree for *mut c_char { + unsafe fn free_in_place(&mut self) { + unsafe { free_c_string(*self) }; + } +} + +pub(crate) mod audit { + #[cfg(feature = "alloc-audit")] + use std::sync::atomic::{AtomicI64, Ordering}; + #[cfg(test)] + use std::sync::{Mutex, MutexGuard}; + + #[cfg(feature = "alloc-audit")] + static LIVE: AtomicI64 = AtomicI64::new(0); + + /// The live-allocation counter is process-global, so allocating tests + /// in different modules race each other when the harness runs them in + /// parallel. Every allocating test takes this lock around its + /// convert→assert→free window (with or without the alloc-audit + /// feature, so test code needs no feature-conditional locking). + #[cfg(test)] + pub(crate) fn test_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + LOCK.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + #[cfg(feature = "alloc-audit")] + pub(crate) fn on_alloc() { + LIVE.fetch_add(1, Ordering::SeqCst); + } + + #[cfg(feature = "alloc-audit")] + pub(crate) fn on_free() { + let prev = LIVE.fetch_sub(1, Ordering::SeqCst); + assert!(prev > 0, "alloc-audit: free without matching alloc"); + } + + /// Number of live FFI allocations. Tests assert this returns to its + /// starting value after a convert→free roundtrip. + #[cfg(all(test, feature = "alloc-audit"))] + pub(crate) fn live_allocations() -> i64 { + LIVE.load(Ordering::SeqCst) + } +} + +#[inline] +fn note_alloc() { + #[cfg(feature = "alloc-audit")] + audit::on_alloc(); +} + +#[inline] +fn note_free() { + #[cfg(feature = "alloc-audit")] + audit::on_free(); +} + +/// Convert an owned Rust string into an owned NUL-terminated C string. +/// +/// The DTO surface never produces interior NUL bytes; if one ever appears +/// it is stripped rather than truncating the payload or panicking across +/// the boundary. +pub(crate) fn owned_c_string(value: String) -> *mut c_char { + let cstring = CString::new(value).unwrap_or_else(|err| { + let mut bytes = err.into_vec(); + bytes.retain(|&b| b != 0); + CString::new(bytes).expect("NUL bytes were just stripped") + }); + note_alloc(); + cstring.into_raw() +} + +/// Free a string produced by [`owned_c_string`]. NULL is a no-op. +/// +/// # Safety +/// `ptr` must be NULL or a pointer returned by [`owned_c_string`] that has +/// not been freed already. +pub(crate) unsafe fn free_c_string(ptr: *mut c_char) { + if ptr.is_null() { + return; + } + note_free(); + drop(unsafe { CString::from_raw(ptr) }); +} + +/// Convert `Option` into a nullable owned C string. +pub(crate) fn owned_opt_c_string(value: Option) -> *mut c_char { + value.map_or(std::ptr::null_mut(), owned_c_string) +} + +/// Convert an owned `Vec` of mirror values into an owned `(ptr, len)` +/// pair. Empty vectors become `(NULL, 0)` with no allocation. +pub(crate) fn owned_vec(values: Vec) -> (*mut T, usize) { + if values.is_empty() { + return (std::ptr::null_mut(), 0); + } + let len = values.len(); + note_alloc(); + let ptr = Box::into_raw(values.into_boxed_slice()) as *mut T; + (ptr, len) +} + +/// Deep-free a `(ptr, len)` pair produced by [`owned_vec`]: free each +/// element in place, then release the slice. `(NULL, 0)` is a no-op. +/// +/// # Safety +/// `(ptr, len)` must be exactly as returned by [`owned_vec`] and not freed +/// already. +pub(crate) unsafe fn free_vec(ptr: *mut T, len: usize) { + if ptr.is_null() { + return; + } + note_free(); + let mut boxed = unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len)) }; + for item in boxed.iter_mut() { + unsafe { item.free_in_place() }; + } + drop(boxed); +} + +/// Move a mirror value to the heap and hand ownership to C. +pub(crate) fn boxed(value: T) -> *mut T { + note_alloc(); + Box::into_raw(Box::new(value)) +} + +/// Deep-free a heap value produced by [`boxed`]. NULL is a no-op. +/// +/// # Safety +/// `ptr` must be NULL or a pointer returned by [`boxed`] that has not been +/// freed already. +pub(crate) unsafe fn free_boxed(ptr: *mut T) { + if ptr.is_null() { + return; + } + note_free(); + let mut boxed = unsafe { Box::from_raw(ptr) }; + unsafe { boxed.free_in_place() }; + drop(boxed); +} + +/// Run a deep-free at the ABI boundary under `catch_unwind`, so a panic in a +/// `Drop`/free path never unwinds into C (which is undefined behavior). Frees +/// return `void` and cannot report a status, so a caught panic is swallowed. +pub(crate) fn free_guard(body: impl FnOnce()) { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)); +} + +/// Convert an optional record into a nullable owned pointer. +pub(crate) fn boxed_opt(value: Option) -> *mut T { + value.map_or(std::ptr::null_mut(), boxed) +} + +/// Read a borrowed C string argument into an owned Rust `String`. +/// +/// # Safety +/// `ptr` must be NULL or a valid NUL-terminated string. +pub(crate) unsafe fn required_str(ptr: *const c_char) -> Result { + if ptr.is_null() { + crate::status::set_last_error("required string argument was NULL"); + return Err(crate::MarmotStatus::NullPointer); + } + match unsafe { std::ffi::CStr::from_ptr(ptr) }.to_str() { + Ok(s) => Ok(s.to_owned()), + Err(_) => { + crate::status::set_last_error("string argument was not valid UTF-8"); + Err(crate::MarmotStatus::InvalidUtf8) + } + } +} + +/// Read a nullable C string argument into `Option`. +/// +/// # Safety +/// `ptr` must be NULL or a valid NUL-terminated string. +pub(crate) unsafe fn optional_str( + ptr: *const c_char, +) -> Result, crate::MarmotStatus> { + if ptr.is_null() { + return Ok(None); + } + unsafe { required_str(ptr) }.map(Some) +} + +/// Read a borrowed `(ptr, len)` array of C strings into `Vec`. +/// `(NULL, 0)` is an empty list; NULL with a nonzero length is an error. +/// +/// # Safety +/// When non-NULL, `ptr` must point to `len` valid NUL-terminated strings. +pub(crate) unsafe fn str_array( + ptr: *const *const c_char, + len: usize, +) -> Result, crate::MarmotStatus> { + if ptr.is_null() { + if len == 0 { + return Ok(Vec::new()); + } + crate::status::set_last_error("string array was NULL with nonzero length"); + return Err(crate::MarmotStatus::NullPointer); + } + let mut out = Vec::with_capacity(len); + for i in 0..len { + out.push(unsafe { required_str(*ptr.add(i)) }?); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn string_roundtrip_strips_interior_nul() { + let _guard = crate::memory::audit::test_lock(); + let ptr = owned_c_string("a\0b".to_string()); + let text = unsafe { std::ffi::CStr::from_ptr(ptr) }.to_str().unwrap(); + assert_eq!(text, "ab"); + unsafe { free_c_string(ptr) }; + } + + #[test] + fn free_guard_swallows_panics() { + // A panic in a free/Drop path must never escape (unwinding into C is + // UB). free_guard catches it; the test passing means it did not + // propagate. + free_guard(|| panic!("boom in a free path")); + } + + #[test] + fn empty_vec_is_null_without_allocation() { + let _guard = crate::memory::audit::test_lock(); + let (ptr, len) = owned_vec::(Vec::new()); + assert!(ptr.is_null()); + assert_eq!(len, 0); + unsafe { free_vec(ptr, len) }; + } + + #[cfg(feature = "alloc-audit")] + #[test] + fn audit_balance_returns_to_zero() { + let _guard = crate::memory::audit::test_lock(); + let start = audit::live_allocations(); + let ptr = owned_c_string("hello".to_string()); + let (vec_ptr, vec_len) = owned_vec(vec![1u64, 2, 3]); + let boxed_ptr = boxed(7u64); + assert_eq!(audit::live_allocations(), start + 3); + unsafe { + free_c_string(ptr); + free_vec(vec_ptr, vec_len); + free_boxed(boxed_ptr); + } + assert_eq!(audit::live_allocations(), start); + } +} diff --git a/crates/marmot-c/src/status.rs b/crates/marmot-c/src/status.rs new file mode 100644 index 00000000..fbcab47f --- /dev/null +++ b/crates/marmot-c/src/status.rs @@ -0,0 +1,206 @@ +//! C ABI status codes and the thread-local last-error detail channel. +//! +//! Every fallible `extern "C"` function returns a [`MarmotStatus`]. The +//! numeric values are part of the stable C ABI — append new codes, never +//! renumber existing ones. + +use std::cell::RefCell; + +use marmot_uniffi::MarmotKitError; + +/// Status code returned by every fallible `marmot_*` function. +/// +/// `MARMOT_STATUS_OK` (0) means success. Codes 1-9 are binding-level +/// failures raised by `marmot-c` itself; codes 10+ mirror the runtime's +/// typed error variants one-to-one. Retrieve the human-readable detail for +/// the most recent failure on the current thread with +/// `marmot_last_error_message()`. +// `i32`-backed so the discriminant width is fixed across platforms/compilers +// (a bare C enum's width is implementation-defined). `#[repr(i32)]` — not +// `#[repr(C, i32)]`, which is only valid on enums with fields — gives a plain +// fixed-width fieldless enum; cbindgen emits it as `int32_t`-backed. The +// numeric values are stable ABI: append new codes, never renumber. +#[repr(i32)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotStatus { + Ok = 0, + + // Binding-level codes (raised by marmot-c, not the runtime). + /// A required pointer argument was NULL. + NullPointer = 1, + /// A string argument was not valid UTF-8. + InvalidUtf8 = 2, + /// A Rust panic was caught at the FFI boundary. State may be + /// inconsistent; treat as fatal. + PanicCaught = 3, + /// A blocking subscription read timed out; no item was produced. + Timeout = 4, + /// The subscription is closed (runtime shutdown or sender dropped); + /// no further items will be produced. + Closed = 5, + + // Runtime error variants (mirror marmot_uniffi::MarmotKitError). + DuplicateIdentity = 10, + UnknownAccount = 11, + UnknownGroup = 12, + InvalidHex = 13, + InvalidIdentity = 14, + MissingKeyPackage = 15, + Publish = 16, + TransportClosed = 17, + RuntimeStopping = 18, + NotGroupAdmin = 19, + AdminCannotSelfRemove = 20, + WouldRemoveLastAdmin = 21, + MemberNotInGroup = 22, + AlreadyAdmin = 23, + NotAdmin = 24, + StorageBusy = 25, + SecretNotFound = 26, + KeystoreUnavailable = 27, + EmptyPassphrase = 28, + EncryptionFailed = 29, + Io = 30, + Runtime = 31, + ExternalSignerUnavailable = 32, + ExternalSignerMismatch = 33, + ExternalSignerRejected = 34, +} + +thread_local! { + static LAST_ERROR: RefCell> = const { RefCell::new(None) }; +} + +/// Record the detail string for the current thread's most recent failure. +pub(crate) fn set_last_error(message: impl Into) { + LAST_ERROR.with(|slot| *slot.borrow_mut() = Some(message.into())); +} + +/// Take (and clear) the current thread's most recent failure detail. +pub(crate) fn take_last_error() -> Option { + LAST_ERROR.with(|slot| slot.borrow_mut().take()) +} + +/// Map a runtime error to its status code and record its detail string. +pub(crate) fn status_from_error(err: &MarmotKitError) -> MarmotStatus { + set_last_error(err.to_string()); + match err { + MarmotKitError::DuplicateIdentity { .. } => MarmotStatus::DuplicateIdentity, + MarmotKitError::UnknownAccount { .. } => MarmotStatus::UnknownAccount, + MarmotKitError::UnknownGroup { .. } => MarmotStatus::UnknownGroup, + MarmotKitError::InvalidHex { .. } => MarmotStatus::InvalidHex, + MarmotKitError::InvalidIdentity { .. } => MarmotStatus::InvalidIdentity, + MarmotKitError::MissingKeyPackage { .. } => MarmotStatus::MissingKeyPackage, + MarmotKitError::Publish { .. } => MarmotStatus::Publish, + MarmotKitError::TransportClosed => MarmotStatus::TransportClosed, + MarmotKitError::RuntimeStopping => MarmotStatus::RuntimeStopping, + MarmotKitError::NotGroupAdmin { .. } => MarmotStatus::NotGroupAdmin, + MarmotKitError::AdminCannotSelfRemove { .. } => MarmotStatus::AdminCannotSelfRemove, + MarmotKitError::WouldRemoveLastAdmin { .. } => MarmotStatus::WouldRemoveLastAdmin, + MarmotKitError::MemberNotInGroup { .. } => MarmotStatus::MemberNotInGroup, + MarmotKitError::AlreadyAdmin { .. } => MarmotStatus::AlreadyAdmin, + MarmotKitError::NotAdmin { .. } => MarmotStatus::NotAdmin, + MarmotKitError::StorageBusy { .. } => MarmotStatus::StorageBusy, + MarmotKitError::SecretNotFound { .. } => MarmotStatus::SecretNotFound, + MarmotKitError::KeystoreUnavailable { .. } => MarmotStatus::KeystoreUnavailable, + MarmotKitError::EmptyPassphrase => MarmotStatus::EmptyPassphrase, + MarmotKitError::EncryptionFailed { .. } => MarmotStatus::EncryptionFailed, + MarmotKitError::Io { .. } => MarmotStatus::Io, + MarmotKitError::Runtime { .. } => MarmotStatus::Runtime, + MarmotKitError::ExternalSignerUnavailable { .. } => MarmotStatus::ExternalSignerUnavailable, + MarmotKitError::ExternalSignerMismatch => MarmotStatus::ExternalSignerMismatch, + MarmotKitError::ExternalSignerRejected => MarmotStatus::ExternalSignerRejected, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_runtime_error_variant_maps_to_a_distinct_status() { + let _guard = crate::memory::audit::test_lock(); + let variants: Vec = vec![ + MarmotKitError::DuplicateIdentity { + account: "a".into(), + }, + MarmotKitError::UnknownAccount { + account_ref: "a".into(), + }, + MarmotKitError::UnknownGroup { + group_id_hex: "aa".into(), + }, + MarmotKitError::InvalidHex { + details: "d".into(), + }, + MarmotKitError::InvalidIdentity { + details: "d".into(), + }, + MarmotKitError::MissingKeyPackage { + account: "a".into(), + }, + MarmotKitError::Publish { + details: "d".into(), + }, + MarmotKitError::TransportClosed, + MarmotKitError::RuntimeStopping, + MarmotKitError::NotGroupAdmin { + group_id_hex: "aa".into(), + }, + MarmotKitError::AdminCannotSelfRemove { + group_id_hex: "aa".into(), + }, + MarmotKitError::WouldRemoveLastAdmin { + group_id_hex: "aa".into(), + }, + MarmotKitError::MemberNotInGroup { + group_id_hex: "aa".into(), + member_id_hex: "bb".into(), + }, + MarmotKitError::AlreadyAdmin { + group_id_hex: "aa".into(), + member_id_hex: "bb".into(), + }, + MarmotKitError::NotAdmin { + group_id_hex: "aa".into(), + member_id_hex: "bb".into(), + }, + MarmotKitError::StorageBusy { + details: "d".into(), + }, + MarmotKitError::SecretNotFound { + details: "d".into(), + }, + MarmotKitError::KeystoreUnavailable { + details: "d".into(), + }, + MarmotKitError::EmptyPassphrase, + MarmotKitError::EncryptionFailed { + details: "d".into(), + }, + MarmotKitError::Io { + details: "d".into(), + }, + MarmotKitError::Runtime { + details: "d".into(), + }, + MarmotKitError::ExternalSignerUnavailable { + account: "a".into(), + }, + MarmotKitError::ExternalSignerMismatch, + MarmotKitError::ExternalSignerRejected, + ]; + let mut seen = std::collections::BTreeSet::new(); + for err in &variants { + let status = status_from_error(err); + assert_ne!(status, MarmotStatus::Ok); + assert!( + seen.insert(status as i32), + "duplicate status code for {err:?}" + ); + } + // Detail string was recorded for the last variant. + assert!(take_last_error().is_some()); + assert!(take_last_error().is_none()); + } +} diff --git a/crates/marmot-c/src/subscriptions.rs b/crates/marmot-c/src/subscriptions.rs new file mode 100644 index 00000000..a065f87f --- /dev/null +++ b/crates/marmot-c/src/subscriptions.rs @@ -0,0 +1,1849 @@ +//! Opaque C handles over the `marmot-uniffi` subscription objects. +//! +//! Each `marmot_subscribe_*` function returns an opaque handle wrapping the +//! `Arc`'d uniffi subscription plus a handle to the client's tokio runtime. +//! Consumers drive it one of two ways: +//! +//! - **Blocking:** `marmot_*_subscription_next(sub, timeout_ms, out)` +//! blocks the calling thread. `timeout_ms == 0` waits indefinitely; a +//! nonzero timeout that elapses returns `MARMOT_STATUS_TIMEOUT` (out is +//! NULL). A closed stream (runtime shutdown / sender dropped) returns +//! `MARMOT_STATUS_CLOSED`; no further items will ever be produced. +//! - **Callbacks:** `marmot_*_subscription_set_callback(sub, cb, user_data)` +//! spawns a runtime task that invokes `cb` with a *borrowed* item pointer +//! valid only for the duration of the call, then a final NULL item when +//! the stream closes. Callbacks run on runtime worker threads, so `cb` and +//! any access to `user_data` must be thread-safe. +//! +//! **`user_data` lifetime — read carefully.** `clear_callback` and +//! `*_subscription_free` only *request* cancellation: internally they call +//! tokio's `JoinHandle::abort`, which is non-blocking. A callback already +//! executing on a worker thread keeps running and returns after the +//! clear/free call has returned; the abort only takes effect at the pump's +//! next await point. So clearing or freeing is **not** a synchronization +//! point — you must not free `user_data` (or invalidate anything the +//! callback touches) just because clear/free returned. `user_data` must +//! outlive every possible callback invocation. To reclaim it safely, use +//! your own synchronization: e.g. have the callback signal an atomic/latch +//! and observe the terminal NULL-item call (or a quiescent flag) before +//! freeing, or only free at process teardown. Making cancellation blocking +//! here would risk deadlock (the pump may be inside `cb`), so the wait is +//! left to the caller who knows the callback's own locking. +//! +//! Do not mix blocking reads and an installed callback on the same handle: +//! both compete for the same inner receiver and items go to whichever wins. +//! +//! Lifetime rule (documented in the header): free every subscription handle +//! before freeing the `MarmotClient` that created it. + +use std::ffi::c_void; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use std::time::Duration; + +use marmot_uniffi::subscriptions::{ + AgentStreamSubscription, ChatListSubscription, ChatsSubscription, EventsSubscription, + GroupStateSubscription, MessagesSubscription, NotificationsSubscription, + TimelineMessagesSubscription, +}; +use tokio::runtime::Handle; +use tokio::task::JoinHandle; + +use crate::MarmotStatus; +use crate::commands::deliver; +use crate::memory::{ + CFree, boxed, free_boxed, free_c_string, optional_str, owned_c_string, required_str, +}; +use crate::status::{set_last_error, status_from_error}; +use crate::types::agent_stream::MarmotAgentStreamUpdate; +use crate::types::chat_list::{ + MarmotChatListRow, MarmotChatListRowList, MarmotChatListSubscriptionUpdate, +}; +use crate::types::event::MarmotEvent; +use crate::types::group::{MarmotAppGroupRecord, MarmotAppGroupRecordList}; +use crate::types::message::{MarmotAppMessageRecordList, MarmotMessageUpdate}; +use crate::types::notification::MarmotNotificationUpdate; +use crate::types::timeline::{MarmotTimelinePage, MarmotTimelineSubscriptionUpdate}; +use crate::{MarmotClient, client_ref, ffi_guard, write_out}; + +/// `user_data` travels into a tokio task; the C caller owns its thread +/// safety (documented on every `set_callback`). +struct CallbackCtx { + user_data: *mut c_void, +} +unsafe impl Send for CallbackCtx {} + +impl CallbackCtx { + /// Accessor (rather than a direct field read) so the callback pump's + /// async block captures the whole `CallbackCtx` — which carries the + /// `Send` justification — instead of precise-capturing the bare + /// `*mut c_void` field, which is not `Send`. + fn user_data(&self) -> *mut c_void { + self.user_data + } +} + +// The callback pump moves each mirror item into a runtime worker task +// before handing C a borrowed pointer. Mirror types contain raw pointers +// only to allocations the value exclusively owns, so moving one across +// threads is sound. (`MarmotEvent` carries the same impl in its own +// module.) +unsafe impl Send for MarmotTimelinePage {} +unsafe impl Send for MarmotNotificationUpdate {} +unsafe impl Send for MarmotAppGroupRecord {} +unsafe impl Send for MarmotChatListRow {} +unsafe impl Send for MarmotMessageUpdate {} +unsafe impl Send for MarmotAgentStreamUpdate {} + +/// Shared body of every subscription handle: the runtime that drives it +/// and the slot holding an installed callback task. +struct SubscriptionCore { + runtime: Handle, + callback_task: StdMutex>>, +} + +impl SubscriptionCore { + fn new(runtime: Handle) -> Self { + Self { + runtime, + callback_task: StdMutex::new(None), + } + } + + /// Block on `fut` with the subscription timeout convention: + /// `Ok(Some(item))` on data, `Err(Timeout)` on elapse, `Ok(None)` on + /// closed stream. + fn block_next( + &self, + timeout_ms: u32, + fut: impl Future>, + ) -> Result, MarmotStatus> { + if timeout_ms == 0 { + return Ok(self.runtime.block_on(fut)); + } + let duration = Duration::from_millis(u64::from(timeout_ms)); + match self + .runtime + .block_on(async { tokio::time::timeout(duration, fut).await }) + { + Ok(item) => Ok(item), + Err(_elapsed) => Err(MarmotStatus::Timeout), + } + } + + /// Reserve the callback slot, then spawn the pump under the lock. Taking + /// the slot before spawning means a rejected second install never starts + /// a pump that races the receiver (and immediately gets aborted). The + /// `spawn` closure receives the runtime handle to spawn onto. + fn install(&self, spawn: impl FnOnce(&Handle) -> JoinHandle<()>) -> MarmotStatus { + let mut slot = self + .callback_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if slot.as_ref().is_some_and(|t| !t.is_finished()) { + set_last_error("a callback is already installed on this subscription"); + return MarmotStatus::Runtime; + } + *slot = Some(spawn(&self.runtime)); + MarmotStatus::Ok + } + + /// Cancel the callback task, if any, at its next await point. + fn clear(&self) { + let task = self + .callback_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(task) = task { + task.abort(); + } + } +} + +impl Drop for SubscriptionCore { + fn drop(&mut self) { + self.clear(); + } +} + +/// Deliver one blocking-next result through an out-pointer using the +/// OK / TIMEOUT / CLOSED convention. `out` receives NULL on both +/// non-item outcomes. +unsafe fn deliver_next( + result: Result, MarmotStatus>, + out: *mut *mut TMirror, +) -> MarmotStatus +where + TMirror: From + CFree, +{ + if out.is_null() { + set_last_error("out-pointer argument was NULL"); + return MarmotStatus::NullPointer; + } + match result { + Ok(Some(item)) => { + unsafe { out.write(boxed(TMirror::from(item))) }; + MarmotStatus::Ok + } + Ok(None) => { + unsafe { out.write(std::ptr::null_mut()) }; + MarmotStatus::Closed + } + Err(status) => { + unsafe { out.write(std::ptr::null_mut()) }; + status + } + } +} + +/// Spawn the callback pump: convert each item to its mirror, hand the C +/// callback a borrowed pointer, deep-free after it returns, and finish +/// with a NULL-item terminal call when the stream closes. +fn spawn_callback_pump( + runtime: &Handle, + ctx: CallbackCtx, + callback: unsafe extern "C" fn(item: *const TMirror, user_data: *mut c_void), + mut next: F, +) -> JoinHandle<()> +where + TFfi: Send + 'static, + TMirror: From + CFree + Send + 'static, + F: FnMut() -> Fut + Send + 'static, + Fut: Future> + Send, +{ + runtime.spawn(async move { + loop { + match next().await { + Some(item) => { + let mut mirror = TMirror::from(item); + // Borrowed for the duration of the call only. + unsafe { callback(&raw const mirror, ctx.user_data()) }; + unsafe { mirror.free_in_place() }; + } + None => { + unsafe { callback(std::ptr::null(), ctx.user_data()) }; + break; + } + } + } + }) +} + +// --------------------------------------------------------------------------- +// Events subscription (top-level firehose) +// --------------------------------------------------------------------------- + +/// Opaque handle to the top-level event firehose: one subscription, every +/// account, every event type. Broadcast lag is skipped silently — catch +/// back up via the per-account subscriptions. +pub struct MarmotEventsSubscription { + core: SubscriptionCore, + inner: Arc, +} + +/// Callback invoked with each event (borrowed; valid only during the +/// call) and finally with NULL when the stream closes. +pub type MarmotEventCallback = + Option; + +/// Subscribe to the event firehose. Free with +/// `marmot_events_subscription_free` (before freeing the client). +/// +/// # Safety +/// `client` must be a live handle; `out_sub` must be a valid pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_subscribe_events( + client: *const MarmotClient, + out_sub: *mut *mut MarmotEventsSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let client = match unsafe { client_ref(client) } { + Ok(c) => c, + Err(status) => return status, + }; + let handle = MarmotEventsSubscription { + core: SubscriptionCore::new(client.runtime.handle().clone()), + inner: client.marmot.subscribe_events(), + }; + let raw = boxed(handle); + match unsafe { write_out(out_sub, raw) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_plain(raw) }; + status + } + } + }) +} + +/// Block until the next event, the timeout, or stream close. +/// `timeout_ms == 0` waits indefinitely. Returns `MARMOT_STATUS_OK` (out +/// set; free with `marmot_event_free`), `MARMOT_STATUS_TIMEOUT`, or +/// `MARMOT_STATUS_CLOSED` (out NULL for both). +/// +/// # Safety +/// `sub` must be a live handle from `marmot_subscribe_events`; +/// `out_event` must be a valid pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_events_subscription_next( + sub: *const MarmotEventsSubscription, + timeout_ms: u32, + out_event: *mut *mut MarmotEvent, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let inner = sub.inner.clone(); + let result = sub.core.block_next(timeout_ms, inner.next()); + unsafe { deliver_next(result, out_event) } + }) +} + +/// Install a callback pump for this subscription. `callback` runs on a +/// runtime worker thread with a borrowed event pointer (valid only during +/// the call; do not store or free it) and a final NULL event on close. +/// `callback` and `user_data` access must be thread-safe. Fails if a +/// callback is already installed. +/// +/// # Safety +/// `sub` must be a live handle; `callback` must be a valid function +/// pointer. `user_data` must outlive every callback invocation. Note that +/// `clear_callback` / `*_subscription_free` request cancellation without +/// waiting (tokio `abort` is non-blocking), so a callback can still be +/// running after they return — do not free `user_data` until your own +/// synchronization proves no callback is in flight (see the module docs). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_events_subscription_set_callback( + sub: *const MarmotEventsSubscription, + callback: MarmotEventCallback, + user_data: *mut c_void, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let Some(callback) = callback else { + set_last_error("callback function pointer was NULL"); + return MarmotStatus::NullPointer; + }; + let inner = sub.inner.clone(); + sub.core.install(|runtime| { + spawn_callback_pump(runtime, CallbackCtx { user_data }, callback, move || { + let inner = inner.clone(); + async move { inner.next().await } + }) + }) + }) +} + +/// Request cancellation of this subscription's callback pump, if any. This +/// does not wait: a callback already running on a worker thread keeps +/// executing and returns after this call does (tokio `abort` is +/// non-blocking). It is not a synchronization point for freeing +/// `user_data` — see the module docs. +/// +/// # Safety +/// `sub` must be a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_events_subscription_clear_callback( + sub: *const MarmotEventsSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + sub.core.clear(); + MarmotStatus::Ok + }) +} + +/// Free the subscription handle. Requests callback-pump cancellation +/// without waiting (see the module docs: a callback may still be running +/// after this returns, so do not free `user_data` here on that basis). +/// NULL is a no-op. Free every handle before the client that created it. +/// +/// # Safety +/// `sub` must be NULL or an unfreed pointer from +/// `marmot_subscribe_events`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_events_subscription_free(sub: *mut MarmotEventsSubscription) { + let _ = ffi_guard(|| { + unsafe { free_plain(sub) }; + MarmotStatus::Ok + }); +} + +// --------------------------------------------------------------------------- +// Timeline messages subscription (snapshot + next + next_update + paginate) +// --------------------------------------------------------------------------- + +/// Opaque handle to one conversation's materialized timeline window. +/// `next` returns the full authoritative window after each update; +/// `next_update` returns the raw delta; pagination extends the window +/// without blocking a concurrent `next`. +pub struct MarmotTimelineSubscription { + core: SubscriptionCore, + inner: Arc, +} + +/// Callback invoked with each full timeline window (borrowed) and a final +/// NULL page on close. +pub type MarmotTimelinePageCallback = + Option; + +/// Subscribe to live materialized timeline updates for a group +/// (`group_id_hex` non-NULL) or the account-wide tail (NULL). `has_limit` +/// plus `limit` cap the initial window. Free with +/// `marmot_timeline_subscription_free`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `group_id_hex` NULL or a valid string; `out_sub` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_subscribe_timeline_messages( + client: *const MarmotClient, + account_ref: *const std::ffi::c_char, + group_id_hex: *const std::ffi::c_char, + has_limit: bool, + limit: u32, + out_sub: *mut *mut MarmotTimelineSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let client = match unsafe { client_ref(client) } { + Ok(c) => c, + Err(status) => return status, + }; + let account_ref = match unsafe { required_str(account_ref) } { + Ok(v) => v, + Err(status) => return status, + }; + let group_id_hex = match unsafe { optional_str(group_id_hex) } { + Ok(v) => v, + Err(status) => return status, + }; + let limit = has_limit.then_some(limit); + match client.block_on(client.marmot.subscribe_timeline_messages( + account_ref, + group_id_hex, + limit, + )) { + Ok(inner) => { + let handle = MarmotTimelineSubscription { + core: SubscriptionCore::new(client.runtime.handle().clone()), + inner, + }; + let raw = boxed(handle); + match unsafe { write_out(out_sub, raw) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_plain(raw) }; + status + } + } + } + Err(err) => status_from_error(&err), + } + }) +} + +/// Take the initial window snapshot. Yields the page exactly once: later +/// calls (or a snapshot already consumed by this handle) write NULL with +/// `MARMOT_STATUS_OK`. Free the page with `marmot_timeline_page_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_page` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_timeline_subscription_snapshot( + sub: *const MarmotTimelineSubscription, + out_page: *mut *mut MarmotTimelinePage, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let page = sub + .inner + .snapshot() + .map_or(std::ptr::null_mut(), |p| boxed(MarmotTimelinePage::from(p))); + match unsafe { write_out(out_page, page) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_boxed(page) }; + status + } + } + }) +} + +/// Block until the next live update and return the resulting full window +/// (already sorted, deduplicated, and capped — render directly). Same +/// OK / TIMEOUT / CLOSED convention as every subscription `next`. Free +/// with `marmot_timeline_page_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_page` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_timeline_subscription_next( + sub: *const MarmotTimelineSubscription, + timeout_ms: u32, + out_page: *mut *mut MarmotTimelinePage, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let inner = sub.inner.clone(); + let result = sub.core.block_next(timeout_ms, inner.next()); + unsafe { deliver_next(result, out_page) } + }) +} + +/// Block until the next raw delta (page replacement or projection +/// update). Free with `marmot_timeline_subscription_update_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_update` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_timeline_subscription_next_update( + sub: *const MarmotTimelineSubscription, + timeout_ms: u32, + out_update: *mut *mut MarmotTimelineSubscriptionUpdate, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let inner = sub.inner.clone(); + let result = sub.core.block_next(timeout_ms, inner.next_update()); + unsafe { deliver_next(result, out_update) } + }) +} + +/// Extend the window toward older history by up to `count` messages and +/// return the new window. Runs on the runtime off the caller's lock, so a +/// concurrent blocking `next` on another thread is not blocked. Free with +/// `marmot_timeline_page_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_page` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_timeline_subscription_paginate_backwards( + sub: *const MarmotTimelineSubscription, + count: u32, + out_page: *mut *mut MarmotTimelinePage, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let inner = sub.inner.clone(); + let result = sub + .core + .runtime + .block_on(async move { inner.paginate_backwards(count).await }); + unsafe { deliver(result, out_page) } + }) +} + +/// Extend the window toward the live head by up to `count` messages and +/// return the new window. Reaching the head re-anchors the window. Free +/// with `marmot_timeline_page_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_page` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_timeline_subscription_paginate_forwards( + sub: *const MarmotTimelineSubscription, + count: u32, + out_page: *mut *mut MarmotTimelinePage, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let inner = sub.inner.clone(); + let result = sub + .core + .runtime + .block_on(async move { inner.paginate_forwards(count).await }); + unsafe { deliver(result, out_page) } + }) +} + +/// Install a full-window callback pump (each call receives the borrowed +/// authoritative window; final NULL page on close). Same rules as +/// `marmot_events_subscription_set_callback`. +/// +/// # Safety +/// Same as `marmot_events_subscription_set_callback`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_timeline_subscription_set_callback( + sub: *const MarmotTimelineSubscription, + callback: MarmotTimelinePageCallback, + user_data: *mut c_void, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let Some(callback) = callback else { + set_last_error("callback function pointer was NULL"); + return MarmotStatus::NullPointer; + }; + let inner = sub.inner.clone(); + sub.core.install(|runtime| { + spawn_callback_pump(runtime, CallbackCtx { user_data }, callback, move || { + let inner = inner.clone(); + async move { inner.next().await } + }) + }) + }) +} + +/// Cancel this subscription's callback pump, if any. +/// +/// # Safety +/// `sub` must be a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_timeline_subscription_clear_callback( + sub: *const MarmotTimelineSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + sub.core.clear(); + MarmotStatus::Ok + }) +} + +/// Free the subscription handle. Requests callback-pump cancellation +/// without waiting (see the module docs: a callback may still be running +/// after this returns, so do not free `user_data` here on that basis). +/// NULL is a no-op. Free every handle before the client that created it. +/// +/// # Safety +/// `sub` must be NULL or an unfreed pointer from +/// `marmot_subscribe_timeline_messages`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_timeline_subscription_free(sub: *mut MarmotTimelineSubscription) { + let _ = ffi_guard(|| { + unsafe { free_plain(sub) }; + MarmotStatus::Ok + }); +} + +// --------------------------------------------------------------------------- +// Notifications subscription +// --------------------------------------------------------------------------- + +/// Opaque handle to the notification pipeline: local-notification updates +/// produced by the runtime (foreground receipt, background collection, …). +pub struct MarmotNotificationsSubscription { + core: SubscriptionCore, + inner: Arc, +} + +/// Callback invoked with each notification update (borrowed; valid only +/// during the call) and finally with NULL when the stream closes. +pub type MarmotNotificationUpdateCallback = + Option; + +/// Subscribe to notification updates. Free with +/// `marmot_notifications_subscription_free` (before freeing the client). +/// +/// # Safety +/// `client` must be a live handle; `out_sub` must be a valid pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_subscribe_notifications( + client: *const MarmotClient, + out_sub: *mut *mut MarmotNotificationsSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let client = match unsafe { client_ref(client) } { + Ok(c) => c, + Err(status) => return status, + }; + match client.block_on(client.marmot.subscribe_notifications()) { + Ok(inner) => { + let handle = MarmotNotificationsSubscription { + core: SubscriptionCore::new(client.runtime.handle().clone()), + inner, + }; + let raw = boxed(handle); + match unsafe { write_out(out_sub, raw) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_plain(raw) }; + status + } + } + } + Err(err) => status_from_error(&err), + } + }) +} + +/// Block until the next notification update, the timeout, or stream +/// close. `timeout_ms == 0` waits indefinitely. Returns +/// `MARMOT_STATUS_OK` (out set; free with +/// `marmot_notification_update_free`), `MARMOT_STATUS_TIMEOUT`, or +/// `MARMOT_STATUS_CLOSED` (out NULL for both). +/// +/// # Safety +/// `sub` must be a live handle from `marmot_subscribe_notifications`; +/// `out_update` must be a valid pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_notifications_subscription_next( + sub: *const MarmotNotificationsSubscription, + timeout_ms: u32, + out_update: *mut *mut MarmotNotificationUpdate, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let inner = sub.inner.clone(); + let result = sub.core.block_next(timeout_ms, inner.next()); + unsafe { deliver_next(result, out_update) } + }) +} + +/// Install a callback pump for this subscription. Same rules as +/// `marmot_events_subscription_set_callback`: borrowed item pointer, +/// runtime worker thread, final NULL on close, one callback at a time. +/// +/// # Safety +/// Same as `marmot_events_subscription_set_callback`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_notifications_subscription_set_callback( + sub: *const MarmotNotificationsSubscription, + callback: MarmotNotificationUpdateCallback, + user_data: *mut c_void, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let Some(callback) = callback else { + set_last_error("callback function pointer was NULL"); + return MarmotStatus::NullPointer; + }; + let inner = sub.inner.clone(); + sub.core.install(|runtime| { + spawn_callback_pump(runtime, CallbackCtx { user_data }, callback, move || { + let inner = inner.clone(); + async move { inner.next().await } + }) + }) + }) +} + +/// Cancel this subscription's callback pump, if any. +/// +/// # Safety +/// `sub` must be a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_notifications_subscription_clear_callback( + sub: *const MarmotNotificationsSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + sub.core.clear(); + MarmotStatus::Ok + }) +} + +/// Free the subscription handle. Requests callback-pump cancellation +/// without waiting (see the module docs: a callback may still be running +/// after this returns, so do not free `user_data` here on that basis). +/// NULL is a no-op. Free every handle before the client that created it. +/// +/// # Safety +/// `sub` must be NULL or an unfreed pointer from +/// `marmot_subscribe_notifications`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_notifications_subscription_free( + sub: *mut MarmotNotificationsSubscription, +) { + let _ = ffi_guard(|| { + unsafe { free_plain(sub) }; + MarmotStatus::Ok + }); +} + +// --------------------------------------------------------------------------- +// Chats subscription (per-account group projections) +// --------------------------------------------------------------------------- + +/// Opaque handle to one account's chats list: an initial snapshot of every +/// group projection, then one record per projection change. +pub struct MarmotChatsSubscription { + core: SubscriptionCore, + inner: Arc, +} + +/// Callback invoked with each group record (borrowed; valid only during +/// the call) and finally with NULL when the stream closes. Shared by the +/// chats and group-state subscriptions. +pub type MarmotAppGroupRecordCallback = + Option; + +/// Subscribe to one account's chats list. Emits whenever a group's +/// projection changes; `include_archived` widens the filter. Free with +/// `marmot_chats_subscription_free`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_sub` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_subscribe_chats( + client: *const MarmotClient, + account_ref: *const std::ffi::c_char, + include_archived: bool, + out_sub: *mut *mut MarmotChatsSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let client = match unsafe { client_ref(client) } { + Ok(c) => c, + Err(status) => return status, + }; + let account_ref = match unsafe { required_str(account_ref) } { + Ok(v) => v, + Err(status) => return status, + }; + match client.block_on(client.marmot.subscribe_chats(account_ref, include_archived)) { + Ok(inner) => { + let handle = MarmotChatsSubscription { + core: SubscriptionCore::new(client.runtime.handle().clone()), + inner, + }; + let raw = boxed(handle); + match unsafe { write_out(out_sub, raw) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_plain(raw) }; + status + } + } + } + Err(err) => status_from_error(&err), + } + }) +} + +/// Take the initial chats snapshot. Yields the populated list exactly +/// once: later calls write an EMPTY list, still with `MARMOT_STATUS_OK`. +/// Free the list with `marmot_app_group_record_list_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_list` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chats_subscription_snapshot( + sub: *const MarmotChatsSubscription, + out_list: *mut *mut MarmotAppGroupRecordList, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let list = boxed(MarmotAppGroupRecordList::from(sub.inner.snapshot())); + match unsafe { write_out(out_list, list) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_boxed(list) }; + status + } + } + }) +} + +/// Block until the next changed group record, the timeout, or stream +/// close. Same OK / TIMEOUT / CLOSED convention as every subscription +/// `next`. Free with `marmot_app_group_record_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_record` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chats_subscription_next( + sub: *const MarmotChatsSubscription, + timeout_ms: u32, + out_record: *mut *mut MarmotAppGroupRecord, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let inner = sub.inner.clone(); + let result = sub.core.block_next(timeout_ms, inner.next()); + unsafe { deliver_next(result, out_record) } + }) +} + +/// Install a callback pump for this subscription. Same rules as +/// `marmot_events_subscription_set_callback`. +/// +/// # Safety +/// Same as `marmot_events_subscription_set_callback`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chats_subscription_set_callback( + sub: *const MarmotChatsSubscription, + callback: MarmotAppGroupRecordCallback, + user_data: *mut c_void, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let Some(callback) = callback else { + set_last_error("callback function pointer was NULL"); + return MarmotStatus::NullPointer; + }; + let inner = sub.inner.clone(); + sub.core.install(|runtime| { + spawn_callback_pump(runtime, CallbackCtx { user_data }, callback, move || { + let inner = inner.clone(); + async move { inner.next().await } + }) + }) + }) +} + +/// Cancel this subscription's callback pump, if any. +/// +/// # Safety +/// `sub` must be a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chats_subscription_clear_callback( + sub: *const MarmotChatsSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + sub.core.clear(); + MarmotStatus::Ok + }) +} + +/// Free the subscription handle. Requests callback-pump cancellation +/// without waiting (see the module docs: a callback may still be running +/// after this returns, so do not free `user_data` here on that basis). +/// NULL is a no-op. Free every handle before the client that created it. +/// +/// # Safety +/// `sub` must be NULL or an unfreed pointer from +/// `marmot_subscribe_chats`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chats_subscription_free(sub: *mut MarmotChatsSubscription) { + let _ = ffi_guard(|| { + unsafe { free_plain(sub) }; + MarmotStatus::Ok + }); +} + +// --------------------------------------------------------------------------- +// Chat-list subscription (durable chat-list projection) +// --------------------------------------------------------------------------- + +/// Opaque handle to one account's durable chat-list projection: an initial +/// row snapshot, then row upserts (`next`) or raw deltas including row +/// removals (`next_update`). +pub struct MarmotChatListSubscription { + core: SubscriptionCore, + inner: Arc, +} + +/// Callback invoked with each upserted chat-list row (borrowed; valid only +/// during the call) and finally with NULL when the stream closes. Row +/// removals are skipped — use `marmot_chat_list_subscription_next_update` +/// polling to observe them. +pub type MarmotChatListRowCallback = + Option; + +/// Subscribe to one account's durable chat-list projection. Free with +/// `marmot_chat_list_subscription_free`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `out_sub` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_subscribe_chat_list( + client: *const MarmotClient, + account_ref: *const std::ffi::c_char, + include_archived: bool, + out_sub: *mut *mut MarmotChatListSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let client = match unsafe { client_ref(client) } { + Ok(c) => c, + Err(status) => return status, + }; + let account_ref = match unsafe { required_str(account_ref) } { + Ok(v) => v, + Err(status) => return status, + }; + match client.block_on( + client + .marmot + .subscribe_chat_list(account_ref, include_archived), + ) { + Ok(inner) => { + let handle = MarmotChatListSubscription { + core: SubscriptionCore::new(client.runtime.handle().clone()), + inner, + }; + let raw = boxed(handle); + match unsafe { write_out(out_sub, raw) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_plain(raw) }; + status + } + } + } + Err(err) => status_from_error(&err), + } + }) +} + +/// Take the initial chat-list snapshot. Yields the populated list exactly +/// once: later calls write an EMPTY list, still with `MARMOT_STATUS_OK`. +/// Free the list with `marmot_chat_list_row_list_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_list` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chat_list_subscription_snapshot( + sub: *const MarmotChatListSubscription, + out_list: *mut *mut MarmotChatListRowList, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let list = boxed(MarmotChatListRowList::from(sub.inner.snapshot())); + match unsafe { write_out(out_list, list) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_boxed(list) }; + status + } + } + }) +} + +/// Block until the next upserted row, the timeout, or stream close. +/// Row-removal deltas are skipped internally — receive them via +/// `marmot_chat_list_subscription_next_update` instead. Free with +/// `marmot_chat_list_row_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_row` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chat_list_subscription_next( + sub: *const MarmotChatListSubscription, + timeout_ms: u32, + out_row: *mut *mut MarmotChatListRow, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let inner = sub.inner.clone(); + let result = sub.core.block_next(timeout_ms, inner.next()); + unsafe { deliver_next(result, out_row) } + }) +} + +/// Block until the next raw chat-list delta (row upsert or removal). Free +/// with `marmot_chat_list_subscription_update_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_update` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chat_list_subscription_next_update( + sub: *const MarmotChatListSubscription, + timeout_ms: u32, + out_update: *mut *mut MarmotChatListSubscriptionUpdate, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let inner = sub.inner.clone(); + let result = sub.core.block_next(timeout_ms, inner.next_update()); + unsafe { deliver_next(result, out_update) } + }) +} + +/// Install an upserted-row callback pump for this subscription. Same +/// rules as `marmot_events_subscription_set_callback`. +/// +/// # Safety +/// Same as `marmot_events_subscription_set_callback`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chat_list_subscription_set_callback( + sub: *const MarmotChatListSubscription, + callback: MarmotChatListRowCallback, + user_data: *mut c_void, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let Some(callback) = callback else { + set_last_error("callback function pointer was NULL"); + return MarmotStatus::NullPointer; + }; + let inner = sub.inner.clone(); + sub.core.install(|runtime| { + spawn_callback_pump(runtime, CallbackCtx { user_data }, callback, move || { + let inner = inner.clone(); + async move { inner.next().await } + }) + }) + }) +} + +/// Cancel this subscription's callback pump, if any. +/// +/// # Safety +/// `sub` must be a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chat_list_subscription_clear_callback( + sub: *const MarmotChatListSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + sub.core.clear(); + MarmotStatus::Ok + }) +} + +/// Free the subscription handle. Requests callback-pump cancellation +/// without waiting (see the module docs: a callback may still be running +/// after this returns, so do not free `user_data` here on that basis). +/// NULL is a no-op. Free every handle before the client that created it. +/// +/// # Safety +/// `sub` must be NULL or an unfreed pointer from +/// `marmot_subscribe_chat_list`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chat_list_subscription_free(sub: *mut MarmotChatListSubscription) { + let _ = ffi_guard(|| { + unsafe { free_plain(sub) }; + MarmotStatus::Ok + }); +} + +// --------------------------------------------------------------------------- +// Messages subscription (per-group or account-wide) +// --------------------------------------------------------------------------- + +/// Opaque handle to a message stream: an initial record snapshot, then one +/// message update per store change. +pub struct MarmotMessagesSubscription { + core: SubscriptionCore, + inner: Arc, +} + +/// Callback invoked with each message update (borrowed; valid only during +/// the call) and finally with NULL when the stream closes. +pub type MarmotMessageUpdateCallback = + Option; + +/// Subscribe to messages for a specific group (`group_id_hex` non-NULL) +/// or every message across the account (NULL). `has_limit` + `limit` cap +/// the initial snapshot to the latest N rows; live updates continue after +/// the snapshot. Free with `marmot_messages_subscription_free`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` a valid string; +/// `group_id_hex` NULL or a valid string; `out_sub` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_subscribe_messages( + client: *const MarmotClient, + account_ref: *const std::ffi::c_char, + group_id_hex: *const std::ffi::c_char, + has_limit: bool, + limit: u32, + out_sub: *mut *mut MarmotMessagesSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let client = match unsafe { client_ref(client) } { + Ok(c) => c, + Err(status) => return status, + }; + let account_ref = match unsafe { required_str(account_ref) } { + Ok(v) => v, + Err(status) => return status, + }; + let group_id_hex = match unsafe { optional_str(group_id_hex) } { + Ok(v) => v, + Err(status) => return status, + }; + let limit = has_limit.then_some(limit); + match client.block_on( + client + .marmot + .subscribe_messages(account_ref, group_id_hex, limit), + ) { + Ok(inner) => { + let handle = MarmotMessagesSubscription { + core: SubscriptionCore::new(client.runtime.handle().clone()), + inner, + }; + let raw = boxed(handle); + match unsafe { write_out(out_sub, raw) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_plain(raw) }; + status + } + } + } + Err(err) => status_from_error(&err), + } + }) +} + +/// Take the initial message-record snapshot. Yields the populated list +/// exactly once: later calls write an EMPTY list, still with +/// `MARMOT_STATUS_OK`. Free the list with +/// `marmot_app_message_record_list_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_list` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_messages_subscription_snapshot( + sub: *const MarmotMessagesSubscription, + out_list: *mut *mut MarmotAppMessageRecordList, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let list = boxed(MarmotAppMessageRecordList::from(sub.inner.snapshot())); + match unsafe { write_out(out_list, list) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_boxed(list) }; + status + } + } + }) +} + +/// Block until the next message update, the timeout, or stream close. +/// Same OK / TIMEOUT / CLOSED convention as every subscription `next`. +/// Free with `marmot_message_update_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_update` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_messages_subscription_next( + sub: *const MarmotMessagesSubscription, + timeout_ms: u32, + out_update: *mut *mut MarmotMessageUpdate, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let inner = sub.inner.clone(); + let result = sub.core.block_next(timeout_ms, inner.next()); + unsafe { deliver_next(result, out_update) } + }) +} + +/// Install a callback pump for this subscription. Same rules as +/// `marmot_events_subscription_set_callback`. +/// +/// # Safety +/// Same as `marmot_events_subscription_set_callback`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_messages_subscription_set_callback( + sub: *const MarmotMessagesSubscription, + callback: MarmotMessageUpdateCallback, + user_data: *mut c_void, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let Some(callback) = callback else { + set_last_error("callback function pointer was NULL"); + return MarmotStatus::NullPointer; + }; + let inner = sub.inner.clone(); + sub.core.install(|runtime| { + spawn_callback_pump(runtime, CallbackCtx { user_data }, callback, move || { + let inner = inner.clone(); + async move { inner.next().await } + }) + }) + }) +} + +/// Cancel this subscription's callback pump, if any. +/// +/// # Safety +/// `sub` must be a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_messages_subscription_clear_callback( + sub: *const MarmotMessagesSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + sub.core.clear(); + MarmotStatus::Ok + }) +} + +/// Free the subscription handle. Requests callback-pump cancellation +/// without waiting (see the module docs: a callback may still be running +/// after this returns, so do not free `user_data` here on that basis). +/// NULL is a no-op. Free every handle before the client that created it. +/// +/// # Safety +/// `sub` must be NULL or an unfreed pointer from +/// `marmot_subscribe_messages`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_messages_subscription_free(sub: *mut MarmotMessagesSubscription) { + let _ = ffi_guard(|| { + unsafe { free_plain(sub) }; + MarmotStatus::Ok + }); +} + +// --------------------------------------------------------------------------- +// Group-state subscription (one group's roster/profile/member changes) +// --------------------------------------------------------------------------- + +/// Opaque handle to one group's state: an initial record snapshot, then +/// the full record after each member/profile/roster change. +pub struct MarmotGroupStateSubscription { + core: SubscriptionCore, + inner: Arc, +} + +/// Subscribe to member/profile/roster changes for one group. Free with +/// `marmot_group_state_subscription_free`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `out_sub` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_subscribe_group_state( + client: *const MarmotClient, + account_ref: *const std::ffi::c_char, + group_id_hex: *const std::ffi::c_char, + out_sub: *mut *mut MarmotGroupStateSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let client = match unsafe { client_ref(client) } { + Ok(c) => c, + Err(status) => return status, + }; + let account_ref = match unsafe { required_str(account_ref) } { + Ok(v) => v, + Err(status) => return status, + }; + let group_id_hex = match unsafe { required_str(group_id_hex) } { + Ok(v) => v, + Err(status) => return status, + }; + match client.block_on( + client + .marmot + .subscribe_group_state(account_ref, group_id_hex), + ) { + Ok(inner) => { + let handle = MarmotGroupStateSubscription { + core: SubscriptionCore::new(client.runtime.handle().clone()), + inner, + }; + let raw = boxed(handle); + match unsafe { write_out(out_sub, raw) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_plain(raw) }; + status + } + } + } + Err(err) => status_from_error(&err), + } + }) +} + +/// Take the initial group-record snapshot. Yields the record exactly +/// once: later calls (or a snapshot already consumed by this handle) +/// write NULL with `MARMOT_STATUS_OK`. Free the record with +/// `marmot_app_group_record_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_record` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_state_subscription_snapshot( + sub: *const MarmotGroupStateSubscription, + out_record: *mut *mut MarmotAppGroupRecord, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let record = sub.inner.snapshot().map_or(std::ptr::null_mut(), |r| { + boxed(MarmotAppGroupRecord::from(r)) + }); + match unsafe { write_out(out_record, record) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_boxed(record) }; + status + } + } + }) +} + +/// Block until the group's next full record, the timeout, or stream +/// close. Same OK / TIMEOUT / CLOSED convention as every subscription +/// `next`. Free with `marmot_app_group_record_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_record` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_state_subscription_next( + sub: *const MarmotGroupStateSubscription, + timeout_ms: u32, + out_record: *mut *mut MarmotAppGroupRecord, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let inner = sub.inner.clone(); + let result = sub.core.block_next(timeout_ms, inner.next()); + unsafe { deliver_next(result, out_record) } + }) +} + +/// Install a callback pump for this subscription. Same rules as +/// `marmot_events_subscription_set_callback`. +/// +/// # Safety +/// Same as `marmot_events_subscription_set_callback`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_state_subscription_set_callback( + sub: *const MarmotGroupStateSubscription, + callback: MarmotAppGroupRecordCallback, + user_data: *mut c_void, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let Some(callback) = callback else { + set_last_error("callback function pointer was NULL"); + return MarmotStatus::NullPointer; + }; + let inner = sub.inner.clone(); + sub.core.install(|runtime| { + spawn_callback_pump(runtime, CallbackCtx { user_data }, callback, move || { + let inner = inner.clone(); + async move { inner.next().await } + }) + }) + }) +} + +/// Cancel this subscription's callback pump, if any. +/// +/// # Safety +/// `sub` must be a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_state_subscription_clear_callback( + sub: *const MarmotGroupStateSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + sub.core.clear(); + MarmotStatus::Ok + }) +} + +/// Free the subscription handle. Requests callback-pump cancellation +/// without waiting (see the module docs: a callback may still be running +/// after this returns, so do not free `user_data` here on that basis). +/// NULL is a no-op. Free every handle before the client that created it. +/// +/// # Safety +/// `sub` must be NULL or an unfreed pointer from +/// `marmot_subscribe_group_state`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_state_subscription_free( + sub: *mut MarmotGroupStateSubscription, +) { + let _ = ffi_guard(|| { + unsafe { free_plain(sub) }; + MarmotStatus::Ok + }); +} + +// --------------------------------------------------------------------------- +// Agent stream subscription (live brokered QUIC text stream watch) +// --------------------------------------------------------------------------- + +/// Opaque handle to a live agent-text-stream watch: incremental `Chunk`s, +/// then a terminal `Finished` / `Failed`, after which the stream closes. +/// Created by `marmot_watch_agent_text_stream`; the matching anchor/start +/// command is `marmot_start_agent_text_stream` in the commands layer. +pub struct MarmotAgentStreamSubscription { + core: SubscriptionCore, + inner: Arc, +} + +/// Callback invoked with each agent-stream update (borrowed; valid only +/// during the call) and finally with NULL when the stream closes. +pub type MarmotAgentStreamUpdateCallback = + Option; + +/// Watch a live agent text stream over the brokered QUIC channel. Pass +/// `stream_id_hex = NULL` to follow the latest stream in the group (the +/// common case when reacting to an AgentStreamStarted event). +/// `server_cert_der` (+ `server_cert_der_len`) pins a self-signed broker +/// certificate; pass NULL with length 0 to use platform trust. The bytes +/// are copied — the caller keeps ownership. `insecure_local` is +/// loopback-only for testing. Free with +/// `marmot_agent_stream_subscription_free`. +/// +/// # Safety +/// `client` must be a live handle; `account_ref` and `group_id_hex` valid +/// strings; `stream_id_hex` NULL or a valid string; `server_cert_der` +/// NULL with length 0, or a pointer to `server_cert_der_len` valid bytes; +/// `out_sub` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_watch_agent_text_stream( + client: *const MarmotClient, + account_ref: *const std::ffi::c_char, + group_id_hex: *const std::ffi::c_char, + stream_id_hex: *const std::ffi::c_char, + server_cert_der: *const u8, + server_cert_der_len: usize, + insecure_local: bool, + out_sub: *mut *mut MarmotAgentStreamSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let client = match unsafe { client_ref(client) } { + Ok(c) => c, + Err(status) => return status, + }; + let account_ref = match unsafe { required_str(account_ref) } { + Ok(v) => v, + Err(status) => return status, + }; + let group_id_hex = match unsafe { required_str(group_id_hex) } { + Ok(v) => v, + Err(status) => return status, + }; + let stream_id_hex = match unsafe { optional_str(stream_id_hex) } { + Ok(v) => v, + Err(status) => return status, + }; + let server_cert_der = if server_cert_der.is_null() { + if server_cert_der_len != 0 { + set_last_error("server_cert_der was NULL with nonzero length"); + return MarmotStatus::NullPointer; + } + None + } else { + Some( + unsafe { std::slice::from_raw_parts(server_cert_der, server_cert_der_len) } + .to_vec(), + ) + }; + match client.block_on(client.marmot.watch_agent_text_stream( + account_ref, + group_id_hex, + stream_id_hex, + server_cert_der, + insecure_local, + )) { + Ok(inner) => { + let handle = MarmotAgentStreamSubscription { + core: SubscriptionCore::new(client.runtime.handle().clone()), + inner, + }; + let raw = boxed(handle); + match unsafe { write_out(out_sub, raw) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_plain(raw) }; + status + } + } + } + Err(err) => status_from_error(&err), + } + }) +} + +/// The resolved stream id this watch is following (hex). Writes an owned +/// copy: free it with `marmot_string_free`. +/// +/// # Safety +/// `sub` must be a live handle from `marmot_watch_agent_text_stream`; +/// `out_stream_id_hex` must be a valid pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_agent_stream_subscription_stream_id_hex( + sub: *const MarmotAgentStreamSubscription, + out_stream_id_hex: *mut *mut std::ffi::c_char, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let ptr = owned_c_string(sub.inner.stream_id_hex()); + match unsafe { write_out(out_stream_id_hex, ptr) } { + Ok(()) => MarmotStatus::Ok, + Err(status) => { + unsafe { free_c_string(ptr) }; + status + } + } + }) +} + +/// Block until the next agent-stream update, the timeout, or stream +/// close. `Chunk`s arrive incrementally; a terminal `Finished` / `Failed` +/// precedes `MARMOT_STATUS_CLOSED`. Free with +/// `marmot_agent_stream_update_free`. +/// +/// # Safety +/// `sub` must be a live handle; `out_update` valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_agent_stream_subscription_next( + sub: *const MarmotAgentStreamSubscription, + timeout_ms: u32, + out_update: *mut *mut MarmotAgentStreamUpdate, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let inner = sub.inner.clone(); + let result = sub.core.block_next(timeout_ms, inner.next()); + unsafe { deliver_next(result, out_update) } + }) +} + +/// Install a callback pump for this subscription. Same rules as +/// `marmot_events_subscription_set_callback`. +/// +/// # Safety +/// Same as `marmot_events_subscription_set_callback`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_agent_stream_subscription_set_callback( + sub: *const MarmotAgentStreamSubscription, + callback: MarmotAgentStreamUpdateCallback, + user_data: *mut c_void, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + let Some(callback) = callback else { + set_last_error("callback function pointer was NULL"); + return MarmotStatus::NullPointer; + }; + let inner = sub.inner.clone(); + sub.core.install(|runtime| { + spawn_callback_pump(runtime, CallbackCtx { user_data }, callback, move || { + let inner = inner.clone(); + async move { inner.next().await } + }) + }) + }) +} + +/// Cancel this subscription's callback pump, if any. +/// +/// # Safety +/// `sub` must be a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_agent_stream_subscription_clear_callback( + sub: *const MarmotAgentStreamSubscription, +) -> MarmotStatus { + ffi_guard(|| { + let sub = match unsafe { sub_ref(sub) } { + Ok(s) => s, + Err(status) => return status, + }; + sub.core.clear(); + MarmotStatus::Ok + }) +} + +/// Free the subscription handle. Requests callback-pump cancellation +/// without waiting (see the module docs: a callback may still be running +/// after this returns, so do not free `user_data` here on that basis). +/// NULL is a no-op. Free every handle before the client that created it. +/// +/// # Safety +/// `sub` must be NULL or an unfreed pointer from +/// `marmot_watch_agent_text_stream`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_agent_stream_subscription_free( + sub: *mut MarmotAgentStreamSubscription, +) { + let _ = ffi_guard(|| { + unsafe { free_plain(sub) }; + MarmotStatus::Ok + }); +} + +// --------------------------------------------------------------------------- +// Shared plumbing +// --------------------------------------------------------------------------- + +/// Borrow-check a subscription handle argument. +pub(crate) unsafe fn sub_ref<'a, T>(sub: *const T) -> Result<&'a T, MarmotStatus> { + if sub.is_null() { + set_last_error("subscription handle was NULL"); + return Err(MarmotStatus::NullPointer); + } + Ok(unsafe { &*sub }) +} + +/// Free a handle allocated with `memory::boxed` whose type has no +/// `CFree` (opaque handles drop their contents via `Drop`). +pub(crate) unsafe fn free_plain(ptr: *mut T) { + if ptr.is_null() { + return; + } + #[cfg(feature = "alloc-audit")] + crate::memory::audit::on_free(); + drop(unsafe { Box::from_raw(ptr) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + + fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .expect("build test runtime") + } + + #[test] + fn block_next_returns_data() { + let rt = runtime(); + let core = SubscriptionCore::new(rt.handle().clone()); + assert_eq!(core.block_next(0, async { Some(42u32) }), Ok(Some(42))); + } + + #[test] + fn block_next_reports_closed_stream() { + let rt = runtime(); + let core = SubscriptionCore::new(rt.handle().clone()); + let result: Result, _> = core.block_next(0, async { None }); + assert_eq!(result, Ok(None)); + } + + #[test] + fn block_next_times_out() { + let rt = runtime(); + let core = SubscriptionCore::new(rt.handle().clone()); + let result: Result, _> = core.block_next(10, std::future::pending()); + assert_eq!(result, Err(MarmotStatus::Timeout)); + } + + #[test] + fn install_reserves_slot_before_spawning() { + let rt = runtime(); + let core = SubscriptionCore::new(rt.handle().clone()); + + // First install occupies the slot with a task that never finishes. + let first = core.install(|handle| handle.spawn(std::future::pending::<()>())); + assert_eq!(first, MarmotStatus::Ok); + + // A second install must be rejected AND must not run its spawn + // closure (the whole point of reserving before spawning). + let spawned = AtomicBool::new(false); + let second = core.install(|handle| { + spawned.store(true, Ordering::SeqCst); + handle.spawn(async {}) + }); + assert_eq!(second, MarmotStatus::Runtime); + assert!( + !spawned.load(Ordering::SeqCst), + "spawn closure must not run when a callback is already installed" + ); + + core.clear(); + } + + #[test] + fn install_accepts_again_after_clear() { + let rt = runtime(); + let core = SubscriptionCore::new(rt.handle().clone()); + assert_eq!( + core.install(|handle| handle.spawn(std::future::pending::<()>())), + MarmotStatus::Ok + ); + core.clear(); + assert_eq!( + core.install(|handle| handle.spawn(std::future::pending::<()>())), + MarmotStatus::Ok + ); + core.clear(); + } +} diff --git a/crates/marmot-c/src/types/account.rs b/crates/marmot-c/src/types/account.rs new file mode 100644 index 00000000..b041a01c --- /dev/null +++ b/crates/marmot-c/src/types/account.rs @@ -0,0 +1,600 @@ +//! C mirrors of the account conversions (`marmot-uniffi/src/conversions/account.rs`). + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{ + AccountKeyPackageFfi, AccountSummaryFfi, AccountUnreadFfi, GroupLeaveFailureFfi, + LocalCleanupReportFfi, RelayFailureFfi, SendSummaryFfi, SignOutOutcomeFfi, + UserProfileMetadataFfi, WipeOutcomeFfi, +}; + +use crate::MarmotStatus; +use crate::memory::{ + CFree, free_boxed, free_c_string, free_vec, optional_str, owned_c_string, owned_opt_c_string, + owned_vec, +}; + +/// One signed-in (or signed-out but known) account. +#[repr(C)] +pub struct MarmotAccountSummary { + pub label: *mut c_char, + pub account_id_hex: *mut c_char, + pub local_signing: bool, + pub signed_out: bool, + pub running: bool, +} + +impl From for MarmotAccountSummary { + fn from(value: AccountSummaryFfi) -> Self { + Self { + label: owned_c_string(value.label), + account_id_hex: owned_c_string(value.account_id_hex), + local_signing: value.local_signing, + signed_out: value.signed_out, + running: value.running, + } + } +} + +impl CFree for MarmotAccountSummary { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.label); + free_c_string(self.account_id_hex); + } + } +} + +/// Owned list of account summaries (`marmot_list_accounts`). +#[repr(C)] +pub struct MarmotAccountSummaryList { + pub items: *mut MarmotAccountSummary, + pub len: usize, +} + +impl From> for MarmotAccountSummaryList { + fn from(value: Vec) -> Self { + let (items, len) = owned_vec(value.into_iter().map(Into::into).collect()); + Self { items, len } + } +} + +impl CFree for MarmotAccountSummaryList { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.items, self.len) }; + } +} + +/// Free a list returned by `marmot_list_accounts`. NULL is a no-op. +/// +/// # Safety +/// `list` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_account_summary_list_free(list: *mut MarmotAccountSummaryList) { + crate::memory::free_guard(|| unsafe { free_boxed(list) }); +} + +/// Free a single account summary root. NULL is a no-op. +/// +/// # Safety +/// `summary` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_account_summary_free(summary: *mut MarmotAccountSummary) { + crate::memory::free_guard(|| unsafe { free_boxed(summary) }); +} + +/// Per-account unread aggregate for the account-switcher badge. +#[repr(C)] +pub struct MarmotAccountUnread { + pub account_id_hex: *mut c_char, + /// Total unread messages across all unarchived conversations. + pub unread_count: u64, + /// Number of unarchived conversations with at least one unread message. + pub unread_conversations: u64, + /// Whether the account has any unread message at all. + pub has_unread: bool, +} + +impl From for MarmotAccountUnread { + fn from(value: AccountUnreadFfi) -> Self { + Self { + account_id_hex: owned_c_string(value.account_id_hex), + unread_count: value.unread_count, + unread_conversations: value.unread_conversations, + has_unread: value.has_unread, + } + } +} + +impl CFree for MarmotAccountUnread { + unsafe fn free_in_place(&mut self) { + unsafe { free_c_string(self.account_id_hex) }; + } +} + +/// Owned list of unread aggregates (`marmot_account_unread_summary`). +#[repr(C)] +pub struct MarmotAccountUnreadList { + pub items: *mut MarmotAccountUnread, + pub len: usize, +} + +impl From> for MarmotAccountUnreadList { + fn from(value: Vec) -> Self { + let (items, len) = owned_vec(value.into_iter().map(Into::into).collect()); + Self { items, len } + } +} + +impl CFree for MarmotAccountUnreadList { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.items, self.len) }; + } +} + +/// Free a list returned by `marmot_account_unread_summary`. NULL is a no-op. +/// +/// # Safety +/// `list` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_account_unread_list_free(list: *mut MarmotAccountUnreadList) { + crate::memory::free_guard(|| unsafe { free_boxed(list) }); +} + +/// Publish outcome for send-shaped operations. +#[repr(C)] +pub struct MarmotSendSummary { + pub published: u32, + pub message_ids: *mut *mut c_char, + pub message_ids_len: usize, +} + +impl From for MarmotSendSummary { + fn from(value: SendSummaryFfi) -> Self { + let (message_ids, message_ids_len) = owned_vec( + value + .message_ids + .into_iter() + .map(owned_c_string) + .collect::>(), + ); + Self { + published: value.published, + message_ids, + message_ids_len, + } + } +} + +impl CFree for MarmotSendSummary { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.message_ids, self.message_ids_len) }; + } +} + +/// Free a send summary root. NULL is a no-op. +/// +/// # Safety +/// `summary` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_send_summary_free(summary: *mut MarmotSendSummary) { + crate::memory::free_guard(|| unsafe { free_boxed(summary) }); +} + +/// One published (or locally known) MLS KeyPackage. +#[repr(C)] +pub struct MarmotAccountKeyPackage { + /// Account label, when known. Nullable. + pub account_ref: *mut c_char, + pub account_id_hex: *mut c_char, + pub key_package_id: *mut c_char, + pub key_package_ref_hex: *mut c_char, + pub event_id_hex: *mut c_char, + pub published_at: u64, + pub key_package_bytes: u64, + pub source_relays: *mut *mut c_char, + pub source_relays_len: usize, + pub local: bool, + pub relay: bool, +} + +impl From for MarmotAccountKeyPackage { + fn from(value: AccountKeyPackageFfi) -> Self { + let (source_relays, source_relays_len) = owned_vec( + value + .source_relays + .into_iter() + .map(owned_c_string) + .collect::>(), + ); + Self { + account_ref: owned_opt_c_string(value.account_ref), + account_id_hex: owned_c_string(value.account_id_hex), + key_package_id: owned_c_string(value.key_package_id), + key_package_ref_hex: owned_c_string(value.key_package_ref_hex), + event_id_hex: owned_c_string(value.event_id_hex), + published_at: value.published_at, + key_package_bytes: value.key_package_bytes, + source_relays, + source_relays_len, + local: value.local, + relay: value.relay, + } + } +} + +impl CFree for MarmotAccountKeyPackage { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.account_ref); + free_c_string(self.account_id_hex); + free_c_string(self.key_package_id); + free_c_string(self.key_package_ref_hex); + free_c_string(self.event_id_hex); + free_vec(self.source_relays, self.source_relays_len); + } + } +} + +/// Owned list of key packages (`marmot_account_key_packages`). +#[repr(C)] +pub struct MarmotAccountKeyPackageList { + pub items: *mut MarmotAccountKeyPackage, + pub len: usize, +} + +impl From> for MarmotAccountKeyPackageList { + fn from(value: Vec) -> Self { + let (items, len) = owned_vec(value.into_iter().map(Into::into).collect()); + Self { items, len } + } +} + +impl CFree for MarmotAccountKeyPackageList { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.items, self.len) }; + } +} + +/// Free a list returned by `marmot_account_key_packages`. NULL is a no-op. +/// +/// # Safety +/// `list` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_account_key_package_list_free( + list: *mut MarmotAccountKeyPackageList, +) { + crate::memory::free_guard(|| unsafe { free_boxed(list) }); +} + +/// Nostr user profile metadata. All fields nullable. Used both as a +/// return value (owned; free the root) and as a borrowed input to +/// `marmot_publish_user_profile` (caller-owned; this library never frees +/// input structs). +#[repr(C)] +pub struct MarmotUserProfileMetadata { + pub name: *mut c_char, + pub display_name: *mut c_char, + pub about: *mut c_char, + pub picture: *mut c_char, + pub nip05: *mut c_char, + pub lud16: *mut c_char, +} + +impl From for MarmotUserProfileMetadata { + fn from(value: UserProfileMetadataFfi) -> Self { + Self { + name: owned_opt_c_string(value.name), + display_name: owned_opt_c_string(value.display_name), + about: owned_opt_c_string(value.about), + picture: owned_opt_c_string(value.picture), + nip05: owned_opt_c_string(value.nip05), + lud16: owned_opt_c_string(value.lud16), + } + } +} + +impl MarmotUserProfileMetadata { + /// Read a caller-owned input struct into the Ffi record without taking + /// ownership of any caller memory. + /// + /// # Safety + /// Every non-NULL field must be a valid NUL-terminated string. + pub(crate) unsafe fn to_ffi(&self) -> Result { + Ok(UserProfileMetadataFfi { + name: unsafe { optional_str(self.name) }?, + display_name: unsafe { optional_str(self.display_name) }?, + about: unsafe { optional_str(self.about) }?, + picture: unsafe { optional_str(self.picture) }?, + nip05: unsafe { optional_str(self.nip05) }?, + lud16: unsafe { optional_str(self.lud16) }?, + }) + } +} + +impl CFree for MarmotUserProfileMetadata { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.name); + free_c_string(self.display_name); + free_c_string(self.about); + free_c_string(self.picture); + free_c_string(self.nip05); + free_c_string(self.lud16); + } + } +} + +/// Free a profile returned by this library. Never call on structs you +/// allocated yourself as inputs. NULL is a no-op. +/// +/// # Safety +/// `profile` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_user_profile_metadata_free( + profile: *mut MarmotUserProfileMetadata, +) { + crate::memory::free_guard(|| unsafe { free_boxed(profile) }); +} + +/// Per-group leave failure inside a wipe outcome. Best-effort: the wipe +/// does not abort on these. +#[repr(C)] +pub struct MarmotGroupLeaveFailure { + pub group_id_hex: *mut c_char, + pub reason: *mut c_char, +} + +impl From for MarmotGroupLeaveFailure { + fn from(value: GroupLeaveFailureFfi) -> Self { + Self { + group_id_hex: owned_c_string(value.group_id_hex), + reason: owned_c_string(value.reason), + } + } +} + +impl CFree for MarmotGroupLeaveFailure { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.group_id_hex); + free_c_string(self.reason); + } + } +} + +/// Per-relay KeyPackage deletion (or discovery) failure. +#[repr(C)] +pub struct MarmotRelayFailure { + pub event_id_hex: *mut c_char, + pub reason: *mut c_char, +} + +impl From for MarmotRelayFailure { + fn from(value: RelayFailureFfi) -> Self { + Self { + event_id_hex: owned_c_string(value.event_id_hex), + reason: owned_c_string(value.reason), + } + } +} + +impl CFree for MarmotRelayFailure { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.event_id_hex); + free_c_string(self.reason); + } + } +} + +/// Local cleanup result inside a sign-out/wipe outcome. +#[repr(C)] +pub struct MarmotLocalCleanupReport { + pub completed: bool, + /// Failure classification when not completed. Nullable. + pub reason: *mut c_char, +} + +impl From for MarmotLocalCleanupReport { + fn from(value: LocalCleanupReportFfi) -> Self { + Self { + completed: value.completed, + reason: owned_opt_c_string(value.reason), + } + } +} + +impl CFree for MarmotLocalCleanupReport { + unsafe fn free_in_place(&mut self) { + unsafe { free_c_string(self.reason) }; + } +} + +/// Structured result of the destructive sign-out-and-wipe. +#[repr(C)] +pub struct MarmotWipeOutcome { + /// Active MLS groups this account successfully left. + pub groups_left: u32, + pub group_leave_failures: *mut MarmotGroupLeaveFailure, + pub group_leave_failures_len: usize, + /// Relay-published KeyPackage events successfully deleted. + pub key_packages_deleted: u32, + pub key_package_failures: *mut MarmotRelayFailure, + pub key_package_failures_len: usize, + pub local_cleanup: MarmotLocalCleanupReport, +} + +impl From for MarmotWipeOutcome { + fn from(value: WipeOutcomeFfi) -> Self { + let (group_leave_failures, group_leave_failures_len) = owned_vec( + value + .group_leave_failures + .into_iter() + .map(Into::into) + .collect(), + ); + let (key_package_failures, key_package_failures_len) = owned_vec( + value + .key_package_failures + .into_iter() + .map(Into::into) + .collect(), + ); + Self { + groups_left: value.groups_left, + group_leave_failures, + group_leave_failures_len, + key_packages_deleted: value.key_packages_deleted, + key_package_failures, + key_package_failures_len, + local_cleanup: value.local_cleanup.into(), + } + } +} + +impl CFree for MarmotWipeOutcome { + unsafe fn free_in_place(&mut self) { + unsafe { + free_vec(self.group_leave_failures, self.group_leave_failures_len); + free_vec(self.key_package_failures, self.key_package_failures_len); + self.local_cleanup.free_in_place(); + } + } +} + +/// Free a wipe outcome root. NULL is a no-op. +/// +/// # Safety +/// `outcome` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_wipe_outcome_free(outcome: *mut MarmotWipeOutcome) { + crate::memory::free_guard(|| unsafe { free_boxed(outcome) }); +} + +/// Structured result of the non-destructive sign-out. +#[repr(C)] +pub struct MarmotSignOutOutcome { + /// Relay-published KeyPackage events successfully deleted. `0` when + /// KeyPackage deletion was not requested. + pub key_packages_deleted: u32, + pub key_package_failures: *mut MarmotRelayFailure, + pub key_package_failures_len: usize, + pub local_cleanup: MarmotLocalCleanupReport, +} + +impl From for MarmotSignOutOutcome { + fn from(value: SignOutOutcomeFfi) -> Self { + let (key_package_failures, key_package_failures_len) = owned_vec( + value + .key_package_failures + .into_iter() + .map(Into::into) + .collect(), + ); + Self { + key_packages_deleted: value.key_packages_deleted, + key_package_failures, + key_package_failures_len, + local_cleanup: value.local_cleanup.into(), + } + } +} + +impl CFree for MarmotSignOutOutcome { + unsafe fn free_in_place(&mut self) { + unsafe { + free_vec(self.key_package_failures, self.key_package_failures_len); + self.local_cleanup.free_in_place(); + } + } +} + +/// Free a sign-out outcome root. NULL is a no-op. +/// +/// # Safety +/// `outcome` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_sign_out_outcome_free(outcome: *mut MarmotSignOutOutcome) { + crate::memory::free_guard(|| unsafe { free_boxed(outcome) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + + fn sample_wipe_outcome() -> WipeOutcomeFfi { + WipeOutcomeFfi { + groups_left: 2, + group_leave_failures: vec![GroupLeaveFailureFfi { + group_id_hex: "aabb".into(), + reason: "relay unreachable".into(), + }], + key_packages_deleted: 3, + key_package_failures: vec![ + RelayFailureFfi { + event_id_hex: "cc".into(), + reason: "timeout".into(), + }, + RelayFailureFfi { + event_id_hex: "dd".into(), + reason: "rejected".into(), + }, + ], + local_cleanup: LocalCleanupReportFfi { + completed: false, + reason: Some("media cache busy".into()), + }, + } + } + + #[test] + fn wipe_outcome_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotWipeOutcome = sample_wipe_outcome().into(); + assert_eq!(mirror.groups_left, 2); + assert_eq!(mirror.group_leave_failures_len, 1); + assert_eq!(mirror.key_package_failures_len, 2); + assert!(!mirror.local_cleanup.completed); + assert!(!mirror.local_cleanup.reason.is_null()); + let root = boxed(mirror); + unsafe { marmot_wipe_outcome_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn profile_input_roundtrips_borrowed_fields() { + let _guard = crate::memory::audit::test_lock(); + let owned: MarmotUserProfileMetadata = UserProfileMetadataFfi { + name: Some("marmy".into()), + display_name: None, + about: Some("burrow enthusiast".into()), + picture: None, + nip05: None, + lud16: None, + } + .into(); + let ffi = unsafe { owned.to_ffi() }.expect("valid strings"); + assert_eq!(ffi.name.as_deref(), Some("marmy")); + assert_eq!(ffi.display_name, None); + assert_eq!(ffi.about.as_deref(), Some("burrow enthusiast")); + let root = boxed(owned); + unsafe { marmot_user_profile_metadata_free(root) }; + } + + #[test] + fn empty_lists_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + let list: MarmotAccountSummaryList = Vec::::new().into(); + assert!(list.items.is_null()); + assert_eq!(list.len, 0); + let root = boxed(list); + unsafe { marmot_account_summary_list_free(root) }; + } +} diff --git a/crates/marmot-c/src/types/agent_stream.rs b/crates/marmot-c/src/types/agent_stream.rs new file mode 100644 index 00000000..449479c0 --- /dev/null +++ b/crates/marmot-c/src/types/agent_stream.rs @@ -0,0 +1,299 @@ +//! C mirrors of the agent-text-stream conversions +//! (`marmot-uniffi/src/conversions/agent_stream.rs`). + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{AgentStreamStartFfi, AgentStreamUpdateFfi}; + +use crate::memory::{CFree, free_boxed, free_c_string, free_vec, owned_c_string, owned_vec}; + +/// Result of anchoring a live agent text stream start in the encrypted +/// group history (`marmot_start_agent_text_stream`). +#[repr(C)] +pub struct MarmotAgentStreamStart { + /// Hex-encoded 32-byte stream id (generated when the caller omitted one). + pub stream_id_hex: *mut c_char, + /// Number of relays the anchor was published to. + pub published: u32, + /// Ids of the published anchor message(s). + pub message_ids: *mut *mut c_char, + pub message_ids_len: usize, +} + +impl From for MarmotAgentStreamStart { + fn from(value: AgentStreamStartFfi) -> Self { + let (message_ids, message_ids_len) = owned_vec( + value + .message_ids + .into_iter() + .map(owned_c_string) + .collect::>(), + ); + Self { + stream_id_hex: owned_c_string(value.stream_id_hex), + published: value.published, + message_ids, + message_ids_len, + } + } +} + +impl CFree for MarmotAgentStreamStart { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.stream_id_hex); + free_vec(self.message_ids, self.message_ids_len); + } + } +} + +/// Free an agent stream start root. NULL is a no-op. +/// +/// # Safety +/// `start` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_agent_stream_start_free(start: *mut MarmotAgentStreamStart) { + crate::memory::free_guard(|| unsafe { free_boxed(start) }); +} + +/// One update from a live agent-text-stream watch. `Chunk.text` is an +/// incremental fragment; `Finished.text` is the complete transcript. +#[repr(C)] +pub enum MarmotAgentStreamUpdate { + /// Incremental transcript fragment. + Chunk { seq: u64, text: *mut c_char }, + /// Out-of-band status line from the agent. + Status { seq: u64, status: *mut c_char }, + /// Progress note from the agent. + Progress { seq: u64, text: *mut c_char }, + /// Typed record frame from the agent. + Record { + seq: u64, + record_type: u8, + text: *mut c_char, + }, + /// Terminal success; `text` is the complete transcript. + Finished { + text: *mut c_char, + transcript_hash_hex: *mut c_char, + chunk_count: u64, + }, + /// Terminal failure. + Failed { message: *mut c_char }, +} + +impl From for MarmotAgentStreamUpdate { + fn from(value: AgentStreamUpdateFfi) -> Self { + match value { + AgentStreamUpdateFfi::Chunk { seq, text } => Self::Chunk { + seq, + text: owned_c_string(text), + }, + AgentStreamUpdateFfi::Status { seq, status } => Self::Status { + seq, + status: owned_c_string(status), + }, + AgentStreamUpdateFfi::Progress { seq, text } => Self::Progress { + seq, + text: owned_c_string(text), + }, + AgentStreamUpdateFfi::Record { + seq, + record_type, + text, + } => Self::Record { + seq, + record_type, + text: owned_c_string(text), + }, + AgentStreamUpdateFfi::Finished { + text, + transcript_hash_hex, + chunk_count, + } => Self::Finished { + text: owned_c_string(text), + transcript_hash_hex: owned_c_string(transcript_hash_hex), + chunk_count, + }, + AgentStreamUpdateFfi::Failed { message } => Self::Failed { + message: owned_c_string(message), + }, + } + } +} + +impl CFree for MarmotAgentStreamUpdate { + unsafe fn free_in_place(&mut self) { + unsafe { + match self { + Self::Chunk { text, .. } + | Self::Progress { text, .. } + | Self::Record { text, .. } => free_c_string(*text), + Self::Status { status, .. } => free_c_string(*status), + Self::Finished { + text, + transcript_hash_hex, + .. + } => { + free_c_string(*text); + free_c_string(*transcript_hash_hex); + } + Self::Failed { message } => free_c_string(*message), + } + } + } +} + +/// Free an agent stream update root (delivered by the agent-stream +/// subscription). NULL is a no-op. +/// +/// # Safety +/// `update` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_agent_stream_update_free(update: *mut MarmotAgentStreamUpdate) { + crate::memory::free_guard(|| unsafe { free_boxed(update) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + + #[test] + fn agent_stream_start_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotAgentStreamStart = AgentStreamStartFfi { + stream_id_hex: "ab".repeat(32), + published: 2, + message_ids: vec!["m1".into(), "m2".into()], + } + .into(); + assert!(!mirror.stream_id_hex.is_null()); + assert_eq!(mirror.published, 2); + assert_eq!(mirror.message_ids_len, 2); + let first = unsafe { std::ffi::CStr::from_ptr(*mirror.message_ids) } + .to_str() + .unwrap(); + assert_eq!(first, "m1"); + let root = boxed(mirror); + unsafe { marmot_agent_stream_start_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn agent_stream_update_all_variants_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let chunk = MarmotAgentStreamUpdate::from(AgentStreamUpdateFfi::Chunk { + seq: 1, + text: "hello".into(), + }); + match &chunk { + MarmotAgentStreamUpdate::Chunk { seq, text } => { + assert_eq!(*seq, 1); + let s = unsafe { std::ffi::CStr::from_ptr(*text) }.to_str().unwrap(); + assert_eq!(s, "hello"); + } + _ => panic!("expected Chunk"), + } + unsafe { marmot_agent_stream_update_free(boxed(chunk)) }; + + let status = MarmotAgentStreamUpdate::from(AgentStreamUpdateFfi::Status { + seq: 2, + status: "thinking".into(), + }); + match &status { + MarmotAgentStreamUpdate::Status { seq, status } => { + assert_eq!(*seq, 2); + assert!(!status.is_null()); + } + _ => panic!("expected Status"), + } + unsafe { marmot_agent_stream_update_free(boxed(status)) }; + + let progress = MarmotAgentStreamUpdate::from(AgentStreamUpdateFfi::Progress { + seq: 3, + text: "step 1".into(), + }); + match &progress { + MarmotAgentStreamUpdate::Progress { seq, text } => { + assert_eq!(*seq, 3); + assert!(!text.is_null()); + } + _ => panic!("expected Progress"), + } + unsafe { marmot_agent_stream_update_free(boxed(progress)) }; + + let record = MarmotAgentStreamUpdate::from(AgentStreamUpdateFfi::Record { + seq: 4, + record_type: 7, + text: "payload".into(), + }); + match &record { + MarmotAgentStreamUpdate::Record { + seq, + record_type, + text, + } => { + assert_eq!(*seq, 4); + assert_eq!(*record_type, 7); + assert!(!text.is_null()); + } + _ => panic!("expected Record"), + } + unsafe { marmot_agent_stream_update_free(boxed(record)) }; + + let finished = MarmotAgentStreamUpdate::from(AgentStreamUpdateFfi::Finished { + text: "full transcript".into(), + transcript_hash_hex: "ee".repeat(32), + chunk_count: 42, + }); + match &finished { + MarmotAgentStreamUpdate::Finished { + text, + transcript_hash_hex, + chunk_count, + } => { + assert_eq!(*chunk_count, 42); + assert!(!text.is_null()); + assert!(!transcript_hash_hex.is_null()); + } + _ => panic!("expected Finished"), + } + unsafe { marmot_agent_stream_update_free(boxed(finished)) }; + + let failed = MarmotAgentStreamUpdate::from(AgentStreamUpdateFfi::Failed { + message: "broker unreachable".into(), + }); + match &failed { + MarmotAgentStreamUpdate::Failed { message } => assert!(!message.is_null()), + _ => panic!("expected Failed"), + } + unsafe { marmot_agent_stream_update_free(boxed(failed)) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn empty_message_ids_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + let mirror: MarmotAgentStreamStart = AgentStreamStartFfi { + stream_id_hex: "00".into(), + published: 0, + message_ids: Vec::new(), + } + .into(); + assert!(mirror.message_ids.is_null()); + assert_eq!(mirror.message_ids_len, 0); + let root = boxed(mirror); + unsafe { marmot_agent_stream_start_free(root) }; + } +} diff --git a/crates/marmot-c/src/types/audit.rs b/crates/marmot-c/src/types/audit.rs new file mode 100644 index 00000000..0c819b5b --- /dev/null +++ b/crates/marmot-c/src/types/audit.rs @@ -0,0 +1,631 @@ +//! C mirrors of the audit conversions (`marmot-uniffi/src/conversions/audit.rs`). + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{ + AuditDataModeFfi, AuditLogDeleteResultFfi, AuditLogFileFfi, AuditLogSettingsFfi, + AuditLogTrackerConfigFfi, AuditLogTrackerUpdateResultFfi, AuditLogUploadResultFfi, + AuditLogUploadSourceFfi, +}; + +use crate::MarmotStatus; +use crate::memory::{ + CFree, free_boxed, free_c_string, free_vec, optional_str, owned_c_string, owned_opt_c_string, + owned_vec, +}; + +/// Forensic audit data mode exposed to host apps. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotAuditDataMode { + /// Default safety posture: obfuscated/hashed identifiers, no plaintext. + ObfuscatedSensitiveData, + /// Explicit opt-in: decrypted content and full identifiers where useful. + FullData, +} + +impl From for MarmotAuditDataMode { + fn from(value: AuditDataModeFfi) -> Self { + match value { + AuditDataModeFfi::ObfuscatedSensitiveData => Self::ObfuscatedSensitiveData, + AuditDataModeFfi::FullData => Self::FullData, + } + } +} + +impl MarmotAuditDataMode { + /// Read a caller-supplied input enum into the Ffi enum. + pub(crate) fn to_ffi(self) -> AuditDataModeFfi { + match self { + Self::ObfuscatedSensitiveData => AuditDataModeFfi::ObfuscatedSensitiveData, + Self::FullData => AuditDataModeFfi::FullData, + } + } +} + +impl CFree for MarmotAuditDataMode { + unsafe fn free_in_place(&mut self) {} +} + +/// One local JSONL forensic audit log file available for explicit upload +/// or deletion. +#[repr(C)] +pub struct MarmotAuditLogFile { + pub account_ref: *mut c_char, + pub path: *mut c_char, + pub file_name: *mut c_char, + pub size_bytes: u64, + /// `modified_at_ms` is only meaningful when `has_modified_at_ms` is true. + pub has_modified_at_ms: bool, + pub modified_at_ms: u64, +} + +impl From for MarmotAuditLogFile { + fn from(value: AuditLogFileFfi) -> Self { + Self { + account_ref: owned_c_string(value.account_ref), + path: owned_c_string(value.path), + file_name: owned_c_string(value.file_name), + size_bytes: value.size_bytes, + has_modified_at_ms: value.modified_at_ms.is_some(), + modified_at_ms: value.modified_at_ms.unwrap_or_default(), + } + } +} + +impl CFree for MarmotAuditLogFile { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.account_ref); + free_c_string(self.path); + free_c_string(self.file_name); + } + } +} + +/// Free a single audit log file root. NULL is a no-op. +/// +/// # Safety +/// `file` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_audit_log_file_free(file: *mut MarmotAuditLogFile) { + crate::memory::free_guard(|| unsafe { free_boxed(file) }); +} + +/// Owned list of audit log files (`marmot_audit_log_files`). +#[repr(C)] +pub struct MarmotAuditLogFileList { + pub items: *mut MarmotAuditLogFile, + pub len: usize, +} + +impl From> for MarmotAuditLogFileList { + fn from(value: Vec) -> Self { + let (items, len) = owned_vec(value.into_iter().map(Into::into).collect()); + Self { items, len } + } +} + +impl CFree for MarmotAuditLogFileList { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.items, self.len) }; + } +} + +/// Free a list returned by `marmot_audit_log_files`. NULL is a no-op. +/// +/// # Safety +/// `list` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_audit_log_file_list_free(list: *mut MarmotAuditLogFileList) { + crate::memory::free_guard(|| unsafe { free_boxed(list) }); +} + +/// Result of POSTing one JSONL audit log to a forensic analyzer endpoint. +#[repr(C)] +pub struct MarmotAuditLogUploadResult { + pub path: *mut c_char, + /// HTTP status code returned by the analyzer endpoint. + pub status: u16, + pub bytes_sent: u64, +} + +impl From for MarmotAuditLogUploadResult { + fn from(value: AuditLogUploadResultFfi) -> Self { + Self { + path: owned_c_string(value.path), + status: value.status, + bytes_sent: value.bytes_sent, + } + } +} + +impl CFree for MarmotAuditLogUploadResult { + unsafe fn free_in_place(&mut self) { + unsafe { free_c_string(self.path) }; + } +} + +/// Free an upload result root. NULL is a no-op. +/// +/// # Safety +/// `result` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_audit_log_upload_result_free( + result: *mut MarmotAuditLogUploadResult, +) { + crate::memory::free_guard(|| unsafe { free_boxed(result) }); +} + +/// Result of deleting one local JSONL audit log file. +#[repr(C)] +pub struct MarmotAuditLogDeleteResult { + /// `true` when a live recorder was rotated and is already recording to a + /// fresh file; `false` when the file was simply removed (no live recorder, + /// or audit logging off). + pub still_recording: bool, +} + +impl From for MarmotAuditLogDeleteResult { + fn from(value: AuditLogDeleteResultFfi) -> Self { + Self { + still_recording: value.still_recording, + } + } +} + +impl CFree for MarmotAuditLogDeleteResult { + unsafe fn free_in_place(&mut self) {} +} + +/// Free a delete result root. NULL is a no-op. +/// +/// # Safety +/// `result` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_audit_log_delete_result_free( + result: *mut MarmotAuditLogDeleteResult, +) { + crate::memory::free_guard(|| unsafe { free_boxed(result) }); +} + +/// Result of POSTing all local audit logs to the configured tracker. +/// Disabled or unconfigured states are structured skips, not errors. +#[repr(C)] +pub struct MarmotAuditLogTrackerUpdateResult { + /// Whether forensic audit logging was enabled when the update ran. + pub enabled: bool, + pub uploaded: *mut MarmotAuditLogUploadResult, + pub uploaded_len: usize, + /// Why the update was skipped, when it was. Nullable. + pub skipped_reason: *mut c_char, +} + +impl From for MarmotAuditLogTrackerUpdateResult { + fn from(value: AuditLogTrackerUpdateResultFfi) -> Self { + let (uploaded, uploaded_len) = + owned_vec(value.uploaded.into_iter().map(Into::into).collect()); + Self { + enabled: value.enabled, + uploaded, + uploaded_len, + skipped_reason: owned_opt_c_string(value.skipped_reason), + } + } +} + +impl CFree for MarmotAuditLogTrackerUpdateResult { + unsafe fn free_in_place(&mut self) { + unsafe { + free_vec(self.uploaded, self.uploaded_len); + free_c_string(self.skipped_reason); + } + } +} + +/// Free a tracker update result root. NULL is a no-op. +/// +/// # Safety +/// `result` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_audit_log_tracker_update_result_free( + result: *mut MarmotAuditLogTrackerUpdateResult, +) { + crate::memory::free_guard(|| unsafe { free_boxed(result) }); +} + +/// Local forensic audit-log recording settings. Recording is opt-in. Used +/// both as a return value (owned; free the root) and as a borrowed input to +/// `marmot_set_audit_log_settings` (caller-owned; this library never frees +/// input structs). +#[repr(C)] +pub struct MarmotAuditLogSettings { + pub enabled: bool, + pub data_mode: MarmotAuditDataMode, +} + +impl From for MarmotAuditLogSettings { + fn from(value: AuditLogSettingsFfi) -> Self { + Self { + enabled: value.enabled, + data_mode: value.data_mode.into(), + } + } +} + +impl MarmotAuditLogSettings { + /// Read a caller-owned input struct into the Ffi record without taking + /// ownership of any caller memory. Infallible today (scalar fields only); + /// the `Result` shape keeps command call sites uniform. + pub(crate) fn to_ffi(&self) -> Result { + Ok(AuditLogSettingsFfi { + enabled: self.enabled, + data_mode: self.data_mode.to_ffi(), + }) + } +} + +impl CFree for MarmotAuditLogSettings { + unsafe fn free_in_place(&mut self) {} +} + +/// Free settings returned by this library. Never call on structs you +/// allocated yourself as inputs. NULL is a no-op. +/// +/// # Safety +/// `settings` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_audit_log_settings_free(settings: *mut MarmotAuditLogSettings) { + crate::memory::free_guard(|| unsafe { free_boxed(settings) }); +} + +/// Optional human source labels attached to tracker uploads. All fields +/// nullable. +#[repr(C)] +pub struct MarmotAuditLogUploadSource { + pub device_label: *mut c_char, + pub platform: *mut c_char, + pub app_version: *mut c_char, +} + +impl From for MarmotAuditLogUploadSource { + fn from(value: AuditLogUploadSourceFfi) -> Self { + Self { + device_label: owned_opt_c_string(value.device_label), + platform: owned_opt_c_string(value.platform), + app_version: owned_opt_c_string(value.app_version), + } + } +} + +impl MarmotAuditLogUploadSource { + /// Read a caller-owned input struct into the Ffi record without taking + /// ownership of any caller memory. + /// + /// # Safety + /// Every non-NULL field must be a valid NUL-terminated string. + pub(crate) unsafe fn to_ffi(&self) -> Result { + Ok(AuditLogUploadSourceFfi { + device_label: unsafe { optional_str(self.device_label) }?, + platform: unsafe { optional_str(self.platform) }?, + app_version: unsafe { optional_str(self.app_version) }?, + }) + } +} + +impl CFree for MarmotAuditLogUploadSource { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.device_label); + free_c_string(self.platform); + free_c_string(self.app_version); + } + } +} + +/// Tracker upload config supplied by the host app. Write-only across the +/// boundary: `authorization_bearer_token` is accepted as input but the +/// config returned by `marmot_set_audit_log_tracker_config` never echoes it +/// back — secrets flow in, not out. Used both as a return value (owned; free +/// the root) and as a borrowed input (caller-owned; this library never frees +/// input structs). +#[repr(C)] +pub struct MarmotAuditLogTrackerConfig { + /// Optional Goggles upload URL override. Nullable. + pub endpoint: *mut c_char, + /// Bearer token from the host app. Nullable. Always NULL on returned + /// configs. + pub authorization_bearer_token: *mut c_char, + pub source: MarmotAuditLogUploadSource, +} + +impl From for MarmotAuditLogTrackerConfig { + fn from(value: AuditLogTrackerConfigFfi) -> Self { + Self { + endpoint: owned_opt_c_string(value.endpoint), + authorization_bearer_token: owned_opt_c_string(value.authorization_bearer_token), + source: value.source.into(), + } + } +} + +impl MarmotAuditLogTrackerConfig { + /// Read a caller-owned input struct into the Ffi record without taking + /// ownership of any caller memory. + /// + /// # Safety + /// Every non-NULL field must be a valid NUL-terminated string. + pub(crate) unsafe fn to_ffi(&self) -> Result { + Ok(AuditLogTrackerConfigFfi { + endpoint: unsafe { optional_str(self.endpoint) }?, + authorization_bearer_token: unsafe { optional_str(self.authorization_bearer_token) }?, + source: unsafe { self.source.to_ffi() }?, + }) + } +} + +impl CFree for MarmotAuditLogTrackerConfig { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.endpoint); + free_c_string(self.authorization_bearer_token); + self.source.free_in_place(); + } + } +} + +/// Free a tracker config returned by this library. Never call on structs +/// you allocated yourself as inputs. NULL is a no-op. +/// +/// # Safety +/// `config` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_audit_log_tracker_config_free( + config: *mut MarmotAuditLogTrackerConfig, +) { + crate::memory::free_guard(|| unsafe { free_boxed(config) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + + #[test] + fn audit_log_file_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotAuditLogFile = AuditLogFileFfi { + account_ref: "alpha".into(), + path: "/logs/alpha/audit-1.jsonl".into(), + file_name: "audit-1.jsonl".into(), + size_bytes: 4096, + modified_at_ms: Some(1_700_000_000_000), + } + .into(); + assert_eq!(mirror.size_bytes, 4096); + assert!(mirror.has_modified_at_ms); + assert_eq!(mirror.modified_at_ms, 1_700_000_000_000); + assert!(!mirror.path.is_null()); + let root = boxed(mirror); + unsafe { marmot_audit_log_file_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn audit_log_file_list_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let list: MarmotAuditLogFileList = vec![AuditLogFileFfi { + account_ref: "alpha".into(), + path: "/logs/alpha/audit-1.jsonl".into(), + file_name: "audit-1.jsonl".into(), + size_bytes: 1, + modified_at_ms: None, + }] + .into(); + assert_eq!(list.len, 1); + let first = unsafe { &*list.items }; + assert!(!first.has_modified_at_ms); + assert_eq!(first.modified_at_ms, 0); + let root = boxed(list); + unsafe { marmot_audit_log_file_list_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn upload_result_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotAuditLogUploadResult = AuditLogUploadResultFfi { + path: "/logs/alpha/audit-1.jsonl".into(), + status: 201, + bytes_sent: 999, + } + .into(); + assert_eq!(mirror.status, 201); + assert_eq!(mirror.bytes_sent, 999); + let root = boxed(mirror); + unsafe { marmot_audit_log_upload_result_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn delete_result_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotAuditLogDeleteResult = AuditLogDeleteResultFfi { + still_recording: true, + } + .into(); + assert!(mirror.still_recording); + let root = boxed(mirror); + unsafe { marmot_audit_log_delete_result_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn tracker_update_result_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotAuditLogTrackerUpdateResult = AuditLogTrackerUpdateResultFfi { + enabled: true, + uploaded: vec![ + AuditLogUploadResultFfi { + path: "/logs/a.jsonl".into(), + status: 200, + bytes_sent: 10, + }, + AuditLogUploadResultFfi { + path: "/logs/b.jsonl".into(), + status: 200, + bytes_sent: 20, + }, + ], + skipped_reason: Some("partially throttled".into()), + } + .into(); + assert!(mirror.enabled); + assert_eq!(mirror.uploaded_len, 2); + assert!(!mirror.skipped_reason.is_null()); + let root = boxed(mirror); + unsafe { marmot_audit_log_tracker_update_result_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn settings_roundtrip_both_modes() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let full: MarmotAuditLogSettings = AuditLogSettingsFfi { + enabled: true, + data_mode: AuditDataModeFfi::FullData, + } + .into(); + assert!(full.enabled); + assert_eq!(full.data_mode, MarmotAuditDataMode::FullData); + let ffi = full.to_ffi().expect("scalar fields"); + assert!(matches!(ffi.data_mode, AuditDataModeFfi::FullData)); + + let obfuscated: MarmotAuditLogSettings = AuditLogSettingsFfi { + enabled: false, + data_mode: AuditDataModeFfi::ObfuscatedSensitiveData, + } + .into(); + assert_eq!( + obfuscated.data_mode, + MarmotAuditDataMode::ObfuscatedSensitiveData + ); + let ffi = obfuscated.to_ffi().expect("scalar fields"); + assert!(matches!( + ffi.data_mode, + AuditDataModeFfi::ObfuscatedSensitiveData + )); + + let root = boxed(full); + unsafe { marmot_audit_log_settings_free(root) }; + let root = boxed(obfuscated); + unsafe { marmot_audit_log_settings_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn tracker_config_deep_roundtrip_and_borrowed_read() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let owned: MarmotAuditLogTrackerConfig = AuditLogTrackerConfigFfi { + endpoint: Some("https://goggles.example/upload".into()), + authorization_bearer_token: Some("bearer-token".into()), + source: AuditLogUploadSourceFfi { + device_label: Some("test-device".into()), + platform: Some("linux".into()), + app_version: Some("1.2.3".into()), + }, + } + .into(); + let ffi = unsafe { owned.to_ffi() }.expect("valid strings"); + assert_eq!( + ffi.endpoint.as_deref(), + Some("https://goggles.example/upload") + ); + assert_eq!( + ffi.authorization_bearer_token.as_deref(), + Some("bearer-token") + ); + assert_eq!(ffi.source.device_label.as_deref(), Some("test-device")); + assert_eq!(ffi.source.platform.as_deref(), Some("linux")); + assert_eq!(ffi.source.app_version.as_deref(), Some("1.2.3")); + let root = boxed(owned); + unsafe { marmot_audit_log_tracker_config_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn empty_and_none_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + let list: MarmotAuditLogFileList = Vec::::new().into(); + assert!(list.items.is_null()); + assert_eq!(list.len, 0); + let root = boxed(list); + unsafe { marmot_audit_log_file_list_free(root) }; + + let update: MarmotAuditLogTrackerUpdateResult = AuditLogTrackerUpdateResultFfi { + enabled: false, + uploaded: vec![], + skipped_reason: None, + } + .into(); + assert!(update.uploaded.is_null()); + assert_eq!(update.uploaded_len, 0); + assert!(update.skipped_reason.is_null()); + let root = boxed(update); + unsafe { marmot_audit_log_tracker_update_result_free(root) }; + + let config: MarmotAuditLogTrackerConfig = AuditLogTrackerConfigFfi { + endpoint: None, + authorization_bearer_token: None, + source: AuditLogUploadSourceFfi { + device_label: None, + platform: None, + app_version: None, + }, + } + .into(); + assert!(config.endpoint.is_null()); + assert!(config.authorization_bearer_token.is_null()); + assert!(config.source.device_label.is_null()); + let ffi = unsafe { config.to_ffi() }.expect("all NULL is valid"); + assert_eq!(ffi.endpoint, None); + assert_eq!(ffi.source.platform, None); + let root = boxed(config); + unsafe { marmot_audit_log_tracker_config_free(root) }; + } +} diff --git a/crates/marmot-c/src/types/chat_list.rs b/crates/marmot-c/src/types/chat_list.rs new file mode 100644 index 00000000..de5236c2 --- /dev/null +++ b/crates/marmot-c/src/types/chat_list.rs @@ -0,0 +1,592 @@ +//! C mirrors of the chat-list conversions (`marmot-uniffi/src/conversions/chat_list.rs`). +//! +//! All chat-list types are outputs only: rows come back from +//! `marmot_chat_list` / read-state commands and from the chat-list +//! subscription; nothing here is read back from caller memory. + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{ + ChatListAvatarFfi, ChatListMessagePreviewFfi, ChatListRowFfi, ChatListSubscriptionUpdateFfi, + ChatListUpdateTriggerFfi, +}; + +use crate::memory::{ + CFree, boxed_opt, free_boxed, free_c_string, free_vec, owned_c_string, owned_opt_c_string, + owned_vec, +}; +use crate::types::common::MarmotSelfMembership; +use crate::types::markdown::MarmotMarkdownDocument; + +/// Group avatar reference. `image_key_hex` is the symmetric key that decrypts +/// the avatar blob and `image_upload_key_hex` is the Blossom upload secret — +/// both are key material. Host apps must not log or otherwise stringify this +/// struct; there is no redaction at the C ABI. +#[repr(C)] +pub struct MarmotChatListAvatar { + pub image_hash_hex: *mut c_char, + /// Symmetric key that decrypts the avatar blob. Key material — do not log. + pub image_key_hex: *mut c_char, + pub image_nonce_hex: *mut c_char, + /// Blossom upload secret. Key material — do not log. + pub image_upload_key_hex: *mut c_char, + /// MIME type of the avatar blob, when known. Nullable. + pub media_type: *mut c_char, +} + +impl From for MarmotChatListAvatar { + fn from(value: ChatListAvatarFfi) -> Self { + Self { + image_hash_hex: owned_c_string(value.image_hash_hex), + image_key_hex: owned_c_string(value.image_key_hex), + image_nonce_hex: owned_c_string(value.image_nonce_hex), + image_upload_key_hex: owned_c_string(value.image_upload_key_hex), + media_type: owned_opt_c_string(value.media_type), + } + } +} + +impl CFree for MarmotChatListAvatar { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.image_hash_hex); + free_c_string(self.image_key_hex); + free_c_string(self.image_nonce_hex); + free_c_string(self.image_upload_key_hex); + free_c_string(self.media_type); + } + } +} + +/// Preview of the most recent message in a conversation, shown on the +/// chat-list row. +#[repr(C)] +pub struct MarmotChatListMessagePreview { + pub message_id_hex: *mut c_char, + pub sender: *mut c_char, + /// Resolved display name of the sender, when known. Nullable. + pub sender_display_name: *mut c_char, + pub plaintext: *mut c_char, + /// Parsed Markdown display tokens for the preview text. Owned by this + /// struct and freed with it — never individually. + pub content_tokens: MarmotMarkdownDocument, + /// Inner Marmot app event kind of the previewed message. + pub kind: u64, + pub timeline_at: u64, + pub deleted: bool, +} + +impl From for MarmotChatListMessagePreview { + fn from(value: ChatListMessagePreviewFfi) -> Self { + Self { + message_id_hex: owned_c_string(value.message_id_hex), + sender: owned_c_string(value.sender), + sender_display_name: owned_opt_c_string(value.sender_display_name), + plaintext: owned_c_string(value.plaintext), + content_tokens: value.content_tokens.into(), + kind: value.kind, + timeline_at: value.timeline_at, + deleted: value.deleted, + } + } +} + +impl CFree for MarmotChatListMessagePreview { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.message_id_hex); + free_c_string(self.sender); + free_c_string(self.sender_display_name); + free_c_string(self.plaintext); + self.content_tokens.free_in_place(); + } + } +} + +/// One conversation row on the chat list: identity, display fields, unread +/// counters, and the latest message preview. +#[repr(C)] +pub struct MarmotChatListRow { + pub group_id_hex: *mut c_char, + pub archived: bool, + pub pending_confirmation: bool, + pub title: *mut c_char, + pub group_name: *mut c_char, + /// Plain avatar URL, when set. Nullable. + pub avatar_url: *mut c_char, + /// Encrypted group avatar reference, when set. Nullable; owned by the row. + pub avatar: *mut MarmotChatListAvatar, + /// Most recent message preview, when the group has one. Nullable; owned + /// by the row. + pub last_message: *mut MarmotChatListMessagePreview, + pub unread_count: u64, + pub has_unread: bool, + pub unread_mention_count: u64, + pub unread_mention: bool, + /// First unread message id, when known. Nullable. + pub first_unread_message_id_hex: *mut c_char, + /// Last read message id, when known. Nullable. + pub last_read_message_id_hex: *mut c_char, + /// `last_read_timeline_at` is only meaningful when + /// `has_last_read_timeline_at` is true; otherwise it is zero. + pub has_last_read_timeline_at: bool, + pub last_read_timeline_at: u64, + pub updated_at: u64, + /// Whether the local account is still a member of this group, and if not, + /// whether it left voluntarily or was removed. + pub self_membership: MarmotSelfMembership, +} + +impl From for MarmotChatListRow { + fn from(value: ChatListRowFfi) -> Self { + Self { + group_id_hex: owned_c_string(value.group_id_hex), + archived: value.archived, + pending_confirmation: value.pending_confirmation, + title: owned_c_string(value.title), + group_name: owned_c_string(value.group_name), + avatar_url: owned_opt_c_string(value.avatar_url), + avatar: boxed_opt(value.avatar.map(Into::into)), + last_message: boxed_opt(value.last_message.map(Into::into)), + unread_count: value.unread_count, + has_unread: value.has_unread, + unread_mention_count: value.unread_mention_count, + unread_mention: value.unread_mention, + first_unread_message_id_hex: owned_opt_c_string(value.first_unread_message_id_hex), + last_read_message_id_hex: owned_opt_c_string(value.last_read_message_id_hex), + has_last_read_timeline_at: value.last_read_timeline_at.is_some(), + last_read_timeline_at: value.last_read_timeline_at.unwrap_or(0), + updated_at: value.updated_at, + self_membership: value.self_membership.into(), + } + } +} + +impl CFree for MarmotChatListRow { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.group_id_hex); + free_c_string(self.title); + free_c_string(self.group_name); + free_c_string(self.avatar_url); + free_boxed(self.avatar); + free_boxed(self.last_message); + free_c_string(self.first_unread_message_id_hex); + free_c_string(self.last_read_message_id_hex); + } + } +} + +/// Free a single chat-list row root (e.g. from +/// `marmot_initialize_chat_read_state` or `marmot_mark_timeline_message_read`). +/// Never call on rows embedded inside a list or subscription update. NULL is +/// a no-op. +/// +/// # Safety +/// `row` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chat_list_row_free(row: *mut MarmotChatListRow) { + crate::memory::free_guard(|| unsafe { free_boxed(row) }); +} + +/// Owned list of chat-list rows (`marmot_chat_list` and the chat-list +/// subscription snapshot). +#[repr(C)] +pub struct MarmotChatListRowList { + pub items: *mut MarmotChatListRow, + pub len: usize, +} + +impl From> for MarmotChatListRowList { + fn from(value: Vec) -> Self { + let (items, len) = owned_vec(value.into_iter().map(Into::into).collect()); + Self { items, len } + } +} + +impl CFree for MarmotChatListRowList { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.items, self.len) }; + } +} + +/// Free a chat-list row list returned by this library. NULL is a no-op. +/// +/// # Safety +/// `list` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chat_list_row_list_free(list: *mut MarmotChatListRowList) { + crate::memory::free_guard(|| unsafe { free_boxed(list) }); +} + +/// Why a chat-list subscription update fired. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotChatListUpdateTrigger { + NewGroup, + NewLastMessage, + LastMessageDeleted, + ArchiveChanged, + PendingConfirmationChanged, + MembershipChanged, + UnreadChanged, + SnapshotRefresh, + Removed, +} + +impl From for MarmotChatListUpdateTrigger { + fn from(value: ChatListUpdateTriggerFfi) -> Self { + match value { + ChatListUpdateTriggerFfi::NewGroup => Self::NewGroup, + ChatListUpdateTriggerFfi::NewLastMessage => Self::NewLastMessage, + ChatListUpdateTriggerFfi::LastMessageDeleted => Self::LastMessageDeleted, + ChatListUpdateTriggerFfi::ArchiveChanged => Self::ArchiveChanged, + ChatListUpdateTriggerFfi::PendingConfirmationChanged => { + Self::PendingConfirmationChanged + } + ChatListUpdateTriggerFfi::MembershipChanged => Self::MembershipChanged, + ChatListUpdateTriggerFfi::UnreadChanged => Self::UnreadChanged, + ChatListUpdateTriggerFfi::SnapshotRefresh => Self::SnapshotRefresh, + ChatListUpdateTriggerFfi::Removed => Self::Removed, + } + } +} + +impl CFree for MarmotChatListUpdateTrigger { + unsafe fn free_in_place(&mut self) {} +} + +/// One incremental chat-list subscription update: either a fresh row to +/// upsert or a group id whose row should be removed. +#[repr(C)] +pub enum MarmotChatListSubscriptionUpdate { + /// Upsert this row into the chat list. + Row { + trigger: MarmotChatListUpdateTrigger, + row: MarmotChatListRow, + }, + /// Remove the row for this group from the chat list. + RemoveRow { + trigger: MarmotChatListUpdateTrigger, + group_id_hex: *mut c_char, + }, +} + +impl From for MarmotChatListSubscriptionUpdate { + fn from(value: ChatListSubscriptionUpdateFfi) -> Self { + match value { + ChatListSubscriptionUpdateFfi::Row { trigger, row } => Self::Row { + trigger: trigger.into(), + row: row.into(), + }, + ChatListSubscriptionUpdateFfi::RemoveRow { + trigger, + group_id_hex, + } => Self::RemoveRow { + trigger: trigger.into(), + group_id_hex: owned_c_string(group_id_hex), + }, + } + } +} + +impl CFree for MarmotChatListSubscriptionUpdate { + unsafe fn free_in_place(&mut self) { + match self { + Self::Row { row, .. } => unsafe { row.free_in_place() }, + Self::RemoveRow { group_id_hex, .. } => unsafe { free_c_string(*group_id_hex) }, + } + } +} + +/// Free a chat-list subscription update root (`*_next_update`). NULL is a +/// no-op. +/// +/// # Safety +/// `update` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_chat_list_subscription_update_free( + update: *mut MarmotChatListSubscriptionUpdate, +) { + crate::memory::free_guard(|| unsafe { free_boxed(update) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + use marmot_uniffi::conversions::SelfMembershipFfi; + use marmot_uniffi::{MarkdownBlockFfi, MarkdownDocumentFfi, MarkdownInlineFfi}; + + fn c_str_eq(ptr: *mut c_char, expected: &str) -> bool { + assert!(!ptr.is_null()); + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_str() + .expect("valid UTF-8") + == expected + } + + fn sample_document() -> MarkdownDocumentFfi { + MarkdownDocumentFfi { + blocks: vec![MarkdownBlockFfi::Paragraph { + inlines: vec![MarkdownInlineFfi::Text { + content: "see you at the burrow".into(), + }], + }], + truncated: false, + } + } + + fn sample_preview() -> ChatListMessagePreviewFfi { + ChatListMessagePreviewFfi { + message_id_hex: "ee".repeat(32), + sender: "ff".repeat(32), + sender_display_name: Some("Marmy".into()), + plaintext: "see you at the burrow".into(), + content_tokens: sample_document(), + kind: 9, + timeline_at: 1_700_000_100, + deleted: false, + } + } + + fn sample_row() -> ChatListRowFfi { + ChatListRowFfi { + group_id_hex: "aa".repeat(16), + archived: false, + pending_confirmation: true, + title: "Burrow crew".into(), + group_name: "burrow-crew".into(), + avatar_url: Some("https://example.com/a.png".into()), + avatar: Some(ChatListAvatarFfi { + image_hash_hex: "cc".repeat(32), + image_key_hex: "11".repeat(32), + image_nonce_hex: "22".repeat(12), + image_upload_key_hex: "33".repeat(32), + media_type: Some("image/png".into()), + }), + last_message: Some(sample_preview()), + unread_count: 4, + has_unread: true, + unread_mention_count: 1, + unread_mention: true, + first_unread_message_id_hex: Some("44".repeat(32)), + last_read_message_id_hex: Some("55".repeat(32)), + last_read_timeline_at: Some(1_700_000_000), + updated_at: 1_700_000_200, + self_membership: SelfMembershipFfi::Member, + } + } + + #[test] + fn chat_list_row_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotChatListRow = sample_row().into(); + assert!(c_str_eq(mirror.group_id_hex, &"aa".repeat(16))); + assert!(!mirror.archived); + assert!(mirror.pending_confirmation); + assert!(c_str_eq(mirror.title, "Burrow crew")); + assert!(c_str_eq(mirror.group_name, "burrow-crew")); + assert!(c_str_eq(mirror.avatar_url, "https://example.com/a.png")); + + assert!(!mirror.avatar.is_null()); + let avatar = unsafe { &*mirror.avatar }; + assert!(c_str_eq(avatar.image_hash_hex, &"cc".repeat(32))); + assert!(c_str_eq(avatar.image_key_hex, &"11".repeat(32))); + assert!(c_str_eq(avatar.image_nonce_hex, &"22".repeat(12))); + assert!(c_str_eq(avatar.image_upload_key_hex, &"33".repeat(32))); + assert!(c_str_eq(avatar.media_type, "image/png")); + + assert!(!mirror.last_message.is_null()); + let preview = unsafe { &*mirror.last_message }; + assert!(c_str_eq(preview.message_id_hex, &"ee".repeat(32))); + assert!(c_str_eq(preview.sender, &"ff".repeat(32))); + assert!(c_str_eq(preview.sender_display_name, "Marmy")); + assert!(c_str_eq(preview.plaintext, "see you at the burrow")); + assert_eq!(preview.content_tokens.blocks_len, 1); + assert!(!preview.content_tokens.blocks.is_null()); + assert_eq!(preview.kind, 9); + assert_eq!(preview.timeline_at, 1_700_000_100); + assert!(!preview.deleted); + + assert_eq!(mirror.unread_count, 4); + assert!(mirror.has_unread); + assert_eq!(mirror.unread_mention_count, 1); + assert!(mirror.unread_mention); + assert!(c_str_eq( + mirror.first_unread_message_id_hex, + &"44".repeat(32) + )); + assert!(c_str_eq(mirror.last_read_message_id_hex, &"55".repeat(32))); + assert!(mirror.has_last_read_timeline_at); + assert_eq!(mirror.last_read_timeline_at, 1_700_000_000); + assert_eq!(mirror.updated_at, 1_700_000_200); + assert_eq!(mirror.self_membership, MarmotSelfMembership::Member); + + let root = boxed(mirror); + unsafe { marmot_chat_list_row_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn chat_list_row_list_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let list: MarmotChatListRowList = vec![sample_row(), sample_row()].into(); + assert_eq!(list.len, 2); + assert!(!list.items.is_null()); + let first = unsafe { &*list.items }; + assert!(c_str_eq(first.title, "Burrow crew")); + let root = boxed(list); + unsafe { marmot_chat_list_row_list_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn subscription_update_row_variant_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotChatListSubscriptionUpdate = ChatListSubscriptionUpdateFfi::Row { + trigger: ChatListUpdateTriggerFfi::NewLastMessage, + row: sample_row(), + } + .into(); + let MarmotChatListSubscriptionUpdate::Row { trigger, row } = &mirror else { + panic!("expected row variant"); + }; + assert_eq!(*trigger, MarmotChatListUpdateTrigger::NewLastMessage); + assert!(c_str_eq(row.group_id_hex, &"aa".repeat(16))); + assert!(!row.last_message.is_null()); + + let root = boxed(mirror); + unsafe { marmot_chat_list_subscription_update_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn subscription_update_remove_row_variant_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotChatListSubscriptionUpdate = ChatListSubscriptionUpdateFfi::RemoveRow { + trigger: ChatListUpdateTriggerFfi::Removed, + group_id_hex: "bb".repeat(16), + } + .into(); + let MarmotChatListSubscriptionUpdate::RemoveRow { + trigger, + group_id_hex, + } = &mirror + else { + panic!("expected remove-row variant"); + }; + assert_eq!(*trigger, MarmotChatListUpdateTrigger::Removed); + assert!(c_str_eq(*group_id_hex, &"bb".repeat(16))); + + let root = boxed(mirror); + unsafe { marmot_chat_list_subscription_update_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn update_trigger_maps_all_variants() { + let _guard = crate::memory::audit::test_lock(); + let cases = [ + ( + ChatListUpdateTriggerFfi::NewGroup, + MarmotChatListUpdateTrigger::NewGroup, + ), + ( + ChatListUpdateTriggerFfi::NewLastMessage, + MarmotChatListUpdateTrigger::NewLastMessage, + ), + ( + ChatListUpdateTriggerFfi::LastMessageDeleted, + MarmotChatListUpdateTrigger::LastMessageDeleted, + ), + ( + ChatListUpdateTriggerFfi::ArchiveChanged, + MarmotChatListUpdateTrigger::ArchiveChanged, + ), + ( + ChatListUpdateTriggerFfi::PendingConfirmationChanged, + MarmotChatListUpdateTrigger::PendingConfirmationChanged, + ), + ( + ChatListUpdateTriggerFfi::MembershipChanged, + MarmotChatListUpdateTrigger::MembershipChanged, + ), + ( + ChatListUpdateTriggerFfi::UnreadChanged, + MarmotChatListUpdateTrigger::UnreadChanged, + ), + ( + ChatListUpdateTriggerFfi::SnapshotRefresh, + MarmotChatListUpdateTrigger::SnapshotRefresh, + ), + ( + ChatListUpdateTriggerFfi::Removed, + MarmotChatListUpdateTrigger::Removed, + ), + ]; + for (input, expected) in cases { + assert_eq!(MarmotChatListUpdateTrigger::from(input), expected); + } + } + + #[test] + fn empty_lists_and_none_fields_convert_to_null() { + // Allocates, so it holds the global audit lock like every other test + // here (the process-global counter is shared across modules). + let _guard = crate::memory::audit::test_lock(); + + let list: MarmotChatListRowList = Vec::::new().into(); + assert!(list.items.is_null()); + assert_eq!(list.len, 0); + let root = boxed(list); + unsafe { marmot_chat_list_row_list_free(root) }; + + let mut bare = sample_row(); + bare.avatar_url = None; + bare.avatar = None; + bare.last_message = None; + bare.first_unread_message_id_hex = None; + bare.last_read_message_id_hex = None; + bare.last_read_timeline_at = None; + let mirror: MarmotChatListRow = bare.into(); + assert!(mirror.avatar_url.is_null()); + assert!(mirror.avatar.is_null()); + assert!(mirror.last_message.is_null()); + assert!(mirror.first_unread_message_id_hex.is_null()); + assert!(mirror.last_read_message_id_hex.is_null()); + assert!(!mirror.has_last_read_timeline_at); + assert_eq!(mirror.last_read_timeline_at, 0); + let root = boxed(mirror); + unsafe { marmot_chat_list_row_free(root) }; + + let preview: MarmotChatListMessagePreview = ChatListMessagePreviewFfi { + sender_display_name: None, + ..sample_preview() + } + .into(); + assert!(preview.sender_display_name.is_null()); + let mut preview = preview; + unsafe { preview.free_in_place() }; + } +} diff --git a/crates/marmot-c/src/types/common.rs b/crates/marmot-c/src/types/common.rs new file mode 100644 index 00000000..3e96cbdf --- /dev/null +++ b/crates/marmot-c/src/types/common.rs @@ -0,0 +1,190 @@ +//! C mirrors of the shared conversions (`marmot-uniffi/src/conversions/common.rs`), +//! plus the shared string-list root used by commands returning `Vec` +//! (e.g. `marmot_account_nip65_relays`). + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{MessageTagFfi, SelfMembershipFfi}; + +use crate::memory::{CFree, free_boxed, free_vec, owned_c_string, owned_vec}; + +/// The local account's own membership in a group: an active `Member`, or a +/// terminal state describing how it left — `Left` (a voluntary self-removal +/// or declined invite) or `Removed` (evicted by another member). Surfaced on +/// both the chat-list row and the group-detail record. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotSelfMembership { + Member, + Left, + Removed, +} + +impl From for MarmotSelfMembership { + fn from(value: SelfMembershipFfi) -> Self { + match value { + SelfMembershipFfi::Member => MarmotSelfMembership::Member, + SelfMembershipFfi::Left => MarmotSelfMembership::Left, + SelfMembershipFfi::Removed => MarmotSelfMembership::Removed, + } + } +} + +impl CFree for MarmotSelfMembership { + unsafe fn free_in_place(&mut self) {} +} + +/// One Nostr tag from an inner Marmot app event, e.g. `["e", ""]` or an +/// `["imeta", …]` media descriptor. Host apps branch on the inner event +/// `kind` plus these tags instead of a fixed payload enum. +#[repr(C)] +pub struct MarmotMessageTag { + pub values: *mut *mut c_char, + pub values_len: usize, +} + +impl From for MarmotMessageTag { + fn from(value: MessageTagFfi) -> Self { + let (values, values_len) = owned_vec( + value + .values + .into_iter() + .map(owned_c_string) + .collect::>(), + ); + Self { values, values_len } + } +} + +impl CFree for MarmotMessageTag { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.values, self.values_len) }; + } +} + +/// Free a single message tag root. NULL is a no-op. Tags nested inside a +/// message record are owned by that record and freed with it — never +/// individually. +/// +/// # Safety +/// `tag` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_message_tag_free(tag: *mut MarmotMessageTag) { + crate::memory::free_guard(|| unsafe { free_boxed(tag) }); +} + +/// Owned list of plain strings. Shared root for every command that returns +/// a list of strings (e.g. `marmot_account_nip65_relays`). +#[repr(C)] +pub struct MarmotStringList { + pub items: *mut *mut c_char, + pub len: usize, +} + +impl From> for MarmotStringList { + fn from(value: Vec) -> Self { + let (items, len) = owned_vec(value.into_iter().map(owned_c_string).collect::>()); + Self { items, len } + } +} + +impl CFree for MarmotStringList { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.items, self.len) }; + } +} + +/// Free a string list returned by this library. NULL is a no-op. +/// +/// # Safety +/// `list` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_string_list_free(list: *mut MarmotStringList) { + crate::memory::free_guard(|| unsafe { free_boxed(list) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + + #[test] + fn message_tag_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotMessageTag = MessageTagFfi { + values: vec!["e".to_string(), "abcd1234".to_string()], + } + .into(); + assert_eq!(mirror.values_len, 2); + assert!(!mirror.values.is_null()); + let first = unsafe { std::ffi::CStr::from_ptr(*mirror.values) } + .to_str() + .expect("valid UTF-8"); + assert_eq!(first, "e"); + let root = boxed(mirror); + unsafe { marmot_message_tag_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn string_list_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let list: MarmotStringList = vec![ + "wss://relay.example/one".to_string(), + "wss://relay.example/two".to_string(), + ] + .into(); + assert_eq!(list.len, 2); + assert!(!list.items.is_null()); + let second = unsafe { std::ffi::CStr::from_ptr(*list.items.add(1)) } + .to_str() + .expect("valid UTF-8"); + assert_eq!(second, "wss://relay.example/two"); + let root = boxed(list); + unsafe { marmot_string_list_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn self_membership_maps_all_variants() { + let _guard = crate::memory::audit::test_lock(); + assert_eq!( + MarmotSelfMembership::from(SelfMembershipFfi::Member), + MarmotSelfMembership::Member + ); + assert_eq!( + MarmotSelfMembership::from(SelfMembershipFfi::Left), + MarmotSelfMembership::Left + ); + assert_eq!( + MarmotSelfMembership::from(SelfMembershipFfi::Removed), + MarmotSelfMembership::Removed + ); + } + + #[test] + fn empty_lists_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + let list: MarmotStringList = Vec::::new().into(); + assert!(list.items.is_null()); + assert_eq!(list.len, 0); + let root = boxed(list); + unsafe { marmot_string_list_free(root) }; + + let tag: MarmotMessageTag = MessageTagFfi { values: Vec::new() }.into(); + assert!(tag.values.is_null()); + assert_eq!(tag.values_len, 0); + let root = boxed(tag); + unsafe { marmot_message_tag_free(root) }; + } +} diff --git a/crates/marmot-c/src/types/event.rs b/crates/marmot-c/src/types/event.rs new file mode 100644 index 00000000..a86f7574 --- /dev/null +++ b/crates/marmot-c/src/types/event.rs @@ -0,0 +1,783 @@ +//! C mirrors of the top-level event firehose conversions +//! (`marmot-uniffi/src/conversions/event.rs`). + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{GroupEventKindFfi, MarmotEventFfi}; + +use crate::memory::{CFree, free_boxed, free_c_string, owned_c_string, owned_opt_c_string}; +use crate::types::group::MarmotAppGroupHydrationQuarantineReason; +use crate::types::message::MarmotRuntimeMessageReceived; +use crate::types::timeline::MarmotRuntimeProjectionUpdate; + +/// Per-group event kind delivered inside [`MarmotEvent::GroupEvent`]. Mirrors +/// the engine's typed group events with privacy-safe scalar fields only: ids +/// are hex-encoded, epochs are `u64`, and the deeply-nested inner enums +/// (group-state change, app-message invalidation reason) are surfaced as +/// stable low-cardinality tag strings rather than re-modeled in full. No +/// payloads, ciphertext, plaintext, or key material cross the boundary. +#[repr(C)] +pub enum MarmotGroupEventKind { + GroupCreated, + GroupJoined { + via_welcome_hex: *mut c_char, + /// Member id of the welcomer, when known. Nullable. + welcomer_id_hex: *mut c_char, + }, + MessageReceived { + sender_id_hex: *mut c_char, + epoch: u64, + }, + AppMessageInvalidated { + message_id_hex: *mut c_char, + epoch: u64, + /// Stable low-cardinality tag (e.g. `losing_branch`, `beyond_anchor`). + reason: *mut c_char, + /// Reference to the previously-decrypted payload, when one exists. + /// Nullable. + decrypted_payload_ref: *mut c_char, + }, + GroupStateChanged { + epoch: u64, + /// Member id of the actor, when known. Nullable. + actor_id_hex: *mut c_char, + /// Stable low-cardinality tag (e.g. `member_added`, `group_renamed`). + change: *mut c_char, + /// Commit id this change originated from, when known. Nullable. + origin_commit_id_hex: *mut c_char, + }, + GroupHydrationQuarantined { + reason: MarmotAppGroupHydrationQuarantineReason, + }, + EpochChanged { + from: u64, + to: u64, + }, + ForkRecovered { + source_epoch: u64, + recovered_epoch: u64, + invalidated_commit_id_hex: *mut c_char, + }, + CommitRolledBack { + invalidated_commit_id_hex: *mut c_char, + }, + /// Explicit withdrawal of every `GroupStateChanged` notification whose + /// `origin_commit_id_hex` matches `invalidated_commit_id_hex`: branch + /// selection superseded that commit, so the changes it announced never + /// canonically happened. `reason` is a stable low-cardinality tag + /// (`superseded_by_branch_selection`). + GroupStateInvalidated { + epoch: u64, + invalidated_commit_id_hex: *mut c_char, + reason: *mut c_char, + }, + GroupUnrecoverable, + PendingCommitRecovered { + recovered_epoch: u64, + }, + GroupHydrationRecovered { + recovered_epoch: u64, + }, +} + +impl From for MarmotGroupEventKind { + fn from(value: GroupEventKindFfi) -> Self { + match value { + GroupEventKindFfi::GroupCreated => Self::GroupCreated, + GroupEventKindFfi::GroupJoined { + via_welcome_hex, + welcomer_id_hex, + } => Self::GroupJoined { + via_welcome_hex: owned_c_string(via_welcome_hex), + welcomer_id_hex: owned_opt_c_string(welcomer_id_hex), + }, + GroupEventKindFfi::MessageReceived { + sender_id_hex, + epoch, + } => Self::MessageReceived { + sender_id_hex: owned_c_string(sender_id_hex), + epoch, + }, + GroupEventKindFfi::AppMessageInvalidated { + message_id_hex, + epoch, + reason, + decrypted_payload_ref, + } => Self::AppMessageInvalidated { + message_id_hex: owned_c_string(message_id_hex), + epoch, + reason: owned_c_string(reason), + decrypted_payload_ref: owned_opt_c_string(decrypted_payload_ref), + }, + GroupEventKindFfi::GroupStateChanged { + epoch, + actor_id_hex, + change, + origin_commit_id_hex, + } => Self::GroupStateChanged { + epoch, + actor_id_hex: owned_opt_c_string(actor_id_hex), + change: owned_c_string(change), + origin_commit_id_hex: owned_opt_c_string(origin_commit_id_hex), + }, + GroupEventKindFfi::GroupHydrationQuarantined { reason } => { + Self::GroupHydrationQuarantined { + reason: reason.into(), + } + } + GroupEventKindFfi::EpochChanged { from, to } => Self::EpochChanged { from, to }, + GroupEventKindFfi::ForkRecovered { + source_epoch, + recovered_epoch, + invalidated_commit_id_hex, + } => Self::ForkRecovered { + source_epoch, + recovered_epoch, + invalidated_commit_id_hex: owned_c_string(invalidated_commit_id_hex), + }, + GroupEventKindFfi::CommitRolledBack { + invalidated_commit_id_hex, + } => Self::CommitRolledBack { + invalidated_commit_id_hex: owned_c_string(invalidated_commit_id_hex), + }, + GroupEventKindFfi::GroupStateInvalidated { + epoch, + invalidated_commit_id_hex, + reason, + } => Self::GroupStateInvalidated { + epoch, + invalidated_commit_id_hex: owned_c_string(invalidated_commit_id_hex), + reason: owned_c_string(reason), + }, + GroupEventKindFfi::GroupUnrecoverable => Self::GroupUnrecoverable, + GroupEventKindFfi::PendingCommitRecovered { recovered_epoch } => { + Self::PendingCommitRecovered { recovered_epoch } + } + GroupEventKindFfi::GroupHydrationRecovered { recovered_epoch } => { + Self::GroupHydrationRecovered { recovered_epoch } + } + } + } +} + +impl CFree for MarmotGroupEventKind { + unsafe fn free_in_place(&mut self) { + match self { + Self::GroupCreated + | Self::GroupHydrationQuarantined { .. } + | Self::EpochChanged { .. } + | Self::GroupUnrecoverable + | Self::PendingCommitRecovered { .. } + | Self::GroupHydrationRecovered { .. } => {} + Self::GroupJoined { + via_welcome_hex, + welcomer_id_hex, + } => unsafe { + free_c_string(*via_welcome_hex); + free_c_string(*welcomer_id_hex); + }, + Self::MessageReceived { sender_id_hex, .. } => unsafe { + free_c_string(*sender_id_hex); + }, + Self::AppMessageInvalidated { + message_id_hex, + reason, + decrypted_payload_ref, + .. + } => unsafe { + free_c_string(*message_id_hex); + free_c_string(*reason); + free_c_string(*decrypted_payload_ref); + }, + Self::GroupStateChanged { + actor_id_hex, + change, + origin_commit_id_hex, + .. + } => unsafe { + free_c_string(*actor_id_hex); + free_c_string(*change); + free_c_string(*origin_commit_id_hex); + }, + Self::ForkRecovered { + invalidated_commit_id_hex, + .. + } + | Self::CommitRolledBack { + invalidated_commit_id_hex, + } => unsafe { + free_c_string(*invalidated_commit_id_hex); + }, + Self::GroupStateInvalidated { + invalidated_commit_id_hex, + reason, + .. + } => unsafe { + free_c_string(*invalidated_commit_id_hex); + free_c_string(*reason); + }, + } + } +} + +/// Top-level event firehose item delivered by the events subscription's +/// `next`. Agent streams collapse to a single "agent stream activity" +/// variant — host apps do not differentiate them at the surface level for v1. +/// Rich payloads are carried by value so hosts read them without extra +/// dereferences. +// Variants intentionally differ widely in size; the firehose delivers one +// heap root per event, so boxing the large payloads would only add derefs. +#[repr(C)] +#[allow(clippy::large_enum_variant)] +pub enum MarmotEvent { + GroupJoined { + account_id_hex: *mut c_char, + account_label: *mut c_char, + group_id_hex: *mut c_char, + }, + GroupStateUpdated { + account_id_hex: *mut c_char, + account_label: *mut c_char, + group_id_hex: *mut c_char, + }, + MessageReceived { + received: MarmotRuntimeMessageReceived, + }, + ProjectionUpdated { + update: MarmotRuntimeProjectionUpdate, + }, + GroupEvent { + account_id_hex: *mut c_char, + account_label: *mut c_char, + group_id_hex: *mut c_char, + event: MarmotGroupEventKind, + }, + AccountError { + account_id_hex: *mut c_char, + account_label: *mut c_char, + message: *mut c_char, + }, + AgentStreamActivity { + account_id_hex: *mut c_char, + account_label: *mut c_char, + }, + /// A confirmed create/invite could not deliver a welcome to + /// `recipient_hex`; that member is in the group but unjoinable until the + /// welcome is re-delivered via `redeliver_welcome(message_id_hex)`. + WelcomeDeliveryPending { + account_id_hex: *mut c_char, + account_label: *mut c_char, + group_id_hex: *mut c_char, + message_id_hex: *mut c_char, + recipient_hex: *mut c_char, + }, +} + +impl From for MarmotEvent { + fn from(value: MarmotEventFfi) -> Self { + match value { + MarmotEventFfi::GroupJoined { + account_id_hex, + account_label, + group_id_hex, + } => Self::GroupJoined { + account_id_hex: owned_c_string(account_id_hex), + account_label: owned_c_string(account_label), + group_id_hex: owned_c_string(group_id_hex), + }, + MarmotEventFfi::GroupStateUpdated { + account_id_hex, + account_label, + group_id_hex, + } => Self::GroupStateUpdated { + account_id_hex: owned_c_string(account_id_hex), + account_label: owned_c_string(account_label), + group_id_hex: owned_c_string(group_id_hex), + }, + MarmotEventFfi::MessageReceived { received } => Self::MessageReceived { + received: received.into(), + }, + MarmotEventFfi::ProjectionUpdated { update } => Self::ProjectionUpdated { + update: update.into(), + }, + MarmotEventFfi::GroupEvent { + account_id_hex, + account_label, + group_id_hex, + event, + } => Self::GroupEvent { + account_id_hex: owned_c_string(account_id_hex), + account_label: owned_c_string(account_label), + group_id_hex: owned_c_string(group_id_hex), + event: event.into(), + }, + MarmotEventFfi::AccountError { + account_id_hex, + account_label, + message, + } => Self::AccountError { + account_id_hex: owned_c_string(account_id_hex), + account_label: owned_c_string(account_label), + message: owned_c_string(message), + }, + MarmotEventFfi::AgentStreamActivity { + account_id_hex, + account_label, + } => Self::AgentStreamActivity { + account_id_hex: owned_c_string(account_id_hex), + account_label: owned_c_string(account_label), + }, + MarmotEventFfi::WelcomeDeliveryPending { + account_id_hex, + account_label, + group_id_hex, + message_id_hex, + recipient_hex, + } => Self::WelcomeDeliveryPending { + account_id_hex: owned_c_string(account_id_hex), + account_label: owned_c_string(account_label), + group_id_hex: owned_c_string(group_id_hex), + message_id_hex: owned_c_string(message_id_hex), + recipient_hex: owned_c_string(recipient_hex), + }, + } + } +} + +impl CFree for MarmotEvent { + unsafe fn free_in_place(&mut self) { + match self { + Self::GroupJoined { + account_id_hex, + account_label, + group_id_hex, + } + | Self::GroupStateUpdated { + account_id_hex, + account_label, + group_id_hex, + } => unsafe { + free_c_string(*account_id_hex); + free_c_string(*account_label); + free_c_string(*group_id_hex); + }, + Self::MessageReceived { received } => unsafe { + received.free_in_place(); + }, + Self::ProjectionUpdated { update } => unsafe { + update.free_in_place(); + }, + Self::GroupEvent { + account_id_hex, + account_label, + group_id_hex, + event, + } => unsafe { + free_c_string(*account_id_hex); + free_c_string(*account_label); + free_c_string(*group_id_hex); + event.free_in_place(); + }, + Self::AccountError { + account_id_hex, + account_label, + message, + } => unsafe { + free_c_string(*account_id_hex); + free_c_string(*account_label); + free_c_string(*message); + }, + Self::AgentStreamActivity { + account_id_hex, + account_label, + } => unsafe { + free_c_string(*account_id_hex); + free_c_string(*account_label); + }, + Self::WelcomeDeliveryPending { + account_id_hex, + account_label, + group_id_hex, + message_id_hex, + recipient_hex, + } => unsafe { + free_c_string(*account_id_hex); + free_c_string(*account_label); + free_c_string(*group_id_hex); + free_c_string(*message_id_hex); + free_c_string(*recipient_hex); + }, + } + } +} + +// SAFETY: every pointer reachable from a `MarmotEvent` is exclusively owned +// by the value (allocated by this crate's conversion code and released +// exactly once by its deep-free), so moving the value to the callback-pump +// task on another thread is safe. +unsafe impl Send for MarmotEvent {} + +/// Free an event root returned by the events subscription. NULL is a no-op. +/// +/// # Safety +/// `event` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_event_free(event: *mut MarmotEvent) { + crate::memory::free_guard(|| unsafe { free_boxed(event) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + use marmot_uniffi::conversions::{ + AppGroupHydrationQuarantineReasonFfi, ChatListUpdateTriggerFfi, MessageTagFfi, + ReceivedMessageFfi, RuntimeMessageReceivedFfi, RuntimeProjectionUpdateFfi, + TimelineProjectionUpdateFfi, + }; + use marmot_uniffi::{MarkdownBlockFfi, MarkdownDocumentFfi, MarkdownInlineFfi}; + + fn c_str_eq(ptr: *mut c_char, expected: &str) -> bool { + assert!(!ptr.is_null()); + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_str() + .expect("valid UTF-8") + == expected + } + + fn sample_runtime_received() -> RuntimeMessageReceivedFfi { + RuntimeMessageReceivedFfi { + account_id_hex: "eeff".to_string(), + account_label: "primary".to_string(), + message: ReceivedMessageFfi { + message_id_hex: "msg-1".to_string(), + group_id_hex: "aabb".to_string(), + sender: "bob".to_string(), + sender_display_name: Some("Bob".to_string()), + plaintext: "fresh dirt".to_string(), + content_tokens: MarkdownDocumentFfi { + blocks: vec![MarkdownBlockFfi::Paragraph { + inlines: vec![MarkdownInlineFfi::Text { + content: "fresh dirt".to_string(), + }], + }], + truncated: false, + }, + kind: 9, + tags: vec![MessageTagFfi { + values: vec!["e".to_string(), "abcd".to_string()], + }], + recorded_at: 1_700_000_010, + }, + } + } + + fn sample_runtime_projection_update() -> RuntimeProjectionUpdateFfi { + RuntimeProjectionUpdateFfi { + account_id_hex: "eeff".to_string(), + account_label: "primary".to_string(), + update: TimelineProjectionUpdateFfi { + group_id_hex: "aabb".to_string(), + messages: Vec::new(), + changes: Vec::new(), + chat_list_row: None, + chat_list_trigger: ChatListUpdateTriggerFfi::UnreadChanged, + }, + } + } + + // Each caller already holds the global audit lock (via the per-test + // guard), so this helper must NOT re-lock the non-reentrant mutex. + fn roundtrip(event: MarmotEventFfi, assert_mirror: impl FnOnce(&MarmotEvent)) { + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotEvent = event.into(); + assert_mirror(&mirror); + let root = boxed(mirror); + unsafe { marmot_event_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn group_joined_event_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + roundtrip( + MarmotEventFfi::GroupJoined { + account_id_hex: "eeff".to_string(), + account_label: "primary".to_string(), + group_id_hex: "aabb".to_string(), + }, + |mirror| match mirror { + MarmotEvent::GroupJoined { + account_id_hex, + account_label, + group_id_hex, + } => { + assert!(c_str_eq(*account_id_hex, "eeff")); + assert!(c_str_eq(*account_label, "primary")); + assert!(c_str_eq(*group_id_hex, "aabb")); + } + _ => panic!("expected GroupJoined"), + }, + ); + } + + #[test] + fn group_state_updated_event_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + roundtrip( + MarmotEventFfi::GroupStateUpdated { + account_id_hex: "eeff".to_string(), + account_label: "primary".to_string(), + group_id_hex: "aabb".to_string(), + }, + |mirror| match mirror { + MarmotEvent::GroupStateUpdated { group_id_hex, .. } => { + assert!(c_str_eq(*group_id_hex, "aabb")); + } + _ => panic!("expected GroupStateUpdated"), + }, + ); + } + + #[test] + fn message_received_event_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + roundtrip( + MarmotEventFfi::MessageReceived { + received: sample_runtime_received(), + }, + |mirror| match mirror { + MarmotEvent::MessageReceived { received } => { + assert!(c_str_eq(received.account_id_hex, "eeff")); + assert!(c_str_eq(received.message.message_id_hex, "msg-1")); + assert!(c_str_eq(received.message.plaintext, "fresh dirt")); + assert!(!received.message.sender_display_name.is_null()); + assert_eq!(received.message.tags_len, 1); + } + _ => panic!("expected MessageReceived"), + }, + ); + } + + #[test] + fn projection_updated_event_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + roundtrip( + MarmotEventFfi::ProjectionUpdated { + update: sample_runtime_projection_update(), + }, + |mirror| match mirror { + MarmotEvent::ProjectionUpdated { update } => { + assert!(c_str_eq(update.account_id_hex, "eeff")); + assert!(c_str_eq(update.update.group_id_hex, "aabb")); + // Empty nested vectors and None row become NULL. + assert!(update.update.messages.is_null()); + assert_eq!(update.update.messages_len, 0); + assert!(update.update.chat_list_row.is_null()); + } + _ => panic!("expected ProjectionUpdated"), + }, + ); + } + + #[test] + fn group_event_group_joined_kind_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + roundtrip( + MarmotEventFfi::GroupEvent { + account_id_hex: "eeff".to_string(), + account_label: "primary".to_string(), + group_id_hex: "aabb".to_string(), + event: GroupEventKindFfi::GroupJoined { + via_welcome_hex: "11ff".to_string(), + welcomer_id_hex: Some("22cc".to_string()), + }, + }, + |mirror| match mirror { + MarmotEvent::GroupEvent { + group_id_hex, + event, + .. + } => { + assert!(c_str_eq(*group_id_hex, "aabb")); + match event { + MarmotGroupEventKind::GroupJoined { + via_welcome_hex, + welcomer_id_hex, + } => { + assert!(c_str_eq(*via_welcome_hex, "11ff")); + assert!(c_str_eq(*welcomer_id_hex, "22cc")); + } + _ => panic!("expected GroupJoined kind"), + } + } + _ => panic!("expected GroupEvent"), + }, + ); + } + + #[test] + fn group_event_app_message_invalidated_kind_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + roundtrip( + MarmotEventFfi::GroupEvent { + account_id_hex: "eeff".to_string(), + account_label: "primary".to_string(), + group_id_hex: "aabb".to_string(), + event: GroupEventKindFfi::AppMessageInvalidated { + message_id_hex: "msg-9".to_string(), + epoch: 7, + reason: "losing_branch".to_string(), + decrypted_payload_ref: Some("ref-1".to_string()), + }, + }, + |mirror| match mirror { + MarmotEvent::GroupEvent { event, .. } => match event { + MarmotGroupEventKind::AppMessageInvalidated { + message_id_hex, + epoch, + reason, + decrypted_payload_ref, + } => { + assert!(c_str_eq(*message_id_hex, "msg-9")); + assert_eq!(*epoch, 7); + assert!(c_str_eq(*reason, "losing_branch")); + assert!(c_str_eq(*decrypted_payload_ref, "ref-1")); + } + _ => panic!("expected AppMessageInvalidated kind"), + }, + _ => panic!("expected GroupEvent"), + }, + ); + } + + #[test] + fn group_event_hydration_quarantined_kind_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + roundtrip( + MarmotEventFfi::GroupEvent { + account_id_hex: "eeff".to_string(), + account_label: "primary".to_string(), + group_id_hex: "aabb".to_string(), + event: GroupEventKindFfi::GroupHydrationQuarantined { + reason: AppGroupHydrationQuarantineReasonFfi::OpenMlsLoadFailed, + }, + }, + |mirror| match mirror { + MarmotEvent::GroupEvent { event, .. } => match event { + MarmotGroupEventKind::GroupHydrationQuarantined { reason } => { + assert_eq!( + *reason, + MarmotAppGroupHydrationQuarantineReason::OpenMlsLoadFailed + ); + } + _ => panic!("expected GroupHydrationQuarantined kind"), + }, + _ => panic!("expected GroupEvent"), + }, + ); + } + + #[test] + fn account_error_event_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + roundtrip( + MarmotEventFfi::AccountError { + account_id_hex: "eeff".to_string(), + account_label: "primary".to_string(), + message: "relay unreachable".to_string(), + }, + |mirror| match mirror { + MarmotEvent::AccountError { message, .. } => { + assert!(c_str_eq(*message, "relay unreachable")); + } + _ => panic!("expected AccountError"), + }, + ); + } + + #[test] + fn agent_stream_activity_event_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + roundtrip( + MarmotEventFfi::AgentStreamActivity { + account_id_hex: "eeff".to_string(), + account_label: "primary".to_string(), + }, + |mirror| match mirror { + MarmotEvent::AgentStreamActivity { + account_id_hex, + account_label, + } => { + assert!(c_str_eq(*account_id_hex, "eeff")); + assert!(c_str_eq(*account_label, "primary")); + } + _ => panic!("expected AgentStreamActivity"), + }, + ); + } + + #[test] + fn welcome_delivery_pending_event_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + roundtrip( + MarmotEventFfi::WelcomeDeliveryPending { + account_id_hex: "eeff".to_string(), + account_label: "primary".to_string(), + group_id_hex: "aabb".to_string(), + message_id_hex: "msg-3".to_string(), + recipient_hex: "44dd".to_string(), + }, + |mirror| match mirror { + MarmotEvent::WelcomeDeliveryPending { + message_id_hex, + recipient_hex, + .. + } => { + assert!(c_str_eq(*message_id_hex, "msg-3")); + assert!(c_str_eq(*recipient_hex, "44dd")); + } + _ => panic!("expected WelcomeDeliveryPending"), + }, + ); + } + + #[test] + fn none_optionals_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + roundtrip( + MarmotEventFfi::GroupEvent { + account_id_hex: "eeff".to_string(), + account_label: "primary".to_string(), + group_id_hex: "aabb".to_string(), + event: GroupEventKindFfi::GroupStateChanged { + epoch: 3, + actor_id_hex: None, + change: "member_added".to_string(), + origin_commit_id_hex: None, + }, + }, + |mirror| match mirror { + MarmotEvent::GroupEvent { event, .. } => match event { + MarmotGroupEventKind::GroupStateChanged { + epoch, + actor_id_hex, + change, + origin_commit_id_hex, + } => { + assert_eq!(*epoch, 3); + assert!(actor_id_hex.is_null()); + assert!(c_str_eq(*change, "member_added")); + assert!(origin_commit_id_hex.is_null()); + } + _ => panic!("expected GroupStateChanged kind"), + }, + _ => panic!("expected GroupEvent"), + }, + ); + } +} diff --git a/crates/marmot-c/src/types/group.rs b/crates/marmot-c/src/types/group.rs new file mode 100644 index 00000000..ab994c9d --- /dev/null +++ b/crates/marmot-c/src/types/group.rs @@ -0,0 +1,1124 @@ +//! C mirrors of the group conversions (`marmot-uniffi/src/conversions/group.rs`). + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{ + AppBlobEndpointFfi, AppGroupEncryptedMediaComponentFfi, AppGroupHydrationQuarantineReasonFfi, + AppGroupMemberRecordFfi, AppGroupMlsStateFfi, AppGroupRecordFfi, AppQuarantinedGroupFfi, + GroupDetailsFfi, GroupInviteDeclineResultFfi, GroupManagementStateFfi, + GroupMemberActionStateFfi, GroupMemberDetailsFfi, GroupMutationResultFfi, MemberRefFfi, +}; + +use crate::MarmotStatus; +use crate::memory::{ + CFree, free_boxed, free_c_string, free_vec, owned_c_string, owned_opt_c_string, owned_vec, + required_str, +}; +use crate::types::account::MarmotSendSummary; +use crate::types::common::MarmotSelfMembership; + +/// One default blob endpoint for encrypted media uploads. Used both as an +/// owned output (nested in the encrypted-media component) and as a borrowed +/// input element of `marmot_replace_encrypted_media_blob_endpoints` +/// (caller-owned; this library never frees input structs). +#[repr(C)] +pub struct MarmotAppBlobEndpoint { + pub locator_kind: *mut c_char, + pub base_url: *mut c_char, +} + +impl From for MarmotAppBlobEndpoint { + fn from(value: AppBlobEndpointFfi) -> Self { + Self { + locator_kind: owned_c_string(value.locator_kind), + base_url: owned_c_string(value.base_url), + } + } +} + +impl MarmotAppBlobEndpoint { + /// Read a caller-owned input struct into the Ffi record without taking + /// ownership of any caller memory. + /// + /// # Safety + /// Both fields must be valid NUL-terminated strings. + pub(crate) unsafe fn to_ffi(&self) -> Result { + Ok(AppBlobEndpointFfi { + locator_kind: unsafe { required_str(self.locator_kind.cast_const()) }?, + base_url: unsafe { required_str(self.base_url.cast_const()) }?, + }) + } +} + +impl CFree for MarmotAppBlobEndpoint { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.locator_kind); + free_c_string(self.base_url); + } + } +} + +/// The group's `marmot.group.encrypted-media.v1` component: whether encrypted +/// media is required, the media format, and which blob locators/endpoints the +/// group allows. +#[repr(C)] +pub struct MarmotAppGroupEncryptedMediaComponent { + pub component_id: u32, + pub component: *mut c_char, + pub required: bool, + pub media_format: *mut c_char, + pub allowed_locator_kinds: *mut *mut c_char, + pub allowed_locator_kinds_len: usize, + pub default_blob_endpoints: *mut MarmotAppBlobEndpoint, + pub default_blob_endpoints_len: usize, +} + +impl From for MarmotAppGroupEncryptedMediaComponent { + fn from(value: AppGroupEncryptedMediaComponentFfi) -> Self { + let (allowed_locator_kinds, allowed_locator_kinds_len) = owned_vec( + value + .allowed_locator_kinds + .into_iter() + .map(owned_c_string) + .collect::>(), + ); + let (default_blob_endpoints, default_blob_endpoints_len) = owned_vec( + value + .default_blob_endpoints + .into_iter() + .map(Into::into) + .collect(), + ); + Self { + component_id: value.component_id, + component: owned_c_string(value.component), + required: value.required, + media_format: owned_c_string(value.media_format), + allowed_locator_kinds, + allowed_locator_kinds_len, + default_blob_endpoints, + default_blob_endpoints_len, + } + } +} + +impl CFree for MarmotAppGroupEncryptedMediaComponent { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.component); + free_c_string(self.media_format); + free_vec(self.allowed_locator_kinds, self.allowed_locator_kinds_len); + free_vec(self.default_blob_endpoints, self.default_blob_endpoints_len); + } + } +} + +/// One group as projected for the chat list and group-detail screens. +#[repr(C)] +pub struct MarmotAppGroupRecord { + pub group_id_hex: *mut c_char, + pub endpoint: *mut c_char, + pub name: *mut c_char, + pub description: *mut c_char, + pub admins: *mut *mut c_char, + pub admins_len: usize, + pub relays: *mut *mut c_char, + pub relays_len: usize, + pub nostr_group_id_hex: *mut c_char, + /// URL-based group avatar (`marmot.group.avatar-url.v1`), NULL when + /// absent. When set it takes precedence over a Blossom image avatar. + pub avatar_url: *mut c_char, + pub avatar_dim: *mut c_char, + pub avatar_thumbhash: *mut c_char, + /// Blossom-hosted group image content hash (hex), NULL when absent; + /// fetch + decrypt via the group image download command. An `avatar_url` + /// when present takes precedence. + pub image_hash_hex: *mut c_char, + pub encrypted_media: MarmotAppGroupEncryptedMediaComponent, + /// Per-group disappearing-message retention in seconds + /// (`marmot.group.message-retention.v1`). `0` means messages never expire. + pub disappearing_message_secs: u64, + pub archived: bool, + pub pending_confirmation: bool, + /// Whether the local account is still a member of this group, and if not, + /// whether it left voluntarily or was removed. + pub self_membership: MarmotSelfMembership, + pub welcomer_account_id_hex: *mut c_char, + pub via_welcome_message_id_hex: *mut c_char, +} + +impl From for MarmotAppGroupRecord { + fn from(value: AppGroupRecordFfi) -> Self { + let (admins, admins_len) = owned_vec( + value + .admins + .into_iter() + .map(owned_c_string) + .collect::>(), + ); + let (relays, relays_len) = owned_vec( + value + .relays + .into_iter() + .map(owned_c_string) + .collect::>(), + ); + Self { + group_id_hex: owned_c_string(value.group_id_hex), + endpoint: owned_c_string(value.endpoint), + name: owned_c_string(value.name), + description: owned_c_string(value.description), + admins, + admins_len, + relays, + relays_len, + nostr_group_id_hex: owned_c_string(value.nostr_group_id_hex), + avatar_url: owned_opt_c_string(value.avatar_url), + avatar_dim: owned_opt_c_string(value.avatar_dim), + avatar_thumbhash: owned_opt_c_string(value.avatar_thumbhash), + image_hash_hex: owned_opt_c_string(value.image_hash_hex), + encrypted_media: value.encrypted_media.into(), + disappearing_message_secs: value.disappearing_message_secs, + archived: value.archived, + pending_confirmation: value.pending_confirmation, + self_membership: value.self_membership.into(), + welcomer_account_id_hex: owned_opt_c_string(value.welcomer_account_id_hex), + via_welcome_message_id_hex: owned_opt_c_string(value.via_welcome_message_id_hex), + } + } +} + +impl CFree for MarmotAppGroupRecord { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.group_id_hex); + free_c_string(self.endpoint); + free_c_string(self.name); + free_c_string(self.description); + free_vec(self.admins, self.admins_len); + free_vec(self.relays, self.relays_len); + free_c_string(self.nostr_group_id_hex); + free_c_string(self.avatar_url); + free_c_string(self.avatar_dim); + free_c_string(self.avatar_thumbhash); + free_c_string(self.image_hash_hex); + self.encrypted_media.free_in_place(); + free_c_string(self.welcomer_account_id_hex); + free_c_string(self.via_welcome_message_id_hex); + } + } +} + +/// Free a group record root (`marmot_accept_group_invite`, +/// `marmot_set_group_archived`, subscription snapshots). NULL is a no-op. +/// +/// # Safety +/// `record` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_app_group_record_free(record: *mut MarmotAppGroupRecord) { + crate::memory::free_guard(|| unsafe { free_boxed(record) }); +} + +/// Owned list of group records (chats subscription snapshots). +#[repr(C)] +pub struct MarmotAppGroupRecordList { + pub items: *mut MarmotAppGroupRecord, + pub len: usize, +} + +impl From> for MarmotAppGroupRecordList { + fn from(value: Vec) -> Self { + let (items, len) = owned_vec(value.into_iter().map(Into::into).collect()); + Self { items, len } + } +} + +impl CFree for MarmotAppGroupRecordList { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.items, self.len) }; + } +} + +/// Free a group record list root. NULL is a no-op. +/// +/// # Safety +/// `list` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_app_group_record_list_free(list: *mut MarmotAppGroupRecordList) { + crate::memory::free_guard(|| unsafe { free_boxed(list) }); +} + +/// One row of the group membership roster. +#[repr(C)] +pub struct MarmotAppGroupMemberRecord { + pub member_id_hex: *mut c_char, + /// Local account label when the member maps to a signed-in account. + /// Nullable. + pub account: *mut c_char, + pub local: bool, +} + +impl From for MarmotAppGroupMemberRecord { + fn from(value: AppGroupMemberRecordFfi) -> Self { + Self { + member_id_hex: owned_c_string(value.member_id_hex), + account: owned_opt_c_string(value.account), + local: value.local, + } + } +} + +impl CFree for MarmotAppGroupMemberRecord { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.member_id_hex); + free_c_string(self.account); + } + } +} + +/// Owned list of membership roster rows (`marmot_group_members`). +#[repr(C)] +pub struct MarmotAppGroupMemberRecordList { + pub items: *mut MarmotAppGroupMemberRecord, + pub len: usize, +} + +impl From> for MarmotAppGroupMemberRecordList { + fn from(value: Vec) -> Self { + let (items, len) = owned_vec(value.into_iter().map(Into::into).collect()); + Self { items, len } + } +} + +impl CFree for MarmotAppGroupMemberRecordList { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.items, self.len) }; + } +} + +/// Free a list returned by `marmot_group_members`. NULL is a no-op. +/// +/// # Safety +/// `list` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_app_group_member_record_list_free( + list: *mut MarmotAppGroupMemberRecordList, +) { + crate::memory::free_guard(|| unsafe { free_boxed(list) }); +} + +/// A normalized member reference (`marmot_normalize_member_ref`): the +/// canonical hex account id plus its `npub` encoding. +#[repr(C)] +pub struct MarmotMemberRef { + pub member_ref: *mut c_char, + pub account_id_hex: *mut c_char, + pub npub: *mut c_char, +} + +impl From for MarmotMemberRef { + fn from(value: MemberRefFfi) -> Self { + Self { + member_ref: owned_c_string(value.member_ref), + account_id_hex: owned_c_string(value.account_id_hex), + npub: owned_c_string(value.npub), + } + } +} + +impl CFree for MarmotMemberRef { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.member_ref); + free_c_string(self.account_id_hex); + free_c_string(self.npub); + } + } +} + +/// Free a member reference root. NULL is a no-op. +/// +/// # Safety +/// `member_ref` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_member_ref_free(member_ref: *mut MarmotMemberRef) { + crate::memory::free_guard(|| unsafe { free_boxed(member_ref) }); +} + +/// One enriched member row for the group-detail screen: roster data plus +/// admin/self flags, `npub`, and a cached display name when known. +#[repr(C)] +pub struct MarmotGroupMemberDetails { + pub member_id_hex: *mut c_char, + /// Local account label when the member maps to a signed-in account. + /// Nullable. + pub account: *mut c_char, + pub local: bool, + pub is_admin: bool, + pub is_self: bool, + pub npub: *mut c_char, + /// Cached profile display name when known. Nullable. + pub display_name: *mut c_char, +} + +impl From for MarmotGroupMemberDetails { + fn from(value: GroupMemberDetailsFfi) -> Self { + Self { + member_id_hex: owned_c_string(value.member_id_hex), + account: owned_opt_c_string(value.account), + local: value.local, + is_admin: value.is_admin, + is_self: value.is_self, + npub: owned_c_string(value.npub), + display_name: owned_opt_c_string(value.display_name), + } + } +} + +impl CFree for MarmotGroupMemberDetails { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.member_id_hex); + free_c_string(self.account); + free_c_string(self.npub); + free_c_string(self.display_name); + } + } +} + +/// Group plus enriched member rows for detail screens +/// (`marmot_group_details`). +#[repr(C)] +pub struct MarmotGroupDetails { + pub group: MarmotAppGroupRecord, + pub members: *mut MarmotGroupMemberDetails, + pub members_len: usize, +} + +impl From for MarmotGroupDetails { + fn from(value: GroupDetailsFfi) -> Self { + let (members, members_len) = owned_vec(value.members.into_iter().map(Into::into).collect()); + Self { + group: value.group.into(), + members, + members_len, + } + } +} + +impl CFree for MarmotGroupDetails { + unsafe fn free_in_place(&mut self) { + unsafe { + self.group.free_in_place(); + free_vec(self.members, self.members_len); + } + } +} + +/// Free a group details root. NULL is a no-op. +/// +/// # Safety +/// `details` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_details_free(details: *mut MarmotGroupDetails) { + crate::memory::free_guard(|| unsafe { free_boxed(details) }); +} + +/// Per-member action availability for the group-management UI. +#[repr(C)] +pub struct MarmotGroupMemberActionState { + pub member_id_hex: *mut c_char, + pub is_self: bool, + pub is_admin: bool, + pub can_remove: bool, + pub can_promote: bool, + pub can_demote: bool, +} + +impl From for MarmotGroupMemberActionState { + fn from(value: GroupMemberActionStateFfi) -> Self { + Self { + member_id_hex: owned_c_string(value.member_id_hex), + is_self: value.is_self, + is_admin: value.is_admin, + can_remove: value.can_remove, + can_promote: value.can_promote, + can_demote: value.can_demote, + } + } +} + +impl CFree for MarmotGroupMemberActionState { + unsafe fn free_in_place(&mut self) { + unsafe { free_c_string(self.member_id_hex) }; + } +} + +/// Current caller permissions plus per-member action availability +/// (`marmot_group_management_state`). +#[repr(C)] +pub struct MarmotGroupManagementState { + pub my_account_id_hex: *mut c_char, + pub is_self_admin: bool, + pub is_last_admin: bool, + pub can_invite: bool, + pub can_leave: bool, + pub requires_self_demote_before_leave: bool, + pub member_actions: *mut MarmotGroupMemberActionState, + pub member_actions_len: usize, +} + +impl From for MarmotGroupManagementState { + fn from(value: GroupManagementStateFfi) -> Self { + let (member_actions, member_actions_len) = + owned_vec(value.member_actions.into_iter().map(Into::into).collect()); + Self { + my_account_id_hex: owned_c_string(value.my_account_id_hex), + is_self_admin: value.is_self_admin, + is_last_admin: value.is_last_admin, + can_invite: value.can_invite, + can_leave: value.can_leave, + requires_self_demote_before_leave: value.requires_self_demote_before_leave, + member_actions, + member_actions_len, + } + } +} + +impl CFree for MarmotGroupManagementState { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.my_account_id_hex); + free_vec(self.member_actions, self.member_actions_len); + } + } +} + +/// Free a group management state root. NULL is a no-op. +/// +/// # Safety +/// `state` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_management_state_free( + state: *mut MarmotGroupManagementState, +) { + crate::memory::free_guard(|| unsafe { free_boxed(state) }); +} + +/// Combined result of a `*_detailed` group mutation: the publish summary +/// plus the refreshed group details and management state. +#[repr(C)] +pub struct MarmotGroupMutationResult { + pub summary: MarmotSendSummary, + pub details: MarmotGroupDetails, + pub management_state: MarmotGroupManagementState, +} + +impl From for MarmotGroupMutationResult { + fn from(value: GroupMutationResultFfi) -> Self { + Self { + summary: value.summary.into(), + details: value.details.into(), + management_state: value.management_state.into(), + } + } +} + +impl CFree for MarmotGroupMutationResult { + unsafe fn free_in_place(&mut self) { + unsafe { + self.summary.free_in_place(); + self.details.free_in_place(); + self.management_state.free_in_place(); + } + } +} + +/// Free a group mutation result root. NULL is a no-op. +/// +/// # Safety +/// `result` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_mutation_result_free(result: *mut MarmotGroupMutationResult) { + crate::memory::free_guard(|| unsafe { free_boxed(result) }); +} + +/// Result of declining a group invite: the updated group record plus the +/// publish summary of the decline. +#[repr(C)] +pub struct MarmotGroupInviteDeclineResult { + pub group: MarmotAppGroupRecord, + pub summary: MarmotSendSummary, +} + +impl From for MarmotGroupInviteDeclineResult { + fn from(value: GroupInviteDeclineResultFfi) -> Self { + Self { + group: value.group.into(), + summary: value.summary.into(), + } + } +} + +impl CFree for MarmotGroupInviteDeclineResult { + unsafe fn free_in_place(&mut self) { + unsafe { + self.group.free_in_place(); + self.summary.free_in_place(); + } + } +} + +/// Free a group invite decline result root. NULL is a no-op. +/// +/// # Safety +/// `result` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_invite_decline_result_free( + result: *mut MarmotGroupInviteDeclineResult, +) { + crate::memory::free_guard(|| unsafe { free_boxed(result) }); +} + +/// MLS-level group state for the conversation's developer/debug view: the +/// current epoch, live member count, and the app components the group +/// requires. +#[repr(C)] +pub struct MarmotAppGroupMlsState { + pub group_id_hex: *mut c_char, + pub epoch: u64, + pub member_count: u32, + pub required_app_components: *mut u16, + pub required_app_components_len: usize, +} + +impl From for MarmotAppGroupMlsState { + fn from(value: AppGroupMlsStateFfi) -> Self { + let (required_app_components, required_app_components_len) = + owned_vec(value.required_app_components); + Self { + group_id_hex: owned_c_string(value.group_id_hex), + epoch: value.epoch, + member_count: value.member_count, + required_app_components, + required_app_components_len, + } + } +} + +impl CFree for MarmotAppGroupMlsState { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.group_id_hex); + free_vec( + self.required_app_components, + self.required_app_components_len, + ); + } + } +} + +/// Free a group MLS state root. NULL is a no-op. +/// +/// # Safety +/// `state` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_app_group_mls_state_free(state: *mut MarmotAppGroupMlsState) { + crate::memory::free_guard(|| unsafe { free_boxed(state) }); +} + +/// Coarse, privacy-safe reason a stored group failed session-open hydration +/// and was quarantined. Carries no group/member ids, payloads, or key +/// material — only a category the client can map to per-reason recovery +/// guidance. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotAppGroupHydrationQuarantineReason { + /// The MLS stack returned an error while loading the stored group state. + OpenMlsLoadFailed, + /// Marmot metadata referenced a group whose MLS state was missing. + OpenMlsGroupMissing, + /// Member credentials, account-identity proofs, or ratchet-tree export + /// validation failed for the loaded MLS group. + MemberValidationFailed, + /// The Marmot group record could not be loaded or refreshed. + GroupRecordLoadFailed, + /// Hydrate found a stranded pending commit, but recovery itself failed. + PendingCommitRecoveryFailed, +} + +impl From for MarmotAppGroupHydrationQuarantineReason { + fn from(value: AppGroupHydrationQuarantineReasonFfi) -> Self { + match value { + AppGroupHydrationQuarantineReasonFfi::OpenMlsLoadFailed => Self::OpenMlsLoadFailed, + AppGroupHydrationQuarantineReasonFfi::OpenMlsGroupMissing => Self::OpenMlsGroupMissing, + AppGroupHydrationQuarantineReasonFfi::MemberValidationFailed => { + Self::MemberValidationFailed + } + AppGroupHydrationQuarantineReasonFfi::GroupRecordLoadFailed => { + Self::GroupRecordLoadFailed + } + AppGroupHydrationQuarantineReasonFfi::PendingCommitRecoveryFailed => { + Self::PendingCommitRecoveryFailed + } + } + } +} + +impl CFree for MarmotAppGroupHydrationQuarantineReason { + unsafe fn free_in_place(&mut self) {} +} + +/// A stored group that failed session-open hydration and was skipped so the +/// rest of the account could open. Surfaced so the app can present a +/// per-group recovery flow distinct from healthy and archived groups, and +/// offer a non-destructive re-hydration retry. +#[repr(C)] +pub struct MarmotAppQuarantinedGroup { + pub group_id_hex: *mut c_char, + pub reason: MarmotAppGroupHydrationQuarantineReason, +} + +impl From for MarmotAppQuarantinedGroup { + fn from(value: AppQuarantinedGroupFfi) -> Self { + Self { + group_id_hex: owned_c_string(value.group_id_hex), + reason: value.reason.into(), + } + } +} + +impl CFree for MarmotAppQuarantinedGroup { + unsafe fn free_in_place(&mut self) { + unsafe { free_c_string(self.group_id_hex) }; + } +} + +/// Owned list of quarantined groups (`marmot_quarantined_groups`). +#[repr(C)] +pub struct MarmotAppQuarantinedGroupList { + pub items: *mut MarmotAppQuarantinedGroup, + pub len: usize, +} + +impl From> for MarmotAppQuarantinedGroupList { + fn from(value: Vec) -> Self { + let (items, len) = owned_vec(value.into_iter().map(Into::into).collect()); + Self { items, len } + } +} + +impl CFree for MarmotAppQuarantinedGroupList { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.items, self.len) }; + } +} + +/// Free a list returned by `marmot_quarantined_groups`. NULL is a no-op. +/// +/// # Safety +/// `list` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_app_quarantined_group_list_free( + list: *mut MarmotAppQuarantinedGroupList, +) { + crate::memory::free_guard(|| unsafe { free_boxed(list) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + use marmot_uniffi::conversions::{SelfMembershipFfi, SendSummaryFfi}; + + fn sample_encrypted_media() -> AppGroupEncryptedMediaComponentFfi { + AppGroupEncryptedMediaComponentFfi { + component_id: 2, + component: "marmot.group.encrypted-media.v1".into(), + required: true, + media_format: "mip04-v2".into(), + allowed_locator_kinds: vec!["blossom".into(), "https".into()], + default_blob_endpoints: vec![AppBlobEndpointFfi { + locator_kind: "blossom".into(), + base_url: "https://blossom.example".into(), + }], + } + } + + fn sample_group_record() -> AppGroupRecordFfi { + AppGroupRecordFfi { + group_id_hex: "aabbccdd".into(), + endpoint: "nostr".into(), + name: "burrow".into(), + description: "alpine chat".into(), + admins: vec!["aa11".into()], + relays: vec![ + "wss://relay.example/one".into(), + "wss://relay.example/two".into(), + ], + nostr_group_id_hex: "ee".repeat(32), + avatar_url: Some("https://img.example/a.png".into()), + avatar_dim: Some("64x64".into()), + avatar_thumbhash: Some("abcd".into()), + image_hash_hex: Some("beef".into()), + encrypted_media: sample_encrypted_media(), + disappearing_message_secs: 3600, + archived: false, + pending_confirmation: true, + self_membership: SelfMembershipFfi::Member, + welcomer_account_id_hex: Some("cc22".into()), + via_welcome_message_id_hex: Some("dd33".into()), + } + } + + fn sample_member_details() -> GroupMemberDetailsFfi { + GroupMemberDetailsFfi { + member_id_hex: "aa11".into(), + account: Some("primary".into()), + local: true, + is_admin: true, + is_self: true, + npub: "npub1example".into(), + display_name: Some("Marmy".into()), + } + } + + fn sample_details() -> GroupDetailsFfi { + GroupDetailsFfi { + group: sample_group_record(), + members: vec![sample_member_details()], + } + } + + fn sample_management_state() -> GroupManagementStateFfi { + GroupManagementStateFfi { + my_account_id_hex: "aa11".into(), + is_self_admin: true, + is_last_admin: false, + can_invite: true, + can_leave: false, + requires_self_demote_before_leave: true, + member_actions: vec![GroupMemberActionStateFfi { + member_id_hex: "bb22".into(), + is_self: false, + is_admin: false, + can_remove: true, + can_promote: true, + can_demote: false, + }], + } + } + + fn sample_send_summary() -> SendSummaryFfi { + SendSummaryFfi { + published: 1, + message_ids: vec!["ff".repeat(32)], + } + } + + #[test] + fn group_record_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotAppGroupRecord = sample_group_record().into(); + assert_eq!(mirror.admins_len, 1); + assert_eq!(mirror.relays_len, 2); + assert!(!mirror.avatar_url.is_null()); + assert_eq!(mirror.disappearing_message_secs, 3600); + assert_eq!(mirror.self_membership, MarmotSelfMembership::Member); + assert_eq!(mirror.encrypted_media.allowed_locator_kinds_len, 2); + assert_eq!(mirror.encrypted_media.default_blob_endpoints_len, 1); + assert!(!mirror.welcomer_account_id_hex.is_null()); + let name = unsafe { std::ffi::CStr::from_ptr(mirror.name) } + .to_str() + .expect("valid UTF-8"); + assert_eq!(name, "burrow"); + let root = boxed(mirror); + unsafe { marmot_app_group_record_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn blob_endpoint_input_roundtrips_borrowed_fields() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mut owned: MarmotAppBlobEndpoint = AppBlobEndpointFfi { + locator_kind: "blossom".into(), + base_url: "https://blossom.example".into(), + } + .into(); + let ffi = unsafe { owned.to_ffi() }.expect("valid strings"); + assert_eq!(ffi.locator_kind, "blossom"); + assert_eq!(ffi.base_url, "https://blossom.example"); + unsafe { owned.free_in_place() }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn group_member_record_list_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let list: MarmotAppGroupMemberRecordList = vec![AppGroupMemberRecordFfi { + member_id_hex: "aa11".into(), + account: Some("primary".into()), + local: true, + }] + .into(); + assert_eq!(list.len, 1); + assert!(!list.items.is_null()); + let first = unsafe { &*list.items }; + assert!(first.local); + assert!(!first.account.is_null()); + let root = boxed(list); + unsafe { marmot_app_group_member_record_list_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn member_ref_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotMemberRef = MemberRefFfi { + member_ref: "aa11".into(), + account_id_hex: "aa11".into(), + npub: "npub1example".into(), + } + .into(); + let npub = unsafe { std::ffi::CStr::from_ptr(mirror.npub) } + .to_str() + .expect("valid UTF-8"); + assert_eq!(npub, "npub1example"); + let root = boxed(mirror); + unsafe { marmot_member_ref_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn group_details_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotGroupDetails = sample_details().into(); + assert_eq!(mirror.members_len, 1); + let member = unsafe { &*mirror.members }; + assert!(member.is_admin); + assert!(!member.display_name.is_null()); + assert!(!mirror.group.group_id_hex.is_null()); + let root = boxed(mirror); + unsafe { marmot_group_details_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn group_management_state_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotGroupManagementState = sample_management_state().into(); + assert!(mirror.is_self_admin); + assert!(mirror.requires_self_demote_before_leave); + assert_eq!(mirror.member_actions_len, 1); + let action = unsafe { &*mirror.member_actions }; + assert!(action.can_remove); + assert!(!action.can_demote); + let root = boxed(mirror); + unsafe { marmot_group_management_state_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn group_mutation_result_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotGroupMutationResult = GroupMutationResultFfi { + summary: sample_send_summary(), + details: sample_details(), + management_state: sample_management_state(), + } + .into(); + assert_eq!(mirror.summary.published, 1); + assert_eq!(mirror.summary.message_ids_len, 1); + assert_eq!(mirror.details.members_len, 1); + assert_eq!(mirror.management_state.member_actions_len, 1); + let root = boxed(mirror); + unsafe { marmot_group_mutation_result_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn group_invite_decline_result_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotGroupInviteDeclineResult = GroupInviteDeclineResultFfi { + group: sample_group_record(), + summary: sample_send_summary(), + } + .into(); + assert!(!mirror.group.group_id_hex.is_null()); + assert_eq!(mirror.summary.published, 1); + let root = boxed(mirror); + unsafe { marmot_group_invite_decline_result_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn group_mls_state_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotAppGroupMlsState = AppGroupMlsStateFfi { + group_id_hex: "aabb".into(), + epoch: 42, + member_count: 3, + required_app_components: vec![1, 2, 3], + } + .into(); + assert_eq!(mirror.epoch, 42); + assert_eq!(mirror.member_count, 3); + assert_eq!(mirror.required_app_components_len, 3); + assert_eq!(unsafe { *mirror.required_app_components.add(2) }, 3); + let root = boxed(mirror); + unsafe { marmot_app_group_mls_state_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn quarantined_group_list_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let list: MarmotAppQuarantinedGroupList = vec![ + AppQuarantinedGroupFfi { + group_id_hex: "aa".into(), + reason: AppGroupHydrationQuarantineReasonFfi::OpenMlsLoadFailed, + }, + AppQuarantinedGroupFfi { + group_id_hex: "bb".into(), + reason: AppGroupHydrationQuarantineReasonFfi::PendingCommitRecoveryFailed, + }, + ] + .into(); + assert_eq!(list.len, 2); + let first = unsafe { &*list.items }; + assert_eq!( + first.reason, + MarmotAppGroupHydrationQuarantineReason::OpenMlsLoadFailed + ); + let second = unsafe { &*list.items.add(1) }; + assert_eq!( + second.reason, + MarmotAppGroupHydrationQuarantineReason::PendingCommitRecoveryFailed + ); + let root = boxed(list); + unsafe { marmot_app_quarantined_group_list_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn quarantine_reason_maps_all_variants() { + let _guard = crate::memory::audit::test_lock(); + for (ffi, mirror) in [ + ( + AppGroupHydrationQuarantineReasonFfi::OpenMlsLoadFailed, + MarmotAppGroupHydrationQuarantineReason::OpenMlsLoadFailed, + ), + ( + AppGroupHydrationQuarantineReasonFfi::OpenMlsGroupMissing, + MarmotAppGroupHydrationQuarantineReason::OpenMlsGroupMissing, + ), + ( + AppGroupHydrationQuarantineReasonFfi::MemberValidationFailed, + MarmotAppGroupHydrationQuarantineReason::MemberValidationFailed, + ), + ( + AppGroupHydrationQuarantineReasonFfi::GroupRecordLoadFailed, + MarmotAppGroupHydrationQuarantineReason::GroupRecordLoadFailed, + ), + ( + AppGroupHydrationQuarantineReasonFfi::PendingCommitRecoveryFailed, + MarmotAppGroupHydrationQuarantineReason::PendingCommitRecoveryFailed, + ), + ] { + assert_eq!(MarmotAppGroupHydrationQuarantineReason::from(ffi), mirror); + } + } + + #[test] + fn empty_vecs_and_nones_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + + let mut record = sample_group_record(); + record.admins = Vec::new(); + record.relays = Vec::new(); + record.avatar_url = None; + record.avatar_dim = None; + record.avatar_thumbhash = None; + record.welcomer_account_id_hex = None; + record.via_welcome_message_id_hex = None; + record.encrypted_media.allowed_locator_kinds = Vec::new(); + record.encrypted_media.default_blob_endpoints = Vec::new(); + let mirror: MarmotAppGroupRecord = record.into(); + assert!(mirror.admins.is_null()); + assert_eq!(mirror.admins_len, 0); + assert!(mirror.relays.is_null()); + assert!(mirror.avatar_url.is_null()); + assert!(mirror.avatar_dim.is_null()); + assert!(mirror.avatar_thumbhash.is_null()); + assert!(mirror.welcomer_account_id_hex.is_null()); + assert!(mirror.via_welcome_message_id_hex.is_null()); + assert!(mirror.encrypted_media.allowed_locator_kinds.is_null()); + assert!(mirror.encrypted_media.default_blob_endpoints.is_null()); + let root = boxed(mirror); + unsafe { marmot_app_group_record_free(root) }; + + let list: MarmotAppGroupRecordList = Vec::::new().into(); + assert!(list.items.is_null()); + assert_eq!(list.len, 0); + let root = boxed(list); + unsafe { marmot_app_group_record_list_free(root) }; + + let list: MarmotAppQuarantinedGroupList = Vec::::new().into(); + assert!(list.items.is_null()); + assert_eq!(list.len, 0); + let root = boxed(list); + unsafe { marmot_app_quarantined_group_list_free(root) }; + } +} diff --git a/crates/marmot-c/src/types/markdown.rs b/crates/marmot-c/src/types/markdown.rs new file mode 100644 index 00000000..d0550ff6 --- /dev/null +++ b/crates/marmot-c/src/types/markdown.rs @@ -0,0 +1,1002 @@ +//! C mirrors of the Markdown AST values (`marmot-uniffi/src/markdown.rs`). +//! +//! The parser crate owns the real AST; these mirrors keep the C surface +//! stable and host-friendly. All markdown types are outputs only: they are +//! produced by `marmot_parse_markdown` and embedded in timeline records, +//! and are never read back from caller memory. + +use std::ffi::c_char; + +use marmot_uniffi::{ + MarkdownAlignmentFfi, MarkdownAutolinkKindFfi, MarkdownBlockFfi, MarkdownCodeBlockKindFfi, + MarkdownDocumentFfi, MarkdownInlineFfi, MarkdownListItemFfi, MarkdownListKindFfi, + MarkdownNostrEntityFfi, MarkdownNostrHrpFfi, MarkdownTableCellFfi, +}; + +use crate::memory::{ + CFree, free_boxed, free_c_string, free_vec, owned_c_string, owned_opt_c_string, owned_vec, +}; + +/// Convert a vector of inline nodes into an owned `(ptr, len)` pair. +fn owned_inlines(values: Vec) -> (*mut MarmotMarkdownInline, usize) { + owned_vec(values.into_iter().map(Into::into).collect()) +} + +/// Convert a vector of block nodes into an owned `(ptr, len)` pair. +fn owned_blocks(values: Vec) -> (*mut MarmotMarkdownBlock, usize) { + owned_vec(values.into_iter().map(Into::into).collect()) +} + +/// A parsed Markdown document: the ordered top-level blocks. +#[repr(C)] +pub struct MarmotMarkdownDocument { + pub blocks: *mut MarmotMarkdownBlock, + pub blocks_len: usize, + /// True when the input exceeded the FFI Markdown safety cap and the + /// blocks were parsed from a UTF-8-boundary prefix. + pub truncated: bool, +} + +impl From for MarmotMarkdownDocument { + fn from(value: MarkdownDocumentFfi) -> Self { + let (blocks, blocks_len) = owned_blocks(value.blocks); + Self { + blocks, + blocks_len, + truncated: value.truncated, + } + } +} + +impl CFree for MarmotMarkdownDocument { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.blocks, self.blocks_len) }; + } +} + +/// Free a document returned by `marmot_parse_markdown`. Never call on +/// documents embedded by value inside another struct. NULL is a no-op. +/// +/// # Safety +/// `document` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_markdown_document_free(document: *mut MarmotMarkdownDocument) { + crate::memory::free_guard(|| unsafe { free_boxed(document) }); +} + +/// One block-level Markdown node. Child blocks and inlines are owned +/// `(ptr, len)` arrays freed by the parent. +#[repr(C)] +pub enum MarmotMarkdownBlock { + Paragraph { + inlines: *mut MarmotMarkdownInline, + inlines_len: usize, + }, + Heading { + level: u8, + inlines: *mut MarmotMarkdownInline, + inlines_len: usize, + }, + ThematicBreak, + CodeBlock { + kind: MarmotMarkdownCodeBlockKind, + info: *mut c_char, + content: *mut c_char, + }, + BlockQuote { + blocks: *mut MarmotMarkdownBlock, + blocks_len: usize, + }, + /// Named `ListBlock` (not `List`) to match the sibling `*Block` variants. + ListBlock { + kind: MarmotMarkdownListKind, + tight: bool, + items: *mut MarmotMarkdownListItem, + items_len: usize, + }, + Table { + alignments: *mut MarmotMarkdownAlignment, + alignments_len: usize, + header: *mut MarmotMarkdownTableCell, + header_len: usize, + rows: *mut MarmotMarkdownTableRow, + rows_len: usize, + }, + MathBlock { + content: *mut c_char, + }, +} + +impl From for MarmotMarkdownBlock { + fn from(value: MarkdownBlockFfi) -> Self { + match value { + MarkdownBlockFfi::Paragraph { inlines } => { + let (inlines, inlines_len) = owned_inlines(inlines); + Self::Paragraph { + inlines, + inlines_len, + } + } + MarkdownBlockFfi::Heading { level, inlines } => { + let (inlines, inlines_len) = owned_inlines(inlines); + Self::Heading { + level, + inlines, + inlines_len, + } + } + MarkdownBlockFfi::ThematicBreak => Self::ThematicBreak, + MarkdownBlockFfi::CodeBlock { + kind, + info, + content, + } => Self::CodeBlock { + kind: kind.into(), + info: owned_c_string(info), + content: owned_c_string(content), + }, + MarkdownBlockFfi::BlockQuote { blocks } => { + let (blocks, blocks_len) = owned_blocks(blocks); + Self::BlockQuote { blocks, blocks_len } + } + MarkdownBlockFfi::ListBlock { kind, tight, items } => { + let (items, items_len) = owned_vec(items.into_iter().map(Into::into).collect()); + Self::ListBlock { + kind: kind.into(), + tight, + items, + items_len, + } + } + MarkdownBlockFfi::Table { + alignments, + header, + rows, + } => { + let (alignments, alignments_len) = + owned_vec(alignments.into_iter().map(Into::into).collect()); + let (header, header_len) = owned_vec(header.into_iter().map(Into::into).collect()); + let (rows, rows_len) = owned_vec(rows.into_iter().map(Into::into).collect()); + Self::Table { + alignments, + alignments_len, + header, + header_len, + rows, + rows_len, + } + } + MarkdownBlockFfi::MathBlock { content } => Self::MathBlock { + content: owned_c_string(content), + }, + } + } +} + +impl CFree for MarmotMarkdownBlock { + unsafe fn free_in_place(&mut self) { + match self { + Self::Paragraph { + inlines, + inlines_len, + } + | Self::Heading { + inlines, + inlines_len, + .. + } => unsafe { free_vec(*inlines, *inlines_len) }, + Self::ThematicBreak => {} + Self::CodeBlock { info, content, .. } => unsafe { + free_c_string(*info); + free_c_string(*content); + }, + Self::BlockQuote { blocks, blocks_len } => unsafe { + free_vec(*blocks, *blocks_len); + }, + Self::ListBlock { + kind, + items, + items_len, + .. + } => unsafe { + kind.free_in_place(); + free_vec(*items, *items_len); + }, + Self::Table { + alignments, + alignments_len, + header, + header_len, + rows, + rows_len, + } => unsafe { + free_vec(*alignments, *alignments_len); + free_vec(*header, *header_len); + free_vec(*rows, *rows_len); + }, + Self::MathBlock { content } => unsafe { free_c_string(*content) }, + } + } +} + +/// How a code block was written in the source text. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotMarkdownCodeBlockKind { + Indented, + Fenced, +} + +impl From for MarmotMarkdownCodeBlockKind { + fn from(value: MarkdownCodeBlockKindFfi) -> Self { + match value { + MarkdownCodeBlockKindFfi::Indented => Self::Indented, + MarkdownCodeBlockKindFfi::Fenced => Self::Fenced, + } + } +} + +impl CFree for MarmotMarkdownCodeBlockKind { + unsafe fn free_in_place(&mut self) {} +} + +/// List flavor: bullet or ordered. +#[repr(C)] +pub enum MarmotMarkdownListKind { + /// `marker` is a single-character string: "-", "*", or "+". + Bullet { marker: *mut c_char }, + /// `delimiter` is a single-character string: "." or ")". + Ordered { start: u32, delimiter: *mut c_char }, +} + +impl From for MarmotMarkdownListKind { + fn from(value: MarkdownListKindFfi) -> Self { + match value { + MarkdownListKindFfi::Bullet { marker } => Self::Bullet { + marker: owned_c_string(marker), + }, + MarkdownListKindFfi::Ordered { start, delimiter } => Self::Ordered { + start, + delimiter: owned_c_string(delimiter), + }, + } + } +} + +impl CFree for MarmotMarkdownListKind { + unsafe fn free_in_place(&mut self) { + match self { + Self::Bullet { marker } => unsafe { free_c_string(*marker) }, + Self::Ordered { delimiter, .. } => unsafe { free_c_string(*delimiter) }, + } + } +} + +/// One list item: nested blocks plus an optional task-list checkbox. +#[repr(C)] +pub struct MarmotMarkdownListItem { + pub blocks: *mut MarmotMarkdownBlock, + pub blocks_len: usize, + /// `has_checked` is false for plain bullets/ordered items; otherwise + /// `checked` is false for `[ ]` and true for `[x]`. + pub has_checked: bool, + pub checked: bool, +} + +impl From for MarmotMarkdownListItem { + fn from(value: MarkdownListItemFfi) -> Self { + let (blocks, blocks_len) = owned_blocks(value.blocks); + Self { + blocks, + blocks_len, + has_checked: value.checked.is_some(), + checked: value.checked.unwrap_or(false), + } + } +} + +impl CFree for MarmotMarkdownListItem { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.blocks, self.blocks_len) }; + } +} + +/// Per-column table alignment. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotMarkdownAlignment { + None, + Left, + Center, + Right, +} + +impl From for MarmotMarkdownAlignment { + fn from(value: MarkdownAlignmentFfi) -> Self { + match value { + MarkdownAlignmentFfi::None => Self::None, + MarkdownAlignmentFfi::Left => Self::Left, + MarkdownAlignmentFfi::Center => Self::Center, + MarkdownAlignmentFfi::Right => Self::Right, + } + } +} + +impl CFree for MarmotMarkdownAlignment { + unsafe fn free_in_place(&mut self) {} +} + +/// One table cell: a run of inline nodes. +#[repr(C)] +pub struct MarmotMarkdownTableCell { + pub inlines: *mut MarmotMarkdownInline, + pub inlines_len: usize, +} + +impl From for MarmotMarkdownTableCell { + fn from(value: MarkdownTableCellFfi) -> Self { + let (inlines, inlines_len) = owned_inlines(value.inlines); + Self { + inlines, + inlines_len, + } + } +} + +impl CFree for MarmotMarkdownTableCell { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.inlines, self.inlines_len) }; + } +} + +/// One table body row (mirror of one `Vec` inside `rows`). +#[repr(C)] +pub struct MarmotMarkdownTableRow { + pub cells: *mut MarmotMarkdownTableCell, + pub cells_len: usize, +} + +impl From> for MarmotMarkdownTableRow { + fn from(value: Vec) -> Self { + let (cells, cells_len) = owned_vec(value.into_iter().map(Into::into).collect()); + Self { cells, cells_len } + } +} + +impl CFree for MarmotMarkdownTableRow { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.cells, self.cells_len) }; + } +} + +/// One inline-level Markdown node. Child inlines are owned `(ptr, len)` +/// arrays freed by the parent. +#[repr(C)] +pub enum MarmotMarkdownInline { + Text { + content: *mut c_char, + }, + SoftBreak, + HardBreak, + Code { + content: *mut c_char, + }, + Emph { + children: *mut MarmotMarkdownInline, + children_len: usize, + }, + Strong { + children: *mut MarmotMarkdownInline, + children_len: usize, + }, + Strikethrough { + children: *mut MarmotMarkdownInline, + children_len: usize, + }, + Link { + dest: *mut c_char, + /// Nullable. + title: *mut c_char, + children: *mut MarmotMarkdownInline, + children_len: usize, + }, + Image { + dest: *mut c_char, + /// Nullable. + title: *mut c_char, + alt: *mut MarmotMarkdownInline, + alt_len: usize, + }, + Autolink { + url: *mut c_char, + kind: MarmotMarkdownAutolinkKind, + }, + Math { + content: *mut c_char, + }, + NostrMention { + entity: MarmotMarkdownNostrEntity, + }, + NostrUri { + entity: MarmotMarkdownNostrEntity, + }, +} + +impl From for MarmotMarkdownInline { + fn from(value: MarkdownInlineFfi) -> Self { + match value { + MarkdownInlineFfi::Text { content } => Self::Text { + content: owned_c_string(content), + }, + MarkdownInlineFfi::SoftBreak => Self::SoftBreak, + MarkdownInlineFfi::HardBreak => Self::HardBreak, + MarkdownInlineFfi::Code { content } => Self::Code { + content: owned_c_string(content), + }, + MarkdownInlineFfi::Emph { children } => { + let (children, children_len) = owned_inlines(children); + Self::Emph { + children, + children_len, + } + } + MarkdownInlineFfi::Strong { children } => { + let (children, children_len) = owned_inlines(children); + Self::Strong { + children, + children_len, + } + } + MarkdownInlineFfi::Strikethrough { children } => { + let (children, children_len) = owned_inlines(children); + Self::Strikethrough { + children, + children_len, + } + } + MarkdownInlineFfi::Link { + dest, + title, + children, + } => { + let (children, children_len) = owned_inlines(children); + Self::Link { + dest: owned_c_string(dest), + title: owned_opt_c_string(title), + children, + children_len, + } + } + MarkdownInlineFfi::Image { dest, title, alt } => { + let (alt, alt_len) = owned_inlines(alt); + Self::Image { + dest: owned_c_string(dest), + title: owned_opt_c_string(title), + alt, + alt_len, + } + } + MarkdownInlineFfi::Autolink { url, kind } => Self::Autolink { + url: owned_c_string(url), + kind: kind.into(), + }, + MarkdownInlineFfi::Math { content } => Self::Math { + content: owned_c_string(content), + }, + MarkdownInlineFfi::NostrMention { entity } => Self::NostrMention { + entity: entity.into(), + }, + MarkdownInlineFfi::NostrUri { entity } => Self::NostrUri { + entity: entity.into(), + }, + } + } +} + +impl CFree for MarmotMarkdownInline { + unsafe fn free_in_place(&mut self) { + match self { + Self::Text { content } | Self::Code { content } | Self::Math { content } => unsafe { + free_c_string(*content); + }, + Self::SoftBreak | Self::HardBreak => {} + Self::Emph { + children, + children_len, + } + | Self::Strong { + children, + children_len, + } + | Self::Strikethrough { + children, + children_len, + } => unsafe { free_vec(*children, *children_len) }, + Self::Link { + dest, + title, + children, + children_len, + } => unsafe { + free_c_string(*dest); + free_c_string(*title); + free_vec(*children, *children_len); + }, + Self::Image { + dest, + title, + alt, + alt_len, + } => unsafe { + free_c_string(*dest); + free_c_string(*title); + free_vec(*alt, *alt_len); + }, + Self::Autolink { url, .. } => unsafe { free_c_string(*url) }, + Self::NostrMention { entity } | Self::NostrUri { entity } => unsafe { + entity.free_in_place(); + }, + } + } +} + +/// Flavor of an autolink. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotMarkdownAutolinkKind { + Uri, + Email, +} + +impl From for MarmotMarkdownAutolinkKind { + fn from(value: MarkdownAutolinkKindFfi) -> Self { + match value { + MarkdownAutolinkKindFfi::Uri => Self::Uri, + MarkdownAutolinkKindFfi::Email => Self::Email, + } + } +} + +impl CFree for MarmotMarkdownAutolinkKind { + unsafe fn free_in_place(&mut self) {} +} + +/// A recognized Nostr entity reference inside message text. +#[repr(C)] +pub struct MarmotMarkdownNostrEntity { + pub hrp: MarmotMarkdownNostrHrp, + pub bech32: *mut c_char, +} + +impl From for MarmotMarkdownNostrEntity { + fn from(value: MarkdownNostrEntityFfi) -> Self { + Self { + hrp: value.hrp.into(), + bech32: owned_c_string(value.bech32), + } + } +} + +impl CFree for MarmotMarkdownNostrEntity { + unsafe fn free_in_place(&mut self) { + unsafe { free_c_string(self.bech32) }; + } +} + +/// Human-readable prefix of a bech32 Nostr entity. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotMarkdownNostrHrp { + Npub, + Note, + Nevent, + Nprofile, + Naddr, + Nrelay, +} + +impl From for MarmotMarkdownNostrHrp { + fn from(value: MarkdownNostrHrpFfi) -> Self { + match value { + MarkdownNostrHrpFfi::Npub => Self::Npub, + MarkdownNostrHrpFfi::Note => Self::Note, + MarkdownNostrHrpFfi::Nevent => Self::Nevent, + MarkdownNostrHrpFfi::Nprofile => Self::Nprofile, + MarkdownNostrHrpFfi::Naddr => Self::Naddr, + MarkdownNostrHrpFfi::Nrelay => Self::Nrelay, + } + } +} + +impl CFree for MarmotMarkdownNostrHrp { + unsafe fn free_in_place(&mut self) {} +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + + fn c_str_eq(ptr: *mut c_char, expected: &str) -> bool { + assert!(!ptr.is_null()); + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_str() + .expect("valid UTF-8") + == expected + } + + fn text(content: &str) -> MarkdownInlineFfi { + MarkdownInlineFfi::Text { + content: content.into(), + } + } + + fn slice<'a, T>(ptr: *mut T, len: usize) -> &'a [T] { + assert!(!ptr.is_null()); + unsafe { std::slice::from_raw_parts(ptr, len) } + } + + fn sample_document() -> MarkdownDocumentFfi { + MarkdownDocumentFfi { + blocks: vec![ + MarkdownBlockFfi::Heading { + level: 2, + inlines: vec![text("Burrow notes")], + }, + MarkdownBlockFfi::Paragraph { + inlines: vec![ + MarkdownInlineFfi::Link { + dest: "https://example.com".into(), + title: Some("example".into()), + children: vec![text("site")], + }, + MarkdownInlineFfi::NostrMention { + entity: MarkdownNostrEntityFfi { + hrp: MarkdownNostrHrpFfi::Npub, + bech32: "npub1abc".into(), + }, + }, + MarkdownInlineFfi::Code { + content: "cargo test".into(), + }, + ], + }, + MarkdownBlockFfi::ListBlock { + kind: MarkdownListKindFfi::Bullet { marker: "-".into() }, + tight: true, + items: vec![MarkdownListItemFfi { + blocks: vec![ + MarkdownBlockFfi::Paragraph { + inlines: vec![text("dig tunnel")], + }, + MarkdownBlockFfi::ListBlock { + kind: MarkdownListKindFfi::Ordered { + start: 3, + delimiter: ".".into(), + }, + tight: false, + items: vec![MarkdownListItemFfi { + blocks: vec![MarkdownBlockFfi::Paragraph { + inlines: vec![text("reinforce walls")], + }], + checked: Some(true), + }], + }, + ], + checked: None, + }], + }, + MarkdownBlockFfi::Table { + alignments: vec![MarkdownAlignmentFfi::Left, MarkdownAlignmentFfi::Right], + header: vec![ + MarkdownTableCellFfi { + inlines: vec![text("a")], + }, + MarkdownTableCellFfi { + inlines: vec![text("b")], + }, + ], + rows: vec![vec![ + MarkdownTableCellFfi { + inlines: vec![text("1")], + }, + MarkdownTableCellFfi { + inlines: vec![text("2")], + }, + ]], + }, + MarkdownBlockFfi::CodeBlock { + kind: MarkdownCodeBlockKindFfi::Fenced, + info: "rust".into(), + content: "fn main() {}".into(), + }, + ], + truncated: true, + } + } + + #[test] + fn document_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotMarkdownDocument = sample_document().into(); + assert!(mirror.truncated); + let blocks = slice(mirror.blocks, mirror.blocks_len); + assert_eq!(blocks.len(), 5); + + let MarmotMarkdownBlock::Heading { + level, + inlines, + inlines_len, + } = &blocks[0] + else { + panic!("expected heading"); + }; + assert_eq!(*level, 2); + let heading_inlines = slice(*inlines, *inlines_len); + let MarmotMarkdownInline::Text { content } = &heading_inlines[0] else { + panic!("expected text"); + }; + assert!(c_str_eq(*content, "Burrow notes")); + + let MarmotMarkdownBlock::Paragraph { + inlines, + inlines_len, + } = &blocks[1] + else { + panic!("expected paragraph"); + }; + let para = slice(*inlines, *inlines_len); + let MarmotMarkdownInline::Link { + dest, + title, + children, + children_len, + } = ¶[0] + else { + panic!("expected link"); + }; + assert!(c_str_eq(*dest, "https://example.com")); + assert!(c_str_eq(*title, "example")); + let link_children = slice(*children, *children_len); + assert!(matches!( + link_children[0], + MarmotMarkdownInline::Text { .. } + )); + let MarmotMarkdownInline::NostrMention { entity } = ¶[1] else { + panic!("expected nostr mention"); + }; + assert_eq!(entity.hrp, MarmotMarkdownNostrHrp::Npub); + assert!(c_str_eq(entity.bech32, "npub1abc")); + let MarmotMarkdownInline::Code { content } = ¶[2] else { + panic!("expected code span"); + }; + assert!(c_str_eq(*content, "cargo test")); + + let MarmotMarkdownBlock::ListBlock { + kind, + tight, + items, + items_len, + } = &blocks[2] + else { + panic!("expected list"); + }; + assert!(*tight); + let MarmotMarkdownListKind::Bullet { marker } = kind else { + panic!("expected bullet kind"); + }; + assert!(c_str_eq(*marker, "-")); + let items = slice(*items, *items_len); + assert!(!items[0].has_checked); + let item_blocks = slice(items[0].blocks, items[0].blocks_len); + let MarmotMarkdownBlock::ListBlock { + kind, + items, + items_len, + .. + } = &item_blocks[1] + else { + panic!("expected nested list"); + }; + let MarmotMarkdownListKind::Ordered { + start: ordered_start, + delimiter, + } = kind + else { + panic!("expected ordered kind"); + }; + assert_eq!(*ordered_start, 3); + assert!(c_str_eq(*delimiter, ".")); + let nested_items = slice(*items, *items_len); + assert!(nested_items[0].has_checked); + assert!(nested_items[0].checked); + + let MarmotMarkdownBlock::Table { + alignments, + alignments_len, + header, + header_len, + rows, + rows_len, + } = &blocks[3] + else { + panic!("expected table"); + }; + assert_eq!( + slice(*alignments, *alignments_len), + [ + MarmotMarkdownAlignment::Left, + MarmotMarkdownAlignment::Right + ] + ); + assert_eq!(*header_len, 2); + assert!(!header.is_null()); + let rows = slice(*rows, *rows_len); + assert_eq!(rows.len(), 1); + let cells = slice(rows[0].cells, rows[0].cells_len); + assert_eq!(cells.len(), 2); + let cell_inlines = slice(cells[1].inlines, cells[1].inlines_len); + let MarmotMarkdownInline::Text { content } = &cell_inlines[0] else { + panic!("expected cell text"); + }; + assert!(c_str_eq(*content, "2")); + + let MarmotMarkdownBlock::CodeBlock { + kind, + info, + content, + } = &blocks[4] + else { + panic!("expected code block"); + }; + assert_eq!(*kind, MarmotMarkdownCodeBlockKind::Fenced); + assert!(c_str_eq(*info, "rust")); + assert!(c_str_eq(*content, "fn main() {}")); + + let root = boxed(mirror); + unsafe { marmot_markdown_document_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn inline_variants_roundtrip_including_nested_emphasis() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let inlines = vec![ + MarkdownInlineFfi::Emph { + children: vec![MarkdownInlineFfi::Strong { + children: vec![text("deep")], + }], + }, + MarkdownInlineFfi::Autolink { + url: "https://marmot.example".into(), + kind: MarkdownAutolinkKindFfi::Uri, + }, + MarkdownInlineFfi::Image { + dest: "https://example.com/m.png".into(), + title: None, + alt: vec![text("marmot")], + }, + MarkdownInlineFfi::Strikethrough { + children: vec![text("gone")], + }, + MarkdownInlineFfi::HardBreak, + MarkdownInlineFfi::Math { + content: "e = mc^2".into(), + }, + ]; + let mirror: MarmotMarkdownDocument = MarkdownDocumentFfi { + blocks: vec![MarkdownBlockFfi::Paragraph { inlines }], + truncated: false, + } + .into(); + + let blocks = slice(mirror.blocks, mirror.blocks_len); + let MarmotMarkdownBlock::Paragraph { + inlines, + inlines_len, + } = &blocks[0] + else { + panic!("expected paragraph"); + }; + let para = slice(*inlines, *inlines_len); + + let MarmotMarkdownInline::Emph { + children, + children_len, + } = ¶[0] + else { + panic!("expected emph"); + }; + let emph_children = slice(*children, *children_len); + let MarmotMarkdownInline::Strong { + children, + children_len, + } = &emph_children[0] + else { + panic!("expected strong inside emph"); + }; + let strong_children = slice(*children, *children_len); + let MarmotMarkdownInline::Text { content } = &strong_children[0] else { + panic!("expected text inside strong"); + }; + assert!(c_str_eq(*content, "deep")); + + let MarmotMarkdownInline::Autolink { url, kind } = ¶[1] else { + panic!("expected autolink"); + }; + assert!(c_str_eq(*url, "https://marmot.example")); + assert_eq!(*kind, MarmotMarkdownAutolinkKind::Uri); + + let MarmotMarkdownInline::Image { + dest, + title, + alt, + alt_len, + } = ¶[2] + else { + panic!("expected image"); + }; + assert!(c_str_eq(*dest, "https://example.com/m.png")); + assert!(title.is_null()); + assert_eq!(slice(*alt, *alt_len).len(), 1); + + assert!(matches!( + para[3], + MarmotMarkdownInline::Strikethrough { .. } + )); + assert!(matches!(para[4], MarmotMarkdownInline::HardBreak)); + let MarmotMarkdownInline::Math { content } = ¶[5] else { + panic!("expected math"); + }; + assert!(c_str_eq(*content, "e = mc^2")); + + let root = boxed(mirror); + unsafe { marmot_markdown_document_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn empty_document_and_none_fields_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + let mirror: MarmotMarkdownDocument = MarkdownDocumentFfi { + blocks: Vec::new(), + truncated: false, + } + .into(); + assert!(mirror.blocks.is_null()); + assert_eq!(mirror.blocks_len, 0); + assert!(!mirror.truncated); + let root = boxed(mirror); + unsafe { marmot_markdown_document_free(root) }; + + let mut link: MarmotMarkdownInline = MarkdownInlineFfi::Link { + dest: "https://example.com".into(), + title: None, + children: Vec::new(), + } + .into(); + let MarmotMarkdownInline::Link { + title, + children, + children_len, + .. + } = &link + else { + panic!("expected link"); + }; + assert!(title.is_null()); + assert!(children.is_null()); + assert_eq!(*children_len, 0); + unsafe { link.free_in_place() }; + } +} diff --git a/crates/marmot-c/src/types/media.rs b/crates/marmot-c/src/types/media.rs new file mode 100644 index 00000000..52b7a23f --- /dev/null +++ b/crates/marmot-c/src/types/media.rs @@ -0,0 +1,723 @@ +//! C mirrors of the media conversions (`marmot-uniffi/src/conversions/media.rs`). + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{ + MediaAttachmentReferenceFfi, MediaDownloadResultFfi, MediaLocatorFfi, MediaRecordFfi, + MediaUploadAttachmentRequestFfi, MediaUploadAttachmentResultFfi, MediaUploadRequestFfi, + MediaUploadResultFfi, +}; + +use super::account::MarmotSendSummary; +use crate::MarmotStatus; +use crate::memory::{ + CFree, boxed_opt, free_boxed, free_c_string, free_vec, optional_str, owned_c_string, + owned_opt_c_string, owned_vec, required_str, +}; + +/// One place an encrypted media blob can be fetched from (e.g. a +/// `blossom-v1` URL). Used both inside owned attachment references returned +/// by this library and inside caller-owned input references (this library +/// never frees input structs). +#[repr(C)] +pub struct MarmotMediaLocator { + /// Locator scheme, e.g. `blossom-v1`. + pub kind: *mut c_char, + /// Scheme-specific location value, e.g. the blob URL. + pub value: *mut c_char, +} + +impl From for MarmotMediaLocator { + fn from(value: MediaLocatorFfi) -> Self { + Self { + kind: owned_c_string(value.kind), + value: owned_c_string(value.value), + } + } +} + +impl MarmotMediaLocator { + /// Read a caller-owned locator into the Ffi record without taking + /// ownership of any caller memory. + /// + /// # Safety + /// `kind` and `value` must be valid NUL-terminated strings. + pub(crate) unsafe fn to_ffi(&self) -> Result { + Ok(MediaLocatorFfi { + kind: unsafe { required_str(self.kind.cast_const()) }?, + value: unsafe { required_str(self.value.cast_const()) }?, + }) + } +} + +impl CFree for MarmotMediaLocator { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.kind); + free_c_string(self.value); + } + } +} + +/// Fully-downloadable reference to one encrypted media attachment. Used +/// both as a return value (owned, freed via its parent root) and as a +/// borrowed input to send/download commands (caller-owned; this library +/// never frees input structs). +#[repr(C)] +pub struct MarmotMediaAttachmentReference { + pub locators: *mut MarmotMediaLocator, + pub locators_len: usize, + /// SHA-256 of the uploaded ciphertext blob, hex-encoded. + pub ciphertext_sha256: *mut c_char, + /// SHA-256 of the original plaintext, hex-encoded. + pub plaintext_sha256: *mut c_char, + /// AEAD nonce, hex-encoded. + pub nonce_hex: *mut c_char, + pub file_name: *mut c_char, + /// MIME type of the plaintext, e.g. `image/png`. + pub media_type: *mut c_char, + /// Encrypted-media format version, e.g. `encrypted-media-v1`. + pub version: *mut c_char, + /// Group epoch whose media secret encrypted this attachment. + pub source_epoch: u64, + /// Pixel dimensions as `WxH`, when known. Nullable. + pub dim: *mut c_char, + /// Thumbhash preview string, when known. Nullable. + pub thumbhash: *mut c_char, +} + +impl From for MarmotMediaAttachmentReference { + fn from(value: MediaAttachmentReferenceFfi) -> Self { + let (locators, locators_len) = + owned_vec(value.locators.into_iter().map(Into::into).collect()); + Self { + locators, + locators_len, + ciphertext_sha256: owned_c_string(value.ciphertext_sha256), + plaintext_sha256: owned_c_string(value.plaintext_sha256), + nonce_hex: owned_c_string(value.nonce_hex), + file_name: owned_c_string(value.file_name), + media_type: owned_c_string(value.media_type), + version: owned_c_string(value.version), + source_epoch: value.source_epoch, + dim: owned_opt_c_string(value.dim), + thumbhash: owned_opt_c_string(value.thumbhash), + } + } +} + +impl MarmotMediaAttachmentReference { + /// Read a caller-owned attachment reference into the Ffi record without + /// taking ownership of any caller memory. + /// + /// # Safety + /// Every non-NULL string field must be a valid NUL-terminated string, + /// and when `locators` is non-NULL it must point to `locators_len` + /// valid locator structs. + pub(crate) unsafe fn to_ffi(&self) -> Result { + let locators = if self.locators.is_null() { + if self.locators_len != 0 { + crate::status::set_last_error("media locator array was NULL with nonzero length"); + return Err(MarmotStatus::NullPointer); + } + Vec::new() + } else { + let borrowed = unsafe { + std::slice::from_raw_parts(self.locators.cast_const(), self.locators_len) + }; + let mut locators = Vec::with_capacity(self.locators_len); + for locator in borrowed { + locators.push(unsafe { locator.to_ffi() }?); + } + locators + }; + Ok(MediaAttachmentReferenceFfi { + locators, + ciphertext_sha256: unsafe { required_str(self.ciphertext_sha256.cast_const()) }?, + plaintext_sha256: unsafe { required_str(self.plaintext_sha256.cast_const()) }?, + nonce_hex: unsafe { required_str(self.nonce_hex.cast_const()) }?, + file_name: unsafe { required_str(self.file_name.cast_const()) }?, + media_type: unsafe { required_str(self.media_type.cast_const()) }?, + version: unsafe { required_str(self.version.cast_const()) }?, + source_epoch: self.source_epoch, + dim: unsafe { optional_str(self.dim.cast_const()) }?, + thumbhash: unsafe { optional_str(self.thumbhash.cast_const()) }?, + }) + } +} + +impl CFree for MarmotMediaAttachmentReference { + unsafe fn free_in_place(&mut self) { + unsafe { + free_vec(self.locators, self.locators_len); + free_c_string(self.ciphertext_sha256); + free_c_string(self.plaintext_sha256); + free_c_string(self.nonce_hex); + free_c_string(self.file_name); + free_c_string(self.media_type); + free_c_string(self.version); + free_c_string(self.dim); + free_c_string(self.thumbhash); + } + } +} + +/// One plaintext attachment to encrypt and upload. Caller-owned input to +/// `marmot_upload_media`; this library never frees input structs. +#[repr(C)] +pub struct MarmotMediaUploadAttachmentRequest { + pub file_name: *mut c_char, + /// MIME type of the plaintext, e.g. `image/png`. + pub media_type: *mut c_char, + /// Plaintext bytes to encrypt and upload. NULL with `plaintext_len == 0` + /// is an empty payload. + pub plaintext: *mut u8, + pub plaintext_len: usize, + /// Pixel dimensions as `WxH`, when known. Nullable. + pub dim: *mut c_char, + /// Thumbhash preview string, when known. Nullable. + pub thumbhash: *mut c_char, +} + +impl From for MarmotMediaUploadAttachmentRequest { + fn from(value: MediaUploadAttachmentRequestFfi) -> Self { + let (plaintext, plaintext_len) = owned_vec(value.plaintext); + Self { + file_name: owned_c_string(value.file_name), + media_type: owned_c_string(value.media_type), + plaintext, + plaintext_len, + dim: owned_opt_c_string(value.dim), + thumbhash: owned_opt_c_string(value.thumbhash), + } + } +} + +impl MarmotMediaUploadAttachmentRequest { + /// Read a caller-owned upload attachment into the Ffi record without + /// taking ownership of any caller memory. + /// + /// # Safety + /// Every non-NULL string field must be a valid NUL-terminated string, + /// and when `plaintext` is non-NULL it must point to `plaintext_len` + /// readable bytes. + pub(crate) unsafe fn to_ffi(&self) -> Result { + let plaintext = if self.plaintext.is_null() { + if self.plaintext_len != 0 { + crate::status::set_last_error("plaintext buffer was NULL with nonzero length"); + return Err(MarmotStatus::NullPointer); + } + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(self.plaintext.cast_const(), self.plaintext_len) } + .to_vec() + }; + Ok(MediaUploadAttachmentRequestFfi { + file_name: unsafe { required_str(self.file_name.cast_const()) }?, + media_type: unsafe { required_str(self.media_type.cast_const()) }?, + plaintext, + dim: unsafe { optional_str(self.dim.cast_const()) }?, + thumbhash: unsafe { optional_str(self.thumbhash.cast_const()) }?, + }) + } +} + +impl CFree for MarmotMediaUploadAttachmentRequest { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.file_name); + free_c_string(self.media_type); + free_vec(self.plaintext, self.plaintext_len); + free_c_string(self.dim); + free_c_string(self.thumbhash); + } + } +} + +/// Batch upload request: encrypt plaintext attachments, upload the +/// ciphertext blobs, and optionally send the resulting references into the +/// group. Caller-owned input to `marmot_upload_media`; this library never +/// frees input structs. +#[repr(C)] +pub struct MarmotMediaUploadRequest { + pub attachments: *mut MarmotMediaUploadAttachmentRequest, + pub attachments_len: usize, + /// Optional chat caption to send alongside the attachments. Nullable. + pub caption: *mut c_char, + /// Whether to send the uploaded references into the group immediately. + pub send: bool, + /// Blossom server override. Nullable for the account default. + pub blossom_server: *mut c_char, +} + +impl From for MarmotMediaUploadRequest { + fn from(value: MediaUploadRequestFfi) -> Self { + let (attachments, attachments_len) = + owned_vec(value.attachments.into_iter().map(Into::into).collect()); + Self { + attachments, + attachments_len, + caption: owned_opt_c_string(value.caption), + send: value.send, + blossom_server: owned_opt_c_string(value.blossom_server), + } + } +} + +impl MarmotMediaUploadRequest { + /// Read a caller-owned upload request into the Ffi record without + /// taking ownership of any caller memory. + /// + /// # Safety + /// Every non-NULL string field must be a valid NUL-terminated string, + /// and when `attachments` is non-NULL it must point to + /// `attachments_len` valid attachment request structs. + pub(crate) unsafe fn to_ffi(&self) -> Result { + let attachments = if self.attachments.is_null() { + if self.attachments_len != 0 { + crate::status::set_last_error( + "media upload attachment array was NULL with nonzero length", + ); + return Err(MarmotStatus::NullPointer); + } + Vec::new() + } else { + let borrowed = unsafe { + std::slice::from_raw_parts(self.attachments.cast_const(), self.attachments_len) + }; + let mut attachments = Vec::with_capacity(self.attachments_len); + for attachment in borrowed { + attachments.push(unsafe { attachment.to_ffi() }?); + } + attachments + }; + Ok(MediaUploadRequestFfi { + attachments, + caption: unsafe { optional_str(self.caption.cast_const()) }?, + send: self.send, + blossom_server: unsafe { optional_str(self.blossom_server.cast_const()) }?, + }) + } +} + +impl CFree for MarmotMediaUploadRequest { + unsafe fn free_in_place(&mut self) { + unsafe { + free_vec(self.attachments, self.attachments_len); + free_c_string(self.caption); + free_c_string(self.blossom_server); + } + } +} + +/// One uploaded attachment inside an upload result: the sendable reference +/// plus the encrypted blob size. +#[repr(C)] +pub struct MarmotMediaUploadAttachmentResult { + pub reference: MarmotMediaAttachmentReference, + pub encrypted_size_bytes: u64, +} + +impl From for MarmotMediaUploadAttachmentResult { + fn from(value: MediaUploadAttachmentResultFfi) -> Self { + Self { + reference: value.reference.into(), + encrypted_size_bytes: value.encrypted_size_bytes, + } + } +} + +impl CFree for MarmotMediaUploadAttachmentResult { + unsafe fn free_in_place(&mut self) { + unsafe { self.reference.free_in_place() }; + } +} + +/// Result of `marmot_upload_media`: one entry per uploaded attachment, plus +/// the publish summary when the request asked to send. +#[repr(C)] +pub struct MarmotMediaUploadResult { + pub attachments: *mut MarmotMediaUploadAttachmentResult, + pub attachments_len: usize, + /// Publish summary when the request asked to send. Nullable. + pub sent: *mut MarmotSendSummary, +} + +impl From for MarmotMediaUploadResult { + fn from(value: MediaUploadResultFfi) -> Self { + let (attachments, attachments_len) = + owned_vec(value.attachments.into_iter().map(Into::into).collect()); + Self { + attachments, + attachments_len, + sent: boxed_opt(value.sent.map(Into::into)), + } + } +} + +impl CFree for MarmotMediaUploadResult { + unsafe fn free_in_place(&mut self) { + unsafe { + free_vec(self.attachments, self.attachments_len); + free_boxed(self.sent); + } + } +} + +/// Free an upload result root returned by `marmot_upload_media`. NULL is a +/// no-op. +/// +/// # Safety +/// `result` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_media_upload_result_free(result: *mut MarmotMediaUploadResult) { + crate::memory::free_guard(|| unsafe { free_boxed(result) }); +} + +/// Result of `marmot_download_media`: the decrypted plaintext plus its +/// original metadata. +#[repr(C)] +pub struct MarmotMediaDownloadResult { + /// Decrypted plaintext bytes. NULL with `plaintext_len == 0` when empty. + pub plaintext: *mut u8, + pub plaintext_len: usize, + pub file_name: *mut c_char, + /// MIME type of the plaintext, e.g. `image/png`. + pub media_type: *mut c_char, + /// Plaintext size in bytes. + pub size_bytes: u64, +} + +impl From for MarmotMediaDownloadResult { + fn from(value: MediaDownloadResultFfi) -> Self { + let (plaintext, plaintext_len) = owned_vec(value.plaintext); + Self { + plaintext, + plaintext_len, + file_name: owned_c_string(value.file_name), + media_type: owned_c_string(value.media_type), + size_bytes: value.size_bytes, + } + } +} + +impl CFree for MarmotMediaDownloadResult { + unsafe fn free_in_place(&mut self) { + unsafe { + free_vec(self.plaintext, self.plaintext_len); + free_c_string(self.file_name); + free_c_string(self.media_type); + } + } +} + +/// Free a download result root returned by `marmot_download_media`. NULL is +/// a no-op. +/// +/// # Safety +/// `result` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_media_download_result_free(result: *mut MarmotMediaDownloadResult) { + crate::memory::free_guard(|| unsafe { free_boxed(result) }); +} + +/// One media attachment projected from group message history. The embedded +/// `reference` can be passed back to `marmot_download_media`. +#[repr(C)] +pub struct MarmotMediaRecord { + pub message_id_hex: *mut c_char, + /// Zero-based position of this attachment within its carrying message. + pub attachment_index: u32, + /// Message direction, e.g. `incoming` or `outgoing`. + pub direction: *mut c_char, + pub group_id_hex: *mut c_char, + pub sender: *mut c_char, + pub reference: MarmotMediaAttachmentReference, + /// Chat caption carried alongside the attachment, when any. Nullable. + pub caption: *mut c_char, + pub recorded_at: u64, + pub received_at: u64, +} + +impl From for MarmotMediaRecord { + fn from(value: MediaRecordFfi) -> Self { + Self { + message_id_hex: owned_c_string(value.message_id_hex), + attachment_index: value.attachment_index, + direction: owned_c_string(value.direction), + group_id_hex: owned_c_string(value.group_id_hex), + sender: owned_c_string(value.sender), + reference: value.reference.into(), + caption: owned_opt_c_string(value.caption), + recorded_at: value.recorded_at, + received_at: value.received_at, + } + } +} + +impl CFree for MarmotMediaRecord { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.message_id_hex); + free_c_string(self.direction); + free_c_string(self.group_id_hex); + free_c_string(self.sender); + self.reference.free_in_place(); + free_c_string(self.caption); + } + } +} + +/// Owned list of media records (`marmot_list_media`). +#[repr(C)] +pub struct MarmotMediaRecordList { + pub items: *mut MarmotMediaRecord, + pub len: usize, +} + +impl From> for MarmotMediaRecordList { + fn from(value: Vec) -> Self { + let (items, len) = owned_vec(value.into_iter().map(Into::into).collect()); + Self { items, len } + } +} + +impl CFree for MarmotMediaRecordList { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.items, self.len) }; + } +} + +/// Free a list returned by `marmot_list_media`. NULL is a no-op. +/// +/// # Safety +/// `list` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_media_record_list_free(list: *mut MarmotMediaRecordList) { + crate::memory::free_guard(|| unsafe { free_boxed(list) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + use marmot_uniffi::conversions::SendSummaryFfi; + + fn sample_reference(byte: u8, file_name: &str) -> MediaAttachmentReferenceFfi { + MediaAttachmentReferenceFfi { + locators: vec![MediaLocatorFfi { + kind: "blossom-v1".into(), + value: format!("https://media.example/{byte:02x}.bin"), + }], + ciphertext_sha256: format!("{byte:02x}").repeat(32), + plaintext_sha256: format!("{:02x}", byte.wrapping_add(1)).repeat(32), + nonce_hex: format!("{byte:02x}").repeat(12), + file_name: file_name.into(), + media_type: "image/png".into(), + version: "encrypted-media-v1".into(), + source_epoch: 7, + dim: Some("800x600".into()), + thumbhash: Some("1QcSHQRnh493V4dIh4eXh1h4kJUI".into()), + } + } + + #[test] + fn media_upload_result_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotMediaUploadResult = MediaUploadResultFfi { + attachments: vec![ + MediaUploadAttachmentResultFfi { + reference: sample_reference(0x11, "diagram.png"), + encrypted_size_bytes: 2048, + }, + MediaUploadAttachmentResultFfi { + reference: sample_reference(0x22, "clip.mp4"), + encrypted_size_bytes: 4096, + }, + ], + sent: Some(SendSummaryFfi { + published: 1, + message_ids: vec!["ee".repeat(32)], + }), + } + .into(); + assert_eq!(mirror.attachments_len, 2); + let first = unsafe { &*mirror.attachments }; + assert_eq!(first.encrypted_size_bytes, 2048); + assert_eq!(first.reference.locators_len, 1); + assert_eq!(first.reference.source_epoch, 7); + assert!(!first.reference.dim.is_null()); + assert!(!mirror.sent.is_null()); + assert_eq!(unsafe { (*mirror.sent).published }, 1); + let root = boxed(mirror); + unsafe { marmot_media_upload_result_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn media_download_result_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotMediaDownloadResult = MediaDownloadResultFfi { + plaintext: vec![1, 2, 3, 4], + file_name: "diagram.png".into(), + media_type: "image/png".into(), + size_bytes: 4, + } + .into(); + assert_eq!(mirror.plaintext_len, 4); + let bytes = unsafe { std::slice::from_raw_parts(mirror.plaintext, mirror.plaintext_len) }; + assert_eq!(bytes, &[1, 2, 3, 4]); + assert_eq!(mirror.size_bytes, 4); + let root = boxed(mirror); + unsafe { marmot_media_download_result_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn media_record_list_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let list: MarmotMediaRecordList = vec![MediaRecordFfi { + message_id_hex: "aa".repeat(32), + attachment_index: 0, + direction: "incoming".into(), + group_id_hex: "bb".repeat(32), + sender: "alice".into(), + reference: sample_reference(0x33, "voice.ogg"), + caption: Some("album caption".into()), + recorded_at: 10, + received_at: 11, + }] + .into(); + assert_eq!(list.len, 1); + let record = unsafe { &*list.items }; + assert_eq!(record.attachment_index, 0); + assert!(!record.caption.is_null()); + assert_eq!(record.reference.locators_len, 1); + assert_eq!(record.recorded_at, 10); + let root = boxed(list); + unsafe { marmot_media_record_list_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn attachment_reference_input_roundtrips_borrowed_fields() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mut owned: MarmotMediaAttachmentReference = sample_reference(0x44, "brief.pdf").into(); + let ffi = unsafe { owned.to_ffi() }.expect("valid borrowed reference"); + assert_eq!(ffi.locators.len(), 1); + assert_eq!(ffi.locators[0].kind, "blossom-v1"); + assert_eq!(ffi.file_name, "brief.pdf"); + assert_eq!(ffi.source_epoch, 7); + assert_eq!(ffi.dim.as_deref(), Some("800x600")); + assert_eq!( + ffi.thumbhash.as_deref(), + Some("1QcSHQRnh493V4dIh4eXh1h4kJUI") + ); + unsafe { owned.free_in_place() }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn upload_request_input_roundtrips_borrowed_fields() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mut owned: MarmotMediaUploadRequest = MediaUploadRequestFfi { + attachments: vec![ + MediaUploadAttachmentRequestFfi { + file_name: "diagram.png".into(), + media_type: "image/png".into(), + plaintext: vec![9, 8, 7], + dim: Some("800x600".into()), + thumbhash: Some("abc".into()), + }, + MediaUploadAttachmentRequestFfi { + file_name: "clip.mp4".into(), + media_type: "video/mp4".into(), + plaintext: vec![1], + dim: None, + thumbhash: None, + }, + ], + caption: Some("two files".into()), + send: true, + blossom_server: Some("https://blossom.example".into()), + } + .into(); + let ffi = unsafe { owned.to_ffi() }.expect("valid borrowed request"); + assert_eq!(ffi.attachments.len(), 2); + assert_eq!(ffi.attachments[0].plaintext, vec![9, 8, 7]); + assert_eq!(ffi.attachments[0].dim.as_deref(), Some("800x600")); + assert_eq!(ffi.attachments[1].file_name, "clip.mp4"); + assert_eq!(ffi.attachments[1].dim, None); + assert_eq!(ffi.caption.as_deref(), Some("two files")); + assert!(ffi.send); + assert_eq!( + ffi.blossom_server.as_deref(), + Some("https://blossom.example") + ); + unsafe { owned.free_in_place() }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn empty_vecs_and_none_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + + let mirror: MarmotMediaUploadResult = MediaUploadResultFfi { + attachments: Vec::new(), + sent: None, + } + .into(); + assert!(mirror.attachments.is_null()); + assert_eq!(mirror.attachments_len, 0); + assert!(mirror.sent.is_null()); + let root = boxed(mirror); + unsafe { marmot_media_upload_result_free(root) }; + + let mut request: MarmotMediaUploadRequest = MediaUploadRequestFfi { + attachments: Vec::new(), + caption: None, + send: false, + blossom_server: None, + } + .into(); + assert!(request.attachments.is_null()); + assert!(request.caption.is_null()); + assert!(request.blossom_server.is_null()); + let ffi = unsafe { request.to_ffi() }.expect("empty request reads back"); + assert!(ffi.attachments.is_empty()); + assert_eq!(ffi.caption, None); + assert_eq!(ffi.blossom_server, None); + unsafe { request.free_in_place() }; + + let list: MarmotMediaRecordList = Vec::::new().into(); + assert!(list.items.is_null()); + assert_eq!(list.len, 0); + let root = boxed(list); + unsafe { marmot_media_record_list_free(root) }; + } +} diff --git a/crates/marmot-c/src/types/message.rs b/crates/marmot-c/src/types/message.rs new file mode 100644 index 00000000..e30b48a5 --- /dev/null +++ b/crates/marmot-c/src/types/message.rs @@ -0,0 +1,557 @@ +//! C mirrors of the message conversions (`marmot-uniffi/src/conversions/message.rs`): +//! stored message records, secure-delete results, and the unified +//! message-subscription update payload. + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{ + AppMessageRecordFfi, MessageUpdateFfi, ReceivedMessageFfi, RuntimeMessageReceivedFfi, + SecureDeleteExpiredResultFfi, +}; + +use crate::memory::{ + CFree, free_boxed, free_c_string, free_vec, owned_c_string, owned_opt_c_string, owned_vec, +}; +use crate::types::common::MarmotMessageTag; +use crate::types::markdown::MarmotMarkdownDocument; + +/// Convert the inner-event tags into an owned `(ptr, len)` pair. +fn owned_tags( + values: Vec, +) -> (*mut MarmotMessageTag, usize) { + owned_vec(values.into_iter().map(Into::into).collect()) +} + +/// One stored application message row. +#[repr(C)] +pub struct MarmotAppMessageRecord { + pub message_id_hex: *mut c_char, + pub direction: *mut c_char, + pub group_id_hex: *mut c_char, + pub sender: *mut c_char, + pub plaintext: *mut c_char, + /// Parsed Markdown of `plaintext` for chat-shaped kinds; empty for + /// non-chat kinds. Owned by this record and freed with it. + pub content_tokens: MarmotMarkdownDocument, + /// Nostr `kind` of the inner Marmot app event (9 chat, 7 reaction, ...). + pub kind: u64, + /// Nostr `tags` of the inner Marmot app event. + pub tags: *mut MarmotMessageTag, + pub tags_len: usize, + pub recorded_at: u64, + pub received_at: u64, +} + +impl From for MarmotAppMessageRecord { + fn from(value: AppMessageRecordFfi) -> Self { + let (tags, tags_len) = owned_tags(value.tags); + Self { + message_id_hex: owned_c_string(value.message_id_hex), + direction: owned_c_string(value.direction), + group_id_hex: owned_c_string(value.group_id_hex), + sender: owned_c_string(value.sender), + plaintext: owned_c_string(value.plaintext), + content_tokens: value.content_tokens.into(), + kind: value.kind, + tags, + tags_len, + recorded_at: value.recorded_at, + received_at: value.received_at, + } + } +} + +impl CFree for MarmotAppMessageRecord { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.message_id_hex); + free_c_string(self.direction); + free_c_string(self.group_id_hex); + free_c_string(self.sender); + free_c_string(self.plaintext); + self.content_tokens.free_in_place(); + free_vec(self.tags, self.tags_len); + } + } +} + +/// Free a single message record root (e.g. a subscription snapshot row). +/// NULL is a no-op. +/// +/// # Safety +/// `record` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_app_message_record_free(record: *mut MarmotAppMessageRecord) { + crate::memory::free_guard(|| unsafe { free_boxed(record) }); +} + +/// Owned list of message records (`marmot_messages`). +#[repr(C)] +pub struct MarmotAppMessageRecordList { + pub items: *mut MarmotAppMessageRecord, + pub len: usize, +} + +impl From> for MarmotAppMessageRecordList { + fn from(value: Vec) -> Self { + let (items, len) = owned_vec(value.into_iter().map(Into::into).collect()); + Self { items, len } + } +} + +impl CFree for MarmotAppMessageRecordList { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.items, self.len) }; + } +} + +/// Free a list returned by `marmot_messages`. NULL is a no-op. +/// +/// # Safety +/// `list` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_app_message_record_list_free( + list: *mut MarmotAppMessageRecordList, +) { + crate::memory::free_guard(|| unsafe { free_boxed(list) }); +} + +/// Result of pruning expired disappearing messages: the number of rows +/// removed and the ciphertext hashes of media blobs that became orphaned. +#[repr(C)] +pub struct MarmotSecureDeleteExpiredResult { + pub pruned_messages: u64, + /// SHA-256 hex digests of media ciphertexts no longer referenced by any + /// surviving message. + pub media_ciphertext_sha256: *mut *mut c_char, + pub media_ciphertext_sha256_len: usize, +} + +impl From for MarmotSecureDeleteExpiredResult { + fn from(value: SecureDeleteExpiredResultFfi) -> Self { + let (media_ciphertext_sha256, media_ciphertext_sha256_len) = owned_vec( + value + .media_ciphertext_sha256 + .into_iter() + .map(owned_c_string) + .collect::>(), + ); + Self { + pruned_messages: value.pruned_messages, + media_ciphertext_sha256, + media_ciphertext_sha256_len, + } + } +} + +impl CFree for MarmotSecureDeleteExpiredResult { + unsafe fn free_in_place(&mut self) { + unsafe { + free_vec( + self.media_ciphertext_sha256, + self.media_ciphertext_sha256_len, + ); + } + } +} + +/// Free a secure-delete result root. NULL is a no-op. +/// +/// # Safety +/// `result` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_secure_delete_expired_result_free( + result: *mut MarmotSecureDeleteExpiredResult, +) { + crate::memory::free_guard(|| unsafe { free_boxed(result) }); +} + +/// One freshly delivered MLS application message. +#[repr(C)] +pub struct MarmotReceivedMessage { + pub message_id_hex: *mut c_char, + pub group_id_hex: *mut c_char, + pub sender: *mut c_char, + /// Sender's display name, when known. Nullable. + pub sender_display_name: *mut c_char, + pub plaintext: *mut c_char, + /// Parsed Markdown of `plaintext` for chat-shaped kinds; empty for + /// non-chat kinds. Owned by this record and freed with it. + pub content_tokens: MarmotMarkdownDocument, + /// Nostr `kind` of the inner Marmot app event. + pub kind: u64, + /// Nostr `tags` of the inner Marmot app event. + pub tags: *mut MarmotMessageTag, + pub tags_len: usize, + /// Source-event timestamp (seconds since epoch) for the MLS-delivered + /// message. Clients should sort the timeline by this value so chronology + /// reflects send time, not delivery time. Zero means the timestamp was + /// unavailable at decode time. + pub recorded_at: u64, +} + +impl From for MarmotReceivedMessage { + fn from(value: ReceivedMessageFfi) -> Self { + let (tags, tags_len) = owned_tags(value.tags); + Self { + message_id_hex: owned_c_string(value.message_id_hex), + group_id_hex: owned_c_string(value.group_id_hex), + sender: owned_c_string(value.sender), + sender_display_name: owned_opt_c_string(value.sender_display_name), + plaintext: owned_c_string(value.plaintext), + content_tokens: value.content_tokens.into(), + kind: value.kind, + tags, + tags_len, + recorded_at: value.recorded_at, + } + } +} + +impl CFree for MarmotReceivedMessage { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.message_id_hex); + free_c_string(self.group_id_hex); + free_c_string(self.sender); + free_c_string(self.sender_display_name); + free_c_string(self.plaintext); + self.content_tokens.free_in_place(); + free_vec(self.tags, self.tags_len); + } + } +} + +/// A received message together with the account it was delivered to. +#[repr(C)] +pub struct MarmotRuntimeMessageReceived { + pub account_id_hex: *mut c_char, + pub account_label: *mut c_char, + pub message: MarmotReceivedMessage, +} + +impl From for MarmotRuntimeMessageReceived { + fn from(value: RuntimeMessageReceivedFfi) -> Self { + Self { + account_id_hex: owned_c_string(value.account_id_hex), + account_label: owned_c_string(value.account_label), + message: value.message.into(), + } + } +} + +impl CFree for MarmotRuntimeMessageReceived { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.account_id_hex); + free_c_string(self.account_label); + self.message.free_in_place(); + } + } +} + +/// Free a runtime-message-received root. Never call on values embedded by +/// value inside another struct. NULL is a no-op. +/// +/// # Safety +/// `received` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_runtime_message_received_free( + received: *mut MarmotRuntimeMessageReceived, +) { + crate::memory::free_guard(|| unsafe { free_boxed(received) }); +} + +/// A unified update from a messages subscription. Each variant carries enough +/// context for host apps to update an in-memory timeline without holding +/// onto the underlying runtime types. +#[repr(C)] +pub enum MarmotMessageUpdate { + /// A raw message update: chat, reply, media, reaction, delete, or the + /// kind-9 stream-final. Materialized timeline pages also include + /// kind-1200 stream starts as timeline record rows. + Message { + received: MarmotRuntimeMessageReceived, + }, + /// A kind-1200 agent text stream start — the signal to open the QUIC + /// preview for raw message subscribers. Its stream id, route, and brokers + /// live on `received.message.tags`. + AgentStreamStarted { + received: MarmotRuntimeMessageReceived, + }, +} + +impl From for MarmotMessageUpdate { + fn from(value: MessageUpdateFfi) -> Self { + match value { + MessageUpdateFfi::Message { received } => Self::Message { + received: received.into(), + }, + MessageUpdateFfi::AgentStreamStarted { received } => Self::AgentStreamStarted { + received: received.into(), + }, + } + } +} + +impl CFree for MarmotMessageUpdate { + unsafe fn free_in_place(&mut self) { + match self { + Self::Message { received } | Self::AgentStreamStarted { received } => unsafe { + received.free_in_place(); + }, + } + } +} + +/// Free a message-subscription update root. NULL is a no-op. +/// +/// # Safety +/// `update` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_message_update_free(update: *mut MarmotMessageUpdate) { + crate::memory::free_guard(|| unsafe { free_boxed(update) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + use marmot_uniffi::conversions::MessageTagFfi; + use marmot_uniffi::{MarkdownBlockFfi, MarkdownDocumentFfi, MarkdownInlineFfi}; + + fn c_str_eq(ptr: *mut c_char, expected: &str) -> bool { + assert!(!ptr.is_null()); + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_str() + .expect("valid UTF-8") + == expected + } + + fn sample_tokens(text: &str) -> MarkdownDocumentFfi { + MarkdownDocumentFfi { + blocks: vec![MarkdownBlockFfi::Paragraph { + inlines: vec![MarkdownInlineFfi::Text { + content: text.into(), + }], + }], + truncated: false, + } + } + + fn sample_tags() -> Vec { + vec![ + MessageTagFfi { + values: vec!["e".to_string(), "abcd1234".to_string()], + }, + MessageTagFfi { + values: vec!["q".to_string(), "parent".to_string()], + }, + ] + } + + fn sample_record() -> AppMessageRecordFfi { + AppMessageRecordFfi { + message_id_hex: "msg-1".to_string(), + direction: "outbound".to_string(), + group_id_hex: "aabb".to_string(), + sender: "alice".to_string(), + plaintext: "hello burrow".to_string(), + content_tokens: sample_tokens("hello burrow"), + kind: 9, + tags: sample_tags(), + recorded_at: 1_700_000_000, + received_at: 1_700_000_005, + } + } + + fn sample_received() -> ReceivedMessageFfi { + ReceivedMessageFfi { + message_id_hex: "msg-2".to_string(), + group_id_hex: "ccdd".to_string(), + sender: "bob".to_string(), + sender_display_name: Some("Bob".to_string()), + plaintext: "fresh dirt".to_string(), + content_tokens: sample_tokens("fresh dirt"), + kind: 9, + tags: sample_tags(), + recorded_at: 1_700_000_010, + } + } + + fn sample_runtime_received() -> RuntimeMessageReceivedFfi { + RuntimeMessageReceivedFfi { + account_id_hex: "eeff".to_string(), + account_label: "primary".to_string(), + message: sample_received(), + } + } + + #[test] + fn app_message_record_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotAppMessageRecord = sample_record().into(); + assert!(c_str_eq(mirror.message_id_hex, "msg-1")); + assert!(c_str_eq(mirror.direction, "outbound")); + assert!(c_str_eq(mirror.group_id_hex, "aabb")); + assert!(c_str_eq(mirror.sender, "alice")); + assert!(c_str_eq(mirror.plaintext, "hello burrow")); + assert_eq!(mirror.content_tokens.blocks_len, 1); + assert!(!mirror.content_tokens.blocks.is_null()); + assert_eq!(mirror.kind, 9); + assert_eq!(mirror.tags_len, 2); + let tags = unsafe { std::slice::from_raw_parts(mirror.tags, mirror.tags_len) }; + assert_eq!(tags[0].values_len, 2); + assert!(c_str_eq(unsafe { *tags[0].values }, "e")); + assert_eq!(mirror.recorded_at, 1_700_000_000); + assert_eq!(mirror.received_at, 1_700_000_005); + let root = boxed(mirror); + unsafe { marmot_app_message_record_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn app_message_record_list_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let list: MarmotAppMessageRecordList = vec![sample_record(), sample_record()].into(); + assert_eq!(list.len, 2); + assert!(!list.items.is_null()); + let items = unsafe { std::slice::from_raw_parts(list.items, list.len) }; + assert!(c_str_eq(items[1].message_id_hex, "msg-1")); + let root = boxed(list); + unsafe { marmot_app_message_record_list_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn secure_delete_expired_result_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotSecureDeleteExpiredResult = SecureDeleteExpiredResultFfi { + pruned_messages: 4, + media_ciphertext_sha256: vec!["aa11".to_string(), "bb22".to_string()], + } + .into(); + assert_eq!(mirror.pruned_messages, 4); + assert_eq!(mirror.media_ciphertext_sha256_len, 2); + assert!(!mirror.media_ciphertext_sha256.is_null()); + assert!(c_str_eq( + unsafe { *mirror.media_ciphertext_sha256.add(1) }, + "bb22" + )); + let root = boxed(mirror); + unsafe { marmot_secure_delete_expired_result_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn runtime_message_received_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotRuntimeMessageReceived = sample_runtime_received().into(); + assert!(c_str_eq(mirror.account_id_hex, "eeff")); + assert!(c_str_eq(mirror.account_label, "primary")); + assert!(c_str_eq(mirror.message.message_id_hex, "msg-2")); + assert!(c_str_eq(mirror.message.sender_display_name, "Bob")); + assert_eq!(mirror.message.content_tokens.blocks_len, 1); + assert_eq!(mirror.message.tags_len, 2); + assert_eq!(mirror.message.recorded_at, 1_700_000_010); + let root = boxed(mirror); + unsafe { marmot_runtime_message_received_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn message_update_roundtrips_both_variants() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let message: MarmotMessageUpdate = MessageUpdateFfi::Message { + received: sample_runtime_received(), + } + .into(); + let MarmotMessageUpdate::Message { received } = &message else { + panic!("expected message variant"); + }; + assert!(c_str_eq(received.account_id_hex, "eeff")); + assert!(c_str_eq(received.message.plaintext, "fresh dirt")); + let root = boxed(message); + unsafe { marmot_message_update_free(root) }; + + let stream: MarmotMessageUpdate = MessageUpdateFfi::AgentStreamStarted { + received: sample_runtime_received(), + } + .into(); + let MarmotMessageUpdate::AgentStreamStarted { received } = &stream else { + panic!("expected agent-stream-started variant"); + }; + assert!(c_str_eq(received.account_label, "primary")); + assert_eq!(received.message.tags_len, 2); + let root = boxed(stream); + unsafe { marmot_message_update_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn empty_vecs_and_none_fields_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + let list: MarmotAppMessageRecordList = Vec::::new().into(); + assert!(list.items.is_null()); + assert_eq!(list.len, 0); + let root = boxed(list); + unsafe { marmot_app_message_record_list_free(root) }; + + let result: MarmotSecureDeleteExpiredResult = SecureDeleteExpiredResultFfi { + pruned_messages: 0, + media_ciphertext_sha256: Vec::new(), + } + .into(); + assert!(result.media_ciphertext_sha256.is_null()); + assert_eq!(result.media_ciphertext_sha256_len, 0); + let root = boxed(result); + unsafe { marmot_secure_delete_expired_result_free(root) }; + + let mut received: MarmotReceivedMessage = ReceivedMessageFfi { + message_id_hex: "msg-3".to_string(), + group_id_hex: "0011".to_string(), + sender: "carol".to_string(), + sender_display_name: None, + plaintext: String::new(), + content_tokens: MarkdownDocumentFfi { + blocks: Vec::new(), + truncated: false, + }, + kind: 7, + tags: Vec::new(), + recorded_at: 0, + } + .into(); + assert!(received.sender_display_name.is_null()); + assert!(received.content_tokens.blocks.is_null()); + assert_eq!(received.content_tokens.blocks_len, 0); + assert!(received.tags.is_null()); + assert_eq!(received.tags_len, 0); + unsafe { received.free_in_place() }; + } +} diff --git a/crates/marmot-c/src/types/mod.rs b/crates/marmot-c/src/types/mod.rs new file mode 100644 index 00000000..6d440cf2 --- /dev/null +++ b/crates/marmot-c/src/types/mod.rs @@ -0,0 +1,32 @@ +//! `#[repr(C)]` mirrors of the `marmot-uniffi` FFI value types. +//! +//! One module per `marmot-uniffi` conversions module. Every mirror follows +//! the same mapping rules (see `AGENTS.md`): +//! +//! - `String` → owned `*mut c_char`; `Option` → nullable pointer. +//! - `Vec` → owned `(ptr, len)`; empty is `(NULL, 0)`. +//! - `Option` → nullable owned pointer; `Option` → +//! `has_x: bool` + value field. +//! - Fieldless enums → `#[repr(C)]` enums; payload enums → `#[repr(C)]` +//! Rust enums (tag + union in the generated header). +//! - Each mirror implements `From<…Ffi>` (allocating) and `CFree` +//! (deep-free). Types returned across the ABI as ownership roots get a +//! public `marmot_*_free` function; commands returning `Vec` return a +//! `Marmot…List` root instead of a bare pair. +//! - Input records additionally implement a borrowed `to_ffi` reader that +//! never takes ownership of caller memory. + +pub mod account; +pub mod agent_stream; +pub mod audit; +pub mod chat_list; +pub mod common; +pub mod event; +pub mod group; +pub mod markdown; +pub mod media; +pub mod message; +pub mod notification; +pub mod push; +pub mod relay; +pub mod timeline; diff --git a/crates/marmot-c/src/types/notification.rs b/crates/marmot-c/src/types/notification.rs new file mode 100644 index 00000000..562aebe1 --- /dev/null +++ b/crates/marmot-c/src/types/notification.rs @@ -0,0 +1,446 @@ +//! C mirrors of the notification conversions +//! (`marmot-uniffi/src/conversions/notification.rs`). + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{ + BackgroundNotificationCollectionFfi, NotificationCollectionStatusFfi, NotificationSettingsFfi, + NotificationTriggerFfi, NotificationUpdateFfi, NotificationUserFfi, NotificationWakeSourceFfi, +}; + +use crate::memory::{ + CFree, free_boxed, free_c_string, free_vec, owned_c_string, owned_opt_c_string, owned_vec, +}; + +/// Platform mechanism that woke the process for background notification +/// collection. Borrowed input to `marmot_collect_notifications_after_wake`. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotNotificationWakeSource { + /// iOS APNs Notification Service Extension wake. + ApnsNse, + /// Android FCM data-message wake. + FcmDataMessage, + /// Android foreground-service driven collection. + AndroidForegroundService, + /// Explicit user- or app-initiated catch-up. + ManualCatchUp, +} + +impl From for MarmotNotificationWakeSource { + fn from(value: NotificationWakeSourceFfi) -> Self { + match value { + NotificationWakeSourceFfi::ApnsNse => Self::ApnsNse, + NotificationWakeSourceFfi::FcmDataMessage => Self::FcmDataMessage, + NotificationWakeSourceFfi::AndroidForegroundService => Self::AndroidForegroundService, + NotificationWakeSourceFfi::ManualCatchUp => Self::ManualCatchUp, + } + } +} + +impl MarmotNotificationWakeSource { + /// Read a caller-provided wake source into the Ffi enum. Infallible. + pub(crate) fn to_ffi(self) -> NotificationWakeSourceFfi { + match self { + Self::ApnsNse => NotificationWakeSourceFfi::ApnsNse, + Self::FcmDataMessage => NotificationWakeSourceFfi::FcmDataMessage, + Self::AndroidForegroundService => NotificationWakeSourceFfi::AndroidForegroundService, + Self::ManualCatchUp => NotificationWakeSourceFfi::ManualCatchUp, + } + } +} + +impl CFree for MarmotNotificationWakeSource { + unsafe fn free_in_place(&mut self) {} +} + +/// Overall outcome of a background notification collection pass. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotNotificationCollectionStatus { + /// New notifications were collected. + NewData, + /// The pass completed but nothing new arrived. + NoData, + /// The pass failed; see the collection's `error` field. + Failed, +} + +impl From for MarmotNotificationCollectionStatus { + fn from(value: NotificationCollectionStatusFfi) -> Self { + match value { + NotificationCollectionStatusFfi::NewData => Self::NewData, + NotificationCollectionStatusFfi::NoData => Self::NoData, + NotificationCollectionStatusFfi::Failed => Self::Failed, + } + } +} + +impl CFree for MarmotNotificationCollectionStatus { + unsafe fn free_in_place(&mut self) {} +} + +/// What kind of event a notification update describes. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotNotificationTrigger { + /// A new message arrived in a group or DM. + NewMessage, + /// The account was invited to a group. + GroupInvite, +} + +impl From for MarmotNotificationTrigger { + fn from(value: NotificationTriggerFfi) -> Self { + match value { + NotificationTriggerFfi::NewMessage => Self::NewMessage, + NotificationTriggerFfi::GroupInvite => Self::GroupInvite, + } + } +} + +impl CFree for MarmotNotificationTrigger { + unsafe fn free_in_place(&mut self) {} +} + +/// Per-account notification preferences. +#[repr(C)] +pub struct MarmotNotificationSettings { + pub account_ref: *mut c_char, + pub account_id_hex: *mut c_char, + /// Whether locally rendered notifications are enabled. + pub local_notifications_enabled: bool, + /// Whether native push (APNs/FCM) delivery is enabled. + pub native_push_enabled: bool, +} + +impl From for MarmotNotificationSettings { + fn from(value: NotificationSettingsFfi) -> Self { + Self { + account_ref: owned_c_string(value.account_ref), + account_id_hex: owned_c_string(value.account_id_hex), + local_notifications_enabled: value.local_notifications_enabled, + native_push_enabled: value.native_push_enabled, + } + } +} + +impl CFree for MarmotNotificationSettings { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.account_ref); + free_c_string(self.account_id_hex); + } + } +} + +/// Free a notification settings root. NULL is a no-op. +/// +/// # Safety +/// `settings` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_notification_settings_free( + settings: *mut MarmotNotificationSettings, +) { + crate::memory::free_guard(|| unsafe { free_boxed(settings) }); +} + +/// A user referenced by a notification (sender or receiver). +#[repr(C)] +pub struct MarmotNotificationUser { + pub account_id_hex: *mut c_char, + /// Display name, when known. Nullable. + pub display_name: *mut c_char, + /// Profile picture URL, when known. Nullable. + pub picture_url: *mut c_char, +} + +impl From for MarmotNotificationUser { + fn from(value: NotificationUserFfi) -> Self { + Self { + account_id_hex: owned_c_string(value.account_id_hex), + display_name: owned_opt_c_string(value.display_name), + picture_url: owned_opt_c_string(value.picture_url), + } + } +} + +impl CFree for MarmotNotificationUser { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.account_id_hex); + free_c_string(self.display_name); + free_c_string(self.picture_url); + } + } +} + +/// One notification-worthy event, delivered via the notification +/// subscription stream and inside background collections. +#[repr(C)] +pub struct MarmotNotificationUpdate { + /// Stable dedup key for this notification. + pub notification_key: *mut c_char, + /// Stable key identifying the conversation for grouping/threading. + pub conversation_key: *mut c_char, + pub trigger: MarmotNotificationTrigger, + pub account_ref: *mut c_char, + pub account_id_hex: *mut c_char, + /// Opaque MLS group id, hex-encoded (variable length; not a 32-byte + /// Nostr route id). + pub group_id_hex: *mut c_char, + /// Group display name, when known. Nullable. + pub group_name: *mut c_char, + pub is_dm: bool, + /// Whether the receiving account was mentioned. + pub is_mention: bool, + /// Message id, hex-encoded, when known. Nullable. + pub message_id_hex: *mut c_char, + pub sender: MarmotNotificationUser, + pub receiver: MarmotNotificationUser, + /// Short displayable preview of the message text. Nullable. + pub preview_text: *mut c_char, + /// Reaction emoji when this update is a reaction. Nullable. + pub reaction_emoji: *mut c_char, + /// Preview of the message that was reacted to. Nullable. + pub reacted_to_preview: *mut c_char, + /// Event timestamp in milliseconds since the Unix epoch. + pub timestamp_ms: i64, + /// Whether the event originated from the receiving account itself. + pub is_from_self: bool, +} + +impl From for MarmotNotificationUpdate { + fn from(value: NotificationUpdateFfi) -> Self { + Self { + notification_key: owned_c_string(value.notification_key), + conversation_key: owned_c_string(value.conversation_key), + trigger: value.trigger.into(), + account_ref: owned_c_string(value.account_ref), + account_id_hex: owned_c_string(value.account_id_hex), + group_id_hex: owned_c_string(value.group_id_hex), + group_name: owned_opt_c_string(value.group_name), + is_dm: value.is_dm, + is_mention: value.is_mention, + message_id_hex: owned_opt_c_string(value.message_id_hex), + sender: value.sender.into(), + receiver: value.receiver.into(), + preview_text: owned_opt_c_string(value.preview_text), + reaction_emoji: owned_opt_c_string(value.reaction_emoji), + reacted_to_preview: owned_opt_c_string(value.reacted_to_preview), + timestamp_ms: value.timestamp_ms, + is_from_self: value.is_from_self, + } + } +} + +impl CFree for MarmotNotificationUpdate { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.notification_key); + free_c_string(self.conversation_key); + free_c_string(self.account_ref); + free_c_string(self.account_id_hex); + free_c_string(self.group_id_hex); + free_c_string(self.group_name); + free_c_string(self.message_id_hex); + self.sender.free_in_place(); + self.receiver.free_in_place(); + free_c_string(self.preview_text); + free_c_string(self.reaction_emoji); + free_c_string(self.reacted_to_preview); + } + } +} + +/// Free a notification update root (delivered by the notification +/// subscription). NULL is a no-op. +/// +/// # Safety +/// `update` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_notification_update_free(update: *mut MarmotNotificationUpdate) { + crate::memory::free_guard(|| unsafe { free_boxed(update) }); +} + +/// Result of a background collection pass +/// (`marmot_collect_notifications_after_wake`). +#[repr(C)] +pub struct MarmotBackgroundNotificationCollection { + pub status: MarmotNotificationCollectionStatus, + pub notifications: *mut MarmotNotificationUpdate, + pub notifications_len: usize, + /// Failure detail when `status` is `Failed`. Nullable. + pub error: *mut c_char, +} + +impl From for MarmotBackgroundNotificationCollection { + fn from(value: BackgroundNotificationCollectionFfi) -> Self { + let (notifications, notifications_len) = + owned_vec(value.notifications.into_iter().map(Into::into).collect()); + Self { + status: value.status.into(), + notifications, + notifications_len, + error: owned_opt_c_string(value.error), + } + } +} + +impl CFree for MarmotBackgroundNotificationCollection { + unsafe fn free_in_place(&mut self) { + unsafe { + free_vec(self.notifications, self.notifications_len); + free_c_string(self.error); + } + } +} + +/// Free a background collection root. NULL is a no-op. +/// +/// # Safety +/// `collection` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_background_notification_collection_free( + collection: *mut MarmotBackgroundNotificationCollection, +) { + crate::memory::free_guard(|| unsafe { free_boxed(collection) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + + fn sample_update() -> NotificationUpdateFfi { + NotificationUpdateFfi { + notification_key: "notif-1".into(), + conversation_key: "conv-1".into(), + trigger: NotificationTriggerFfi::NewMessage, + account_ref: "primary".into(), + account_id_hex: "aa11".into(), + group_id_hex: "bb22".into(), + group_name: Some("Burrow".into()), + is_dm: false, + is_mention: true, + message_id_hex: Some("cc33".into()), + sender: NotificationUserFfi { + account_id_hex: "dd44".into(), + display_name: Some("Marmy".into()), + picture_url: Some("https://example.invalid/m.png".into()), + }, + receiver: NotificationUserFfi { + account_id_hex: "ee55".into(), + display_name: Some("Whistler".into()), + picture_url: Some("https://example.invalid/w.png".into()), + }, + preview_text: Some("hello burrow".into()), + reaction_emoji: Some("🎉".into()), + reacted_to_preview: Some("earlier message".into()), + timestamp_ms: 1_700_000_000_123, + is_from_self: false, + } + } + + #[test] + fn notification_settings_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotNotificationSettings = NotificationSettingsFfi { + account_ref: "primary".into(), + account_id_hex: "aa11".into(), + local_notifications_enabled: true, + native_push_enabled: false, + } + .into(); + assert!(!mirror.account_ref.is_null()); + assert!(!mirror.account_id_hex.is_null()); + assert!(mirror.local_notifications_enabled); + assert!(!mirror.native_push_enabled); + let root = boxed(mirror); + unsafe { marmot_notification_settings_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn notification_update_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotNotificationUpdate = sample_update().into(); + assert_eq!(mirror.trigger, MarmotNotificationTrigger::NewMessage); + assert!(!mirror.notification_key.is_null()); + assert!(!mirror.group_name.is_null()); + assert!(!mirror.message_id_hex.is_null()); + assert!(!mirror.sender.display_name.is_null()); + assert!(!mirror.receiver.picture_url.is_null()); + assert!(!mirror.reaction_emoji.is_null()); + assert_eq!(mirror.timestamp_ms, 1_700_000_000_123); + assert!(mirror.is_mention); + let root = boxed(mirror); + unsafe { marmot_notification_update_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn background_collection_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotBackgroundNotificationCollection = BackgroundNotificationCollectionFfi { + status: NotificationCollectionStatusFfi::NewData, + notifications: vec![sample_update(), sample_update()], + error: Some("partial relay timeout".into()), + } + .into(); + assert_eq!(mirror.status, MarmotNotificationCollectionStatus::NewData); + assert_eq!(mirror.notifications_len, 2); + assert!(!mirror.notifications.is_null()); + assert!(!mirror.error.is_null()); + let root = boxed(mirror); + unsafe { marmot_background_notification_collection_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn empty_vec_and_none_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + let mirror: MarmotBackgroundNotificationCollection = BackgroundNotificationCollectionFfi { + status: NotificationCollectionStatusFfi::NoData, + notifications: Vec::new(), + error: None, + } + .into(); + assert_eq!(mirror.status, MarmotNotificationCollectionStatus::NoData); + assert!(mirror.notifications.is_null()); + assert_eq!(mirror.notifications_len, 0); + assert!(mirror.error.is_null()); + let root = boxed(mirror); + unsafe { marmot_background_notification_collection_free(root) }; + } + + #[test] + fn wake_source_to_ffi_covers_all_variants() { + let _guard = crate::memory::audit::test_lock(); + let sources = [ + MarmotNotificationWakeSource::ApnsNse, + MarmotNotificationWakeSource::FcmDataMessage, + MarmotNotificationWakeSource::AndroidForegroundService, + MarmotNotificationWakeSource::ManualCatchUp, + ]; + for source in sources { + let back: MarmotNotificationWakeSource = source.to_ffi().into(); + assert_eq!(back, source); + } + } +} diff --git a/crates/marmot-c/src/types/push.rs b/crates/marmot-c/src/types/push.rs new file mode 100644 index 00000000..387648d2 --- /dev/null +++ b/crates/marmot-c/src/types/push.rs @@ -0,0 +1,402 @@ +//! C mirrors of the push conversions (`marmot-uniffi/src/conversions/push.rs`). + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{ + GroupPushDebugInfoFfi, GroupPushTokenDebugEntryFfi, LocalPushRegistrationDebugFfi, + PushPlatformFfi, PushRegistrationFfi, +}; + +use crate::memory::{ + CFree, free_boxed, free_c_string, free_vec, owned_c_string, owned_opt_c_string, owned_vec, +}; + +/// Native push platform a device token belongs to. Used both as a return +/// field and as a borrowed input to `marmot_upsert_push_registration`. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotPushPlatform { + /// Apple Push Notification service. + Apns, + /// Firebase Cloud Messaging. + Fcm, +} + +impl From for MarmotPushPlatform { + fn from(value: PushPlatformFfi) -> Self { + match value { + PushPlatformFfi::Apns => Self::Apns, + PushPlatformFfi::Fcm => Self::Fcm, + } + } +} + +impl MarmotPushPlatform { + /// Read a caller-supplied platform value into the Ffi enum. Infallible; + /// borrows nothing. + pub(crate) fn to_ffi(self) -> PushPlatformFfi { + match self { + Self::Apns => PushPlatformFfi::Apns, + Self::Fcm => PushPlatformFfi::Fcm, + } + } +} + +impl CFree for MarmotPushPlatform { + unsafe fn free_in_place(&mut self) {} +} + +/// One account's local native-push registration +/// (`marmot_push_registration`, `marmot_upsert_push_registration`). +#[repr(C)] +pub struct MarmotPushRegistration { + /// Account label the registration belongs to. + pub account_ref: *mut c_char, + pub account_id_hex: *mut c_char, + pub platform: MarmotPushPlatform, + /// Privacy-safe fingerprint of the raw device token; the raw token is + /// never returned. + pub token_fingerprint: *mut c_char, + /// Push-server pubkey the token was encrypted to. + pub server_pubkey_hex: *mut c_char, + /// Optional preferred relay for push delivery. Nullable. + pub relay_hint: *mut c_char, + pub created_at_ms: i64, + pub updated_at_ms: i64, + /// When the token was last shared into a group token list. `has_*` + /// false means never shared. + pub has_last_shared_at_ms: bool, + pub last_shared_at_ms: i64, +} + +impl From for MarmotPushRegistration { + fn from(value: PushRegistrationFfi) -> Self { + Self { + account_ref: owned_c_string(value.account_ref), + account_id_hex: owned_c_string(value.account_id_hex), + platform: value.platform.into(), + token_fingerprint: owned_c_string(value.token_fingerprint), + server_pubkey_hex: owned_c_string(value.server_pubkey_hex), + relay_hint: owned_opt_c_string(value.relay_hint), + created_at_ms: value.created_at_ms, + updated_at_ms: value.updated_at_ms, + has_last_shared_at_ms: value.last_shared_at_ms.is_some(), + last_shared_at_ms: value.last_shared_at_ms.unwrap_or_default(), + } + } +} + +impl CFree for MarmotPushRegistration { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.account_ref); + free_c_string(self.account_id_hex); + free_c_string(self.token_fingerprint); + free_c_string(self.server_pubkey_hex); + free_c_string(self.relay_hint); + } + } +} + +/// Free a push registration root. NULL is a no-op. +/// +/// # Safety +/// `registration` must be NULL or an unfreed pointer returned by this +/// library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_push_registration_free(registration: *mut MarmotPushRegistration) { + crate::memory::free_guard(|| unsafe { free_boxed(registration) }); +} + +/// Local-member registration state inside a group push-debug snapshot. +#[repr(C)] +pub struct MarmotLocalPushRegistrationDebug { + /// Whether the account has a local push registration at all. + pub registered: bool, + /// Whether the registration is complete enough to share into groups. + pub shareable: bool, + pub local_notifications_enabled: bool, + pub native_push_enabled: bool, + /// The local member's leaf index in the group, when known. `has_*` + /// false means unknown. + pub has_local_leaf_index: bool, + pub local_leaf_index: u32, + /// Whether a token for the local member is cached in the group's + /// token list. + pub local_token_cached: bool, +} + +impl From for MarmotLocalPushRegistrationDebug { + fn from(value: LocalPushRegistrationDebugFfi) -> Self { + Self { + registered: value.registered, + shareable: value.shareable, + local_notifications_enabled: value.local_notifications_enabled, + native_push_enabled: value.native_push_enabled, + has_local_leaf_index: value.local_leaf_index.is_some(), + local_leaf_index: value.local_leaf_index.unwrap_or_default(), + local_token_cached: value.local_token_cached, + } + } +} + +impl CFree for MarmotLocalPushRegistrationDebug { + unsafe fn free_in_place(&mut self) {} +} + +/// One member's token entry inside a group push-debug snapshot. +#[repr(C)] +pub struct MarmotGroupPushTokenDebugEntry { + pub member_id_hex: *mut c_char, + pub leaf_index: u32, + pub platform: MarmotPushPlatform, + /// Privacy-safe fingerprint of the member's device token. + pub token_fingerprint: *mut c_char, + pub server_pubkey_hex: *mut c_char, + pub has_relay_hint: bool, + /// Whether the leaf index is an active leaf in the current group state. + pub active_leaf: bool, + /// Whether the token's member id matches the member at that leaf. + pub member_matches_active_leaf: bool, + pub is_local_member: bool, + pub updated_at_ms: i64, +} + +impl From for MarmotGroupPushTokenDebugEntry { + fn from(value: GroupPushTokenDebugEntryFfi) -> Self { + Self { + member_id_hex: owned_c_string(value.member_id_hex), + leaf_index: value.leaf_index, + platform: value.platform.into(), + token_fingerprint: owned_c_string(value.token_fingerprint), + server_pubkey_hex: owned_c_string(value.server_pubkey_hex), + has_relay_hint: value.has_relay_hint, + active_leaf: value.active_leaf, + member_matches_active_leaf: value.member_matches_active_leaf, + is_local_member: value.is_local_member, + updated_at_ms: value.updated_at_ms, + } + } +} + +impl CFree for MarmotGroupPushTokenDebugEntry { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.member_id_hex); + free_c_string(self.token_fingerprint); + free_c_string(self.server_pubkey_hex); + } + } +} + +/// Aggregated push-token diagnostics for one group +/// (`marmot_group_push_debug_info`). +#[repr(C)] +pub struct MarmotGroupPushDebugInfo { + pub total_token_count: u32, + /// Tokens whose leaf is active and matches the member. + pub active_token_count: u32, + /// Tokens pointing at removed or mismatched leaves. + pub stale_token_count: u32, + pub missing_relay_hint_count: u32, + /// When the group token list was last updated. `has_*` false means + /// no token list has been seen. + pub has_last_token_list_updated_at_ms: bool, + pub last_token_list_updated_at_ms: i64, + pub local_registration: MarmotLocalPushRegistrationDebug, + pub tokens: *mut MarmotGroupPushTokenDebugEntry, + pub tokens_len: usize, +} + +impl From for MarmotGroupPushDebugInfo { + fn from(value: GroupPushDebugInfoFfi) -> Self { + let (tokens, tokens_len) = owned_vec(value.tokens.into_iter().map(Into::into).collect()); + Self { + total_token_count: value.total_token_count, + active_token_count: value.active_token_count, + stale_token_count: value.stale_token_count, + missing_relay_hint_count: value.missing_relay_hint_count, + has_last_token_list_updated_at_ms: value.last_token_list_updated_at_ms.is_some(), + last_token_list_updated_at_ms: value.last_token_list_updated_at_ms.unwrap_or_default(), + local_registration: value.local_registration.into(), + tokens, + tokens_len, + } + } +} + +impl CFree for MarmotGroupPushDebugInfo { + unsafe fn free_in_place(&mut self) { + unsafe { + free_vec(self.tokens, self.tokens_len); + self.local_registration.free_in_place(); + } + } +} + +/// Free a group push-debug info root. NULL is a no-op. +/// +/// # Safety +/// `info` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_group_push_debug_info_free(info: *mut MarmotGroupPushDebugInfo) { + crate::memory::free_guard(|| unsafe { free_boxed(info) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + + fn sample_registration() -> PushRegistrationFfi { + PushRegistrationFfi { + account_ref: "primary".into(), + account_id_hex: "aa11".into(), + platform: PushPlatformFfi::Apns, + token_fingerprint: "fp01".into(), + server_pubkey_hex: "bb22".into(), + relay_hint: Some("wss://relay.example".into()), + created_at_ms: 1_000, + updated_at_ms: 2_000, + last_shared_at_ms: Some(3_000), + } + } + + fn sample_debug_info() -> GroupPushDebugInfoFfi { + GroupPushDebugInfoFfi { + total_token_count: 2, + active_token_count: 1, + stale_token_count: 1, + missing_relay_hint_count: 1, + last_token_list_updated_at_ms: Some(4_000), + local_registration: LocalPushRegistrationDebugFfi { + registered: true, + shareable: true, + local_notifications_enabled: false, + native_push_enabled: true, + local_leaf_index: Some(3), + local_token_cached: true, + }, + tokens: vec![ + GroupPushTokenDebugEntryFfi { + member_id_hex: "cc33".into(), + leaf_index: 3, + platform: PushPlatformFfi::Apns, + token_fingerprint: "fp02".into(), + server_pubkey_hex: "dd44".into(), + has_relay_hint: true, + active_leaf: true, + member_matches_active_leaf: true, + is_local_member: true, + updated_at_ms: 4_000, + }, + GroupPushTokenDebugEntryFfi { + member_id_hex: "ee55".into(), + leaf_index: 7, + platform: PushPlatformFfi::Fcm, + token_fingerprint: "fp03".into(), + server_pubkey_hex: "ff66".into(), + has_relay_hint: false, + active_leaf: false, + member_matches_active_leaf: false, + is_local_member: false, + updated_at_ms: 3_500, + }, + ], + } + } + + #[test] + fn push_registration_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotPushRegistration = sample_registration().into(); + assert_eq!(mirror.platform, MarmotPushPlatform::Apns); + assert!(!mirror.account_ref.is_null()); + assert!(!mirror.relay_hint.is_null()); + assert!(mirror.has_last_shared_at_ms); + assert_eq!(mirror.last_shared_at_ms, 3_000); + let root = boxed(mirror); + unsafe { marmot_push_registration_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn group_push_debug_info_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotGroupPushDebugInfo = sample_debug_info().into(); + assert_eq!(mirror.total_token_count, 2); + assert!(mirror.has_last_token_list_updated_at_ms); + assert_eq!(mirror.last_token_list_updated_at_ms, 4_000); + assert!(mirror.local_registration.has_local_leaf_index); + assert_eq!(mirror.local_registration.local_leaf_index, 3); + assert_eq!(mirror.tokens_len, 2); + let tokens = unsafe { std::slice::from_raw_parts(mirror.tokens, mirror.tokens_len) }; + assert_eq!(tokens[0].platform, MarmotPushPlatform::Apns); + assert_eq!(tokens[1].platform, MarmotPushPlatform::Fcm); + assert!(!tokens[1].has_relay_hint); + let root = boxed(mirror); + unsafe { marmot_group_push_debug_info_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn none_and_empty_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + let mirror: MarmotPushRegistration = PushRegistrationFfi { + relay_hint: None, + last_shared_at_ms: None, + ..sample_registration() + } + .into(); + assert!(mirror.relay_hint.is_null()); + assert!(!mirror.has_last_shared_at_ms); + assert_eq!(mirror.last_shared_at_ms, 0); + let root = boxed(mirror); + unsafe { marmot_push_registration_free(root) }; + + let mirror: MarmotGroupPushDebugInfo = GroupPushDebugInfoFfi { + last_token_list_updated_at_ms: None, + local_registration: LocalPushRegistrationDebugFfi { + registered: false, + shareable: false, + local_notifications_enabled: false, + native_push_enabled: false, + local_leaf_index: None, + local_token_cached: false, + }, + tokens: Vec::new(), + ..sample_debug_info() + } + .into(); + assert!(!mirror.has_last_token_list_updated_at_ms); + assert!(!mirror.local_registration.has_local_leaf_index); + assert_eq!(mirror.local_registration.local_leaf_index, 0); + assert!(mirror.tokens.is_null()); + assert_eq!(mirror.tokens_len, 0); + let root = boxed(mirror); + unsafe { marmot_group_push_debug_info_free(root) }; + } + + #[test] + fn platform_input_roundtrips() { + let _guard = crate::memory::audit::test_lock(); + assert!(matches!( + MarmotPushPlatform::Apns.to_ffi(), + PushPlatformFfi::Apns + )); + assert!(matches!( + MarmotPushPlatform::Fcm.to_ffi(), + PushPlatformFfi::Fcm + )); + } +} diff --git a/crates/marmot-c/src/types/relay.rs b/crates/marmot-c/src/types/relay.rs new file mode 100644 index 00000000..c2f6a13d --- /dev/null +++ b/crates/marmot-c/src/types/relay.rs @@ -0,0 +1,601 @@ +//! C mirrors of the relay conversions (`marmot-uniffi/src/conversions/relay.rs`). + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{ + AccountRelayListsFfi, MissingRelayListKindFfi, RelayHealthFfi, RelayListFfi, + RelayTelemetryResourceFfi, RelayTelemetryRuntimeConfigFfi, RelayTelemetrySettingsFfi, +}; + +use crate::MarmotStatus; +use crate::memory::{ + CFree, boxed_opt, free_boxed, free_c_string, free_vec, optional_str, owned_c_string, + owned_opt_c_string, owned_vec, required_str, +}; + +/// Device-wide relay telemetry export settings. Export is opt-in and stays +/// inert until `export_enabled` is true and runtime/default config supplies a +/// valid OTLP endpoint, bearer token, and resource attributes. Used both as a +/// return value (owned; free the root) and as a borrowed input to +/// `marmot_set_relay_telemetry_settings` (caller-owned; this library never +/// frees input structs). +#[repr(C)] +pub struct MarmotRelayTelemetrySettings { + pub export_enabled: bool, + pub export_interval_seconds: u64, +} + +impl From for MarmotRelayTelemetrySettings { + fn from(value: RelayTelemetrySettingsFfi) -> Self { + Self { + export_enabled: value.export_enabled, + export_interval_seconds: value.export_interval_seconds, + } + } +} + +impl MarmotRelayTelemetrySettings { + /// Read a caller-owned input struct into the Ffi record. All fields are + /// scalars, so this is infallible and touches no caller memory. + pub(crate) fn to_ffi(&self) -> RelayTelemetrySettingsFfi { + RelayTelemetrySettingsFfi { + export_enabled: self.export_enabled, + export_interval_seconds: self.export_interval_seconds, + } + } +} + +impl CFree for MarmotRelayTelemetrySettings { + unsafe fn free_in_place(&mut self) {} +} + +/// Free a telemetry settings root returned by this library. Never call on +/// structs you allocated yourself as inputs. NULL is a no-op. +/// +/// # Safety +/// `settings` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_relay_telemetry_settings_free( + settings: *mut MarmotRelayTelemetrySettings, +) { + crate::memory::free_guard(|| unsafe { free_boxed(settings) }); +} + +/// OTLP resource attributes supplied by the platform shell. Nested inside +/// `MarmotRelayTelemetryRuntimeConfig`; borrowed when used as input. +#[repr(C)] +pub struct MarmotRelayTelemetryResource { + pub service_version: *mut c_char, + pub service_instance_id: *mut c_char, + pub deployment_environment: *mut c_char, + pub tenant: *mut c_char, + pub os_type: *mut c_char, + pub os_version: *mut c_char, + /// Device model identifier, when known. Nullable. + pub device_model_identifier: *mut c_char, +} + +impl From for MarmotRelayTelemetryResource { + fn from(value: RelayTelemetryResourceFfi) -> Self { + Self { + service_version: owned_c_string(value.service_version), + service_instance_id: owned_c_string(value.service_instance_id), + deployment_environment: owned_c_string(value.deployment_environment), + tenant: owned_c_string(value.tenant), + os_type: owned_c_string(value.os_type), + os_version: owned_c_string(value.os_version), + device_model_identifier: owned_opt_c_string(value.device_model_identifier), + } + } +} + +impl MarmotRelayTelemetryResource { + /// Read a caller-owned input struct into the Ffi record without taking + /// ownership of any caller memory. + /// + /// # Safety + /// Every non-NULL field must be a valid NUL-terminated string. + pub(crate) unsafe fn to_ffi(&self) -> Result { + Ok(RelayTelemetryResourceFfi { + service_version: unsafe { required_str(self.service_version) }?, + service_instance_id: unsafe { required_str(self.service_instance_id) }?, + deployment_environment: unsafe { required_str(self.deployment_environment) }?, + tenant: unsafe { required_str(self.tenant) }?, + os_type: unsafe { required_str(self.os_type) }?, + os_version: unsafe { required_str(self.os_version) }?, + device_model_identifier: unsafe { optional_str(self.device_model_identifier) }?, + }) + } +} + +impl CFree for MarmotRelayTelemetryResource { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.service_version); + free_c_string(self.service_instance_id); + free_c_string(self.deployment_environment); + free_c_string(self.tenant); + free_c_string(self.os_type); + free_c_string(self.os_version); + free_c_string(self.device_model_identifier); + } + } +} + +/// Free a telemetry resource root returned by this library. Never call on +/// structs you allocated yourself as inputs. NULL is a no-op. +/// +/// # Safety +/// `resource` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_relay_telemetry_resource_free( + resource: *mut MarmotRelayTelemetryResource, +) { + crate::memory::free_guard(|| unsafe { free_boxed(resource) }); +} + +/// Non-persisted OTLP runtime metadata supplied by the host app: optional +/// metrics URL override, bearer token from the host app's build-time secret, +/// and resource attributes from the platform shell. Borrowed input to +/// `marmot_set_relay_telemetry_runtime_config` (caller-owned; this library +/// never frees input structs). `authorization_bearer_token` is the OTLP push +/// credential and is never logged or echoed back by this library. +#[repr(C)] +pub struct MarmotRelayTelemetryRuntimeConfig { + /// Metrics endpoint URL override. Nullable. + pub otlp_endpoint: *mut c_char, + /// OTLP push credential. Nullable. + pub authorization_bearer_token: *mut c_char, + /// Resource attributes from the platform shell. Nullable. + pub resource: *mut MarmotRelayTelemetryResource, +} + +impl From for MarmotRelayTelemetryRuntimeConfig { + fn from(value: RelayTelemetryRuntimeConfigFfi) -> Self { + Self { + otlp_endpoint: owned_opt_c_string(value.otlp_endpoint), + authorization_bearer_token: owned_opt_c_string(value.authorization_bearer_token), + resource: boxed_opt(value.resource.map(Into::into)), + } + } +} + +impl MarmotRelayTelemetryRuntimeConfig { + /// Read a caller-owned input struct into the Ffi record without taking + /// ownership of any caller memory. + /// + /// # Safety + /// Every non-NULL string field must be a valid NUL-terminated string, and + /// `resource` must be NULL or point to a valid + /// `MarmotRelayTelemetryResource`. + pub(crate) unsafe fn to_ffi(&self) -> Result { + let resource = if self.resource.is_null() { + None + } else { + Some(unsafe { (*self.resource).to_ffi() }?) + }; + Ok(RelayTelemetryRuntimeConfigFfi { + otlp_endpoint: unsafe { optional_str(self.otlp_endpoint) }?, + authorization_bearer_token: unsafe { optional_str(self.authorization_bearer_token) }?, + resource, + }) + } +} + +impl CFree for MarmotRelayTelemetryRuntimeConfig { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.otlp_endpoint); + free_c_string(self.authorization_bearer_token); + free_boxed(self.resource); + } + } +} + +/// Free a runtime config root returned by this library. Never call on +/// structs you allocated yourself as inputs. NULL is a no-op. +/// +/// # Safety +/// `config` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_relay_telemetry_runtime_config_free( + config: *mut MarmotRelayTelemetryRuntimeConfig, +) { + crate::memory::free_guard(|| unsafe { free_boxed(config) }); +} + +/// One published relay list (NIP-65 or Marmot inbox) for an account. +#[repr(C)] +pub struct MarmotRelayList { + /// Nostr event kind of the published list. + pub kind: u64, + pub relays: *mut *mut c_char, + pub relays_len: usize, +} + +impl From for MarmotRelayList { + fn from(value: RelayListFfi) -> Self { + let (relays, relays_len) = owned_vec( + value + .relays + .into_iter() + .map(owned_c_string) + .collect::>(), + ); + Self { + kind: value.kind, + relays, + relays_len, + } + } +} + +impl CFree for MarmotRelayList { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.relays, self.relays_len) }; + } +} + +/// A relay list the account is missing, as a stable typed variant clients +/// localize without parsing strings. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotMissingRelayListKind { + /// NIP-65 relay list — where this account publishes (outbox/write-side). + Nip65, + /// Marmot inbox relay list — where this account receives (inbox/read-side). + Inbox, +} + +impl From for MarmotMissingRelayListKind { + fn from(value: MissingRelayListKindFfi) -> Self { + match value { + MissingRelayListKindFfi::Nip65 => Self::Nip65, + MissingRelayListKindFfi::Inbox => Self::Inbox, + } + } +} + +impl CFree for MarmotMissingRelayListKind { + unsafe fn free_in_place(&mut self) {} +} + +/// Per-account relay lists: the NIP-65 and inbox lists the account has +/// published, plus the configured default/bootstrap sets +/// (`marmot_account_relay_lists`). +#[repr(C)] +pub struct MarmotAccountRelayLists { + /// Whether both required relay lists have been published. + pub complete: bool, + pub missing: *mut MarmotMissingRelayListKind, + pub missing_len: usize, + pub default_relays: *mut *mut c_char, + pub default_relays_len: usize, + pub bootstrap_relays: *mut *mut c_char, + pub bootstrap_relays_len: usize, + pub nip65: MarmotRelayList, + pub inbox: MarmotRelayList, +} + +impl From for MarmotAccountRelayLists { + fn from(value: AccountRelayListsFfi) -> Self { + let (missing, missing_len) = owned_vec(value.missing.into_iter().map(Into::into).collect()); + let (default_relays, default_relays_len) = owned_vec( + value + .default_relays + .into_iter() + .map(owned_c_string) + .collect::>(), + ); + let (bootstrap_relays, bootstrap_relays_len) = owned_vec( + value + .bootstrap_relays + .into_iter() + .map(owned_c_string) + .collect::>(), + ); + Self { + complete: value.complete, + missing, + missing_len, + default_relays, + default_relays_len, + bootstrap_relays, + bootstrap_relays_len, + nip65: value.nip65.into(), + inbox: value.inbox.into(), + } + } +} + +impl CFree for MarmotAccountRelayLists { + unsafe fn free_in_place(&mut self) { + unsafe { + free_vec(self.missing, self.missing_len); + free_vec(self.default_relays, self.default_relays_len); + free_vec(self.bootstrap_relays, self.bootstrap_relays_len); + self.nip65.free_in_place(); + self.inbox.free_in_place(); + } + } +} + +/// Free an account relay lists root. NULL is a no-op. +/// +/// # Safety +/// `lists` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_account_relay_lists_free(lists: *mut MarmotAccountRelayLists) { + crate::memory::free_guard(|| unsafe { free_boxed(lists) }); +} + +/// Live relay-plane connection health for the diagnostics view +/// (`marmot_relay_health`). +#[repr(C)] +pub struct MarmotRelayHealth { + pub sdk_backed: bool, + pub total_relays: u32, + pub initialized: u32, + pub pending: u32, + pub connecting: u32, + pub connected: u32, + pub disconnected: u32, + pub terminated: u32, + pub banned: u32, + pub sleeping: u32, + pub connection_attempts: u32, + pub connection_successes: u32, +} + +impl From for MarmotRelayHealth { + fn from(value: RelayHealthFfi) -> Self { + Self { + sdk_backed: value.sdk_backed, + total_relays: value.total_relays, + initialized: value.initialized, + pending: value.pending, + connecting: value.connecting, + connected: value.connected, + disconnected: value.disconnected, + terminated: value.terminated, + banned: value.banned, + sleeping: value.sleeping, + connection_attempts: value.connection_attempts, + connection_successes: value.connection_successes, + } + } +} + +impl CFree for MarmotRelayHealth { + unsafe fn free_in_place(&mut self) {} +} + +/// Free a relay health root. NULL is a no-op. +/// +/// # Safety +/// `health` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_relay_health_free(health: *mut MarmotRelayHealth) { + crate::memory::free_guard(|| unsafe { free_boxed(health) }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::boxed; + + fn sample_resource() -> RelayTelemetryResourceFfi { + RelayTelemetryResourceFfi { + service_version: "1.2.3".into(), + service_instance_id: "install-42".into(), + deployment_environment: "production".into(), + tenant: "acme".into(), + os_type: "linux".into(), + os_version: "7.0".into(), + device_model_identifier: Some("Burrow-9".into()), + } + } + + fn sample_account_relay_lists() -> AccountRelayListsFfi { + AccountRelayListsFfi { + complete: false, + missing: vec![ + MissingRelayListKindFfi::Nip65, + MissingRelayListKindFfi::Inbox, + ], + default_relays: vec!["wss://default.example".into()], + bootstrap_relays: vec!["wss://boot-a.example".into(), "wss://boot-b.example".into()], + nip65: RelayListFfi { + kind: 10002, + relays: vec!["wss://write.example".into()], + }, + inbox: RelayListFfi { + kind: 10050, + relays: vec!["wss://read.example".into()], + }, + } + } + + #[test] + fn telemetry_settings_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotRelayTelemetrySettings = RelayTelemetrySettingsFfi { + export_enabled: true, + export_interval_seconds: 900, + } + .into(); + assert!(mirror.export_enabled); + assert_eq!(mirror.export_interval_seconds, 900); + let ffi = mirror.to_ffi(); + assert!(ffi.export_enabled); + assert_eq!(ffi.export_interval_seconds, 900); + let root = boxed(mirror); + unsafe { marmot_relay_telemetry_settings_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn telemetry_resource_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotRelayTelemetryResource = sample_resource().into(); + let ffi = unsafe { mirror.to_ffi() }.expect("valid strings"); + assert_eq!(ffi.service_version, "1.2.3"); + assert_eq!(ffi.tenant, "acme"); + assert_eq!(ffi.device_model_identifier.as_deref(), Some("Burrow-9")); + let root = boxed(mirror); + unsafe { marmot_relay_telemetry_resource_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn telemetry_runtime_config_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotRelayTelemetryRuntimeConfig = RelayTelemetryRuntimeConfigFfi { + otlp_endpoint: Some("https://otlp.example/v1/metrics".into()), + authorization_bearer_token: Some("secret-token".into()), + resource: Some(sample_resource()), + } + .into(); + assert!(!mirror.otlp_endpoint.is_null()); + assert!(!mirror.authorization_bearer_token.is_null()); + assert!(!mirror.resource.is_null()); + let ffi = unsafe { mirror.to_ffi() }.expect("valid input"); + assert_eq!( + ffi.otlp_endpoint.as_deref(), + Some("https://otlp.example/v1/metrics") + ); + assert_eq!( + ffi.authorization_bearer_token.as_deref(), + Some("secret-token") + ); + assert_eq!( + ffi.resource.expect("resource present").service_instance_id, + "install-42" + ); + let root = boxed(mirror); + unsafe { marmot_relay_telemetry_runtime_config_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn account_relay_lists_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotAccountRelayLists = sample_account_relay_lists().into(); + assert!(!mirror.complete); + assert_eq!(mirror.missing_len, 2); + assert_eq!( + unsafe { *mirror.missing }, + MarmotMissingRelayListKind::Nip65 + ); + assert_eq!(mirror.default_relays_len, 1); + assert_eq!(mirror.bootstrap_relays_len, 2); + assert_eq!(mirror.nip65.kind, 10002); + assert_eq!(mirror.nip65.relays_len, 1); + assert_eq!(mirror.inbox.kind, 10050); + assert_eq!(mirror.inbox.relays_len, 1); + let root = boxed(mirror); + unsafe { marmot_account_relay_lists_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn relay_health_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotRelayHealth = RelayHealthFfi { + sdk_backed: true, + total_relays: 8, + initialized: 8, + pending: 1, + connecting: 2, + connected: 3, + disconnected: 1, + terminated: 1, + banned: 0, + sleeping: 0, + connection_attempts: 21, + connection_successes: 20, + } + .into(); + assert!(mirror.sdk_backed); + assert_eq!(mirror.total_relays, 8); + assert_eq!(mirror.connected, 3); + assert_eq!(mirror.connection_successes, 20); + let root = boxed(mirror); + unsafe { marmot_relay_health_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn empty_and_none_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + let config: MarmotRelayTelemetryRuntimeConfig = RelayTelemetryRuntimeConfigFfi { + otlp_endpoint: None, + authorization_bearer_token: None, + resource: None, + } + .into(); + assert!(config.otlp_endpoint.is_null()); + assert!(config.authorization_bearer_token.is_null()); + assert!(config.resource.is_null()); + let ffi = unsafe { config.to_ffi() }.expect("all-NULL input is valid"); + assert_eq!(ffi.otlp_endpoint, None); + assert_eq!(ffi.authorization_bearer_token, None); + assert!(ffi.resource.is_none()); + let config_root = boxed(config); + unsafe { marmot_relay_telemetry_runtime_config_free(config_root) }; + + let lists: MarmotAccountRelayLists = AccountRelayListsFfi { + complete: true, + missing: vec![], + default_relays: vec![], + bootstrap_relays: vec![], + nip65: RelayListFfi { + kind: 10002, + relays: vec![], + }, + inbox: RelayListFfi { + kind: 10050, + relays: vec![], + }, + } + .into(); + assert!(lists.missing.is_null()); + assert_eq!(lists.missing_len, 0); + assert!(lists.default_relays.is_null()); + assert!(lists.bootstrap_relays.is_null()); + assert!(lists.nip65.relays.is_null()); + assert_eq!(lists.nip65.relays_len, 0); + let lists_root = boxed(lists); + unsafe { marmot_account_relay_lists_free(lists_root) }; + } +} diff --git a/crates/marmot-c/src/types/timeline.rs b/crates/marmot-c/src/types/timeline.rs new file mode 100644 index 00000000..415fa4da --- /dev/null +++ b/crates/marmot-c/src/types/timeline.rs @@ -0,0 +1,1117 @@ +//! C mirrors of the timeline conversions (`marmot-uniffi/src/conversions/timeline.rs`): +//! reaction tallies, reply previews, message records, pages, and the +//! projection/subscription update payloads. + +use std::ffi::c_char; + +use marmot_uniffi::conversions::{ + GroupSystemEventFfi, RuntimeProjectionUpdateFfi, TimelineMessageChangeFfi, + TimelineMessageQueryFfi, TimelineMessageRecordFfi, TimelinePageFfi, + TimelineProjectionUpdateFfi, TimelineReactionEmojiFfi, TimelineReactionSummaryFfi, + TimelineRemoveReasonFfi, TimelineReplyPreviewFfi, TimelineSubscriptionUpdateFfi, + TimelineUpdateTriggerFfi, TimelineUserReactionFfi, +}; + +use crate::MarmotStatus; +use crate::memory::{ + CFree, boxed_opt, free_boxed, free_c_string, free_vec, optional_str, owned_c_string, + owned_opt_c_string, owned_vec, +}; +use crate::types::chat_list::{MarmotChatListRow, MarmotChatListUpdateTrigger}; +use crate::types::common::MarmotMessageTag; +use crate::types::markdown::MarmotMarkdownDocument; +use crate::types::media::MarmotMediaAttachmentReference; + +/// One emoji tally inside a reaction summary. +#[repr(C)] +pub struct MarmotTimelineReactionEmoji { + pub emoji: *mut c_char, + /// Number of distinct senders that reacted with this emoji + /// (`== senders_len`), surfaced so clients render the tally without + /// counting. This is the authenticated reaction count only; clients + /// overlay their own optimistic react/unreact and "did I react" state on + /// top. + pub count: u32, + pub senders: *mut *mut c_char, + pub senders_len: usize, +} + +impl From for MarmotTimelineReactionEmoji { + fn from(value: TimelineReactionEmojiFfi) -> Self { + let (senders, senders_len) = owned_vec( + value + .senders + .into_iter() + .map(owned_c_string) + .collect::>(), + ); + Self { + emoji: owned_c_string(value.emoji), + count: value.count, + senders, + senders_len, + } + } +} + +impl CFree for MarmotTimelineReactionEmoji { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.emoji); + free_vec(self.senders, self.senders_len); + } + } +} + +/// One individual reaction event by one sender on one target message. +#[repr(C)] +pub struct MarmotTimelineUserReaction { + pub reaction_message_id_hex: *mut c_char, + pub target_message_id_hex: *mut c_char, + pub sender: *mut c_char, + pub emoji: *mut c_char, + pub reacted_at: u64, +} + +impl From for MarmotTimelineUserReaction { + fn from(value: TimelineUserReactionFfi) -> Self { + Self { + reaction_message_id_hex: owned_c_string(value.reaction_message_id_hex), + target_message_id_hex: owned_c_string(value.target_message_id_hex), + sender: owned_c_string(value.sender), + emoji: owned_c_string(value.emoji), + reacted_at: value.reacted_at, + } + } +} + +impl CFree for MarmotTimelineUserReaction { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.reaction_message_id_hex); + free_c_string(self.target_message_id_hex); + free_c_string(self.sender); + free_c_string(self.emoji); + } + } +} + +/// Aggregated reactions on one timeline message. +#[repr(C)] +pub struct MarmotTimelineReactionSummary { + /// Reaction tallies pre-sorted by `count` descending, ties broken by + /// `emoji` ascending, so clients render a stable tally without + /// re-sorting. + pub by_emoji: *mut MarmotTimelineReactionEmoji, + pub by_emoji_len: usize, + pub user_reactions: *mut MarmotTimelineUserReaction, + pub user_reactions_len: usize, +} + +impl From for MarmotTimelineReactionSummary { + fn from(value: TimelineReactionSummaryFfi) -> Self { + let (by_emoji, by_emoji_len) = + owned_vec(value.by_emoji.into_iter().map(Into::into).collect()); + let (user_reactions, user_reactions_len) = + owned_vec(value.user_reactions.into_iter().map(Into::into).collect()); + Self { + by_emoji, + by_emoji_len, + user_reactions, + user_reactions_len, + } + } +} + +impl CFree for MarmotTimelineReactionSummary { + unsafe fn free_in_place(&mut self) { + unsafe { + free_vec(self.by_emoji, self.by_emoji_len); + free_vec(self.user_reactions, self.user_reactions_len); + } + } +} + +/// Timeline query filter for `marmot_timeline_messages`. Caller-owned input; +/// this library never frees input structs. All fields optional: NULL strings +/// and `has_x == false` scalars mean "unset". +#[repr(C)] +pub struct MarmotTimelineMessageQuery { + /// Restrict to one group (opaque MLS group id, hex). Nullable. + pub group_id_hex: *mut c_char, + /// Substring search over plaintext. Nullable. + pub search: *mut c_char, + /// Only messages before this timeline timestamp; set `has_before`. + pub has_before: bool, + pub before: u64, + /// Cursor message id paired with `before`. Nullable. + pub before_message_id: *mut c_char, + /// Only messages after this timeline timestamp; set `has_after`. + pub has_after: bool, + pub after: u64, + /// Cursor message id paired with `after`. Nullable. + pub after_message_id: *mut c_char, + /// Page-size cap; set `has_limit`. + pub has_limit: bool, + pub limit: u32, +} + +impl From for MarmotTimelineMessageQuery { + fn from(value: TimelineMessageQueryFfi) -> Self { + Self { + group_id_hex: owned_opt_c_string(value.group_id_hex), + search: owned_opt_c_string(value.search), + has_before: value.before.is_some(), + before: value.before.unwrap_or(0), + before_message_id: owned_opt_c_string(value.before_message_id), + has_after: value.after.is_some(), + after: value.after.unwrap_or(0), + after_message_id: owned_opt_c_string(value.after_message_id), + has_limit: value.limit.is_some(), + limit: value.limit.unwrap_or(0), + } + } +} + +impl MarmotTimelineMessageQuery { + /// Read a caller-owned query struct into the Ffi record without taking + /// ownership of any caller memory. + /// + /// # Safety + /// Every non-NULL string field must be a valid NUL-terminated string. + pub(crate) unsafe fn to_ffi(&self) -> Result { + Ok(TimelineMessageQueryFfi { + group_id_hex: unsafe { optional_str(self.group_id_hex.cast_const()) }?, + search: unsafe { optional_str(self.search.cast_const()) }?, + before: self.has_before.then_some(self.before), + before_message_id: unsafe { optional_str(self.before_message_id.cast_const()) }?, + after: self.has_after.then_some(self.after), + after_message_id: unsafe { optional_str(self.after_message_id.cast_const()) }?, + limit: self.has_limit.then_some(self.limit), + }) + } +} + +impl CFree for MarmotTimelineMessageQuery { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.group_id_hex); + free_c_string(self.search); + free_c_string(self.before_message_id); + free_c_string(self.after_message_id); + } + } +} + +/// Inline preview of the message a timeline row replies to. +#[repr(C)] +pub struct MarmotTimelineReplyPreview { + pub message_id_hex: *mut c_char, + pub sender: *mut c_char, + pub plaintext: *mut c_char, + pub content_tokens: MarmotMarkdownDocument, + pub kind: u64, + /// Raw `imeta` media JSON of the previewed message. Nullable. + pub media_json: *mut c_char, + /// Fully-resolved, downloadable media references for the previewed + /// message, built from its `imeta` tags + its own `source_epoch` using + /// the same resolution and validation as `marmot_list_media`. Empty when + /// the previewed message has no media or its `imeta` is malformed. + pub media: *mut MarmotMediaAttachmentReference, + pub media_len: usize, + /// Raw agent text-stream JSON of the previewed message. Nullable. + pub agent_text_stream_json: *mut c_char, + pub deleted: bool, +} + +impl From for MarmotTimelineReplyPreview { + fn from(value: TimelineReplyPreviewFfi) -> Self { + let (media, media_len) = owned_vec(value.media.into_iter().map(Into::into).collect()); + Self { + message_id_hex: owned_c_string(value.message_id_hex), + sender: owned_c_string(value.sender), + plaintext: owned_c_string(value.plaintext), + content_tokens: value.content_tokens.into(), + kind: value.kind, + media_json: owned_opt_c_string(value.media_json), + media, + media_len, + agent_text_stream_json: owned_opt_c_string(value.agent_text_stream_json), + deleted: value.deleted, + } + } +} + +impl CFree for MarmotTimelineReplyPreview { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.message_id_hex); + free_c_string(self.sender); + free_c_string(self.plaintext); + self.content_tokens.free_in_place(); + free_c_string(self.media_json); + free_vec(self.media, self.media_len); + free_c_string(self.agent_text_stream_json); + } + } +} + +/// Parsed view of a kind-1210 group system row (member added/removed, +/// rename, retention change, …). +#[repr(C)] +pub struct MarmotGroupSystemEvent { + pub system_type: *mut c_char, + /// Human-readable fallback from the row content. Prefer rendering from + /// `system_type` plus the structured fields so clients can localize and + /// render the local account as "you". + pub text: *mut c_char, + /// Account that performed the action. Nullable. + pub actor_account_id_hex: *mut c_char, + /// Account the action targeted. Nullable. + pub subject_account_id_hex: *mut c_char, + /// New group name for rename events. Nullable. + pub name: *mut c_char, + /// Previous group name for rename events. Nullable. + pub old_name: *mut c_char, + /// Previous disappearing-message retention in seconds; `0` means off. + /// Only meaningful when `has_old_retention_seconds`. + pub has_old_retention_seconds: bool, + pub old_retention_seconds: u64, + /// New disappearing-message retention in seconds; `0` means off. + /// Only meaningful when `has_new_retention_seconds`. + pub has_new_retention_seconds: bool, + pub new_retention_seconds: u64, +} + +impl From for MarmotGroupSystemEvent { + fn from(value: GroupSystemEventFfi) -> Self { + Self { + system_type: owned_c_string(value.system_type), + text: owned_c_string(value.text), + actor_account_id_hex: owned_opt_c_string(value.actor_account_id_hex), + subject_account_id_hex: owned_opt_c_string(value.subject_account_id_hex), + name: owned_opt_c_string(value.name), + old_name: owned_opt_c_string(value.old_name), + has_old_retention_seconds: value.old_retention_seconds.is_some(), + old_retention_seconds: value.old_retention_seconds.unwrap_or(0), + has_new_retention_seconds: value.new_retention_seconds.is_some(), + new_retention_seconds: value.new_retention_seconds.unwrap_or(0), + } + } +} + +impl CFree for MarmotGroupSystemEvent { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.system_type); + free_c_string(self.text); + free_c_string(self.actor_account_id_hex); + free_c_string(self.subject_account_id_hex); + free_c_string(self.name); + free_c_string(self.old_name); + } + } +} + +/// One projected timeline message row. +#[repr(C)] +pub struct MarmotTimelineMessageRecord { + pub message_id_hex: *mut c_char, + /// Delivery marker for own (`direction == "sent"`) messages. An own send + /// commits and projects locally *before* it publishes, so a message that + /// was committed but not yet delivered (e.g. sent offline / relay + /// unreachable) carries NULL here — render it as pending/failed. On + /// delivery/convergence the same row is upserted with the published + /// source event id, so flip the UI to delivered once this becomes + /// non-NULL. To re-drive delivery of a stuck pending message without + /// minting a duplicate, call `marmot_retry_group_convergence` rather + /// than re-sending the text. For received messages this is the + /// originating event id and is always non-NULL. + pub source_message_id_hex: *mut c_char, + pub direction: *mut c_char, + pub group_id_hex: *mut c_char, + pub sender: *mut c_char, + pub plaintext: *mut c_char, + pub content_tokens: MarmotMarkdownDocument, + pub kind: u64, + pub tags: *mut MarmotMessageTag, + pub tags_len: usize, + pub timeline_at: u64, + pub received_at: u64, + /// Id of the message this row replies to. Nullable. + pub reply_to_message_id_hex: *mut c_char, + /// Inline preview of the replied-to message. Nullable. + pub reply_preview: *mut MarmotTimelineReplyPreview, + /// Raw `imeta` media JSON. Nullable. + pub media_json: *mut c_char, + /// Fully-resolved, downloadable media references for this message, built + /// from its `imeta` tags + its own `source_epoch` using the same + /// resolution and validation as `marmot_list_media` (a `list_media` + /// record and this row's `media` resolve identically for the same + /// message). Empty when the message has no media; a malformed `imeta` + /// attachment is dropped while the message still appears as text. + pub media: *mut MarmotMediaAttachmentReference, + pub media_len: usize, + /// Raw agent text-stream JSON. Nullable. + pub agent_text_stream_json: *mut c_char, + /// Parsed view of kind-1210 group system rows. NULL for chat, reactions, + /// stream rows, and malformed/free-text kind-1210 assertions. + pub group_system: *mut MarmotGroupSystemEvent, + pub reactions: MarmotTimelineReactionSummary, + pub deleted: bool, + /// Id of the deletion event when `deleted`. Nullable. + pub deleted_by_message_id_hex: *mut c_char, + /// Set when convergence invalidated this message (it landed on a losing + /// branch). The message is kept as a "did not reach the group" tombstone + /// instead of disappearing; the value is the engine invalidation reason + /// (e.g. `LosingBranch`). NULL for delivered messages. + pub invalidation_status: *mut c_char, +} + +impl From for MarmotTimelineMessageRecord { + fn from(value: TimelineMessageRecordFfi) -> Self { + let (tags, tags_len) = owned_vec(value.tags.into_iter().map(Into::into).collect()); + let (media, media_len) = owned_vec(value.media.into_iter().map(Into::into).collect()); + Self { + message_id_hex: owned_c_string(value.message_id_hex), + source_message_id_hex: owned_opt_c_string(value.source_message_id_hex), + direction: owned_c_string(value.direction), + group_id_hex: owned_c_string(value.group_id_hex), + sender: owned_c_string(value.sender), + plaintext: owned_c_string(value.plaintext), + content_tokens: value.content_tokens.into(), + kind: value.kind, + tags, + tags_len, + timeline_at: value.timeline_at, + received_at: value.received_at, + reply_to_message_id_hex: owned_opt_c_string(value.reply_to_message_id_hex), + reply_preview: boxed_opt(value.reply_preview.map(Into::into)), + media_json: owned_opt_c_string(value.media_json), + media, + media_len, + agent_text_stream_json: owned_opt_c_string(value.agent_text_stream_json), + group_system: boxed_opt(value.group_system.map(Into::into)), + reactions: value.reactions.into(), + deleted: value.deleted, + deleted_by_message_id_hex: owned_opt_c_string(value.deleted_by_message_id_hex), + invalidation_status: owned_opt_c_string(value.invalidation_status), + } + } +} + +impl CFree for MarmotTimelineMessageRecord { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.message_id_hex); + free_c_string(self.source_message_id_hex); + free_c_string(self.direction); + free_c_string(self.group_id_hex); + free_c_string(self.sender); + free_c_string(self.plaintext); + self.content_tokens.free_in_place(); + free_vec(self.tags, self.tags_len); + free_c_string(self.reply_to_message_id_hex); + free_boxed(self.reply_preview); + free_c_string(self.media_json); + free_vec(self.media, self.media_len); + free_c_string(self.agent_text_stream_json); + free_boxed(self.group_system); + self.reactions.free_in_place(); + free_c_string(self.deleted_by_message_id_hex); + free_c_string(self.invalidation_status); + } + } +} + +/// Free a single message record root. NULL is a no-op. Records nested inside +/// a page or update are owned by that root and freed with it — never +/// individually. +/// +/// # Safety +/// `record` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_timeline_message_record_free( + record: *mut MarmotTimelineMessageRecord, +) { + crate::memory::free_guard(|| unsafe { free_boxed(record) }); +} + +/// One page of timeline messages (`marmot_timeline_messages`, subscription +/// snapshot/next/paginate). +#[repr(C)] +pub struct MarmotTimelinePage { + pub messages: *mut MarmotTimelineMessageRecord, + pub messages_len: usize, + pub has_more_before: bool, + pub has_more_after: bool, +} + +impl From for MarmotTimelinePage { + fn from(value: TimelinePageFfi) -> Self { + let (messages, messages_len) = + owned_vec(value.messages.into_iter().map(Into::into).collect()); + Self { + messages, + messages_len, + has_more_before: value.has_more_before, + has_more_after: value.has_more_after, + } + } +} + +impl CFree for MarmotTimelinePage { + unsafe fn free_in_place(&mut self) { + unsafe { free_vec(self.messages, self.messages_len) }; + } +} + +/// Free a timeline page root. NULL is a no-op. +/// +/// # Safety +/// `page` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_timeline_page_free(page: *mut MarmotTimelinePage) { + crate::memory::free_guard(|| unsafe { free_boxed(page) }); +} + +/// Why the timeline subscription raised an upsert. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotTimelineUpdateTrigger { + NewMessage, + MessageEditedOrReprojected, + ReactionAdded, + ReactionRemoved, + MessageDeleted, + ReplyPreviewChanged, + AgentStreamStarted, + AgentStreamFinished, + AgentActivity, + AgentOperation, + GroupSystem, + DeliveryOrSendStateChanged, + ReceiptChanged, + SnapshotRefresh, +} + +impl From for MarmotTimelineUpdateTrigger { + fn from(value: TimelineUpdateTriggerFfi) -> Self { + match value { + TimelineUpdateTriggerFfi::NewMessage => Self::NewMessage, + TimelineUpdateTriggerFfi::MessageEditedOrReprojected => { + Self::MessageEditedOrReprojected + } + TimelineUpdateTriggerFfi::ReactionAdded => Self::ReactionAdded, + TimelineUpdateTriggerFfi::ReactionRemoved => Self::ReactionRemoved, + TimelineUpdateTriggerFfi::MessageDeleted => Self::MessageDeleted, + TimelineUpdateTriggerFfi::ReplyPreviewChanged => Self::ReplyPreviewChanged, + TimelineUpdateTriggerFfi::AgentStreamStarted => Self::AgentStreamStarted, + TimelineUpdateTriggerFfi::AgentStreamFinished => Self::AgentStreamFinished, + TimelineUpdateTriggerFfi::AgentActivity => Self::AgentActivity, + TimelineUpdateTriggerFfi::AgentOperation => Self::AgentOperation, + TimelineUpdateTriggerFfi::GroupSystem => Self::GroupSystem, + TimelineUpdateTriggerFfi::DeliveryOrSendStateChanged => { + Self::DeliveryOrSendStateChanged + } + TimelineUpdateTriggerFfi::ReceiptChanged => Self::ReceiptChanged, + TimelineUpdateTriggerFfi::SnapshotRefresh => Self::SnapshotRefresh, + } + } +} + +impl CFree for MarmotTimelineUpdateTrigger { + unsafe fn free_in_place(&mut self) {} +} + +/// Why a message left the subscribed timeline window. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarmotTimelineRemoveReason { + Invalidated, + Cleared, + Pruned, + NoLongerMatchesQuery, +} + +impl From for MarmotTimelineRemoveReason { + fn from(value: TimelineRemoveReasonFfi) -> Self { + match value { + TimelineRemoveReasonFfi::Invalidated => Self::Invalidated, + TimelineRemoveReasonFfi::Cleared => Self::Cleared, + TimelineRemoveReasonFfi::Pruned => Self::Pruned, + TimelineRemoveReasonFfi::NoLongerMatchesQuery => Self::NoLongerMatchesQuery, + } + } +} + +impl CFree for MarmotTimelineRemoveReason { + unsafe fn free_in_place(&mut self) {} +} + +/// One incremental change to a subscribed timeline window: a row upsert +/// (with the full replacement record) or a row removal. +#[repr(C)] +#[allow(clippy::large_enum_variant)] +pub enum MarmotTimelineMessageChange { + Upsert { + trigger: MarmotTimelineUpdateTrigger, + message: MarmotTimelineMessageRecord, + }, + Remove { + message_id_hex: *mut c_char, + reason: MarmotTimelineRemoveReason, + }, +} + +impl From for MarmotTimelineMessageChange { + fn from(value: TimelineMessageChangeFfi) -> Self { + match value { + TimelineMessageChangeFfi::Upsert { trigger, message } => Self::Upsert { + trigger: trigger.into(), + message: message.into(), + }, + TimelineMessageChangeFfi::Remove { + message_id_hex, + reason, + } => Self::Remove { + message_id_hex: owned_c_string(message_id_hex), + reason: reason.into(), + }, + } + } +} + +impl CFree for MarmotTimelineMessageChange { + unsafe fn free_in_place(&mut self) { + match self { + Self::Upsert { message, .. } => unsafe { message.free_in_place() }, + Self::Remove { message_id_hex, .. } => unsafe { free_c_string(*message_id_hex) }, + } + } +} + +/// One projection update for a single group: the refreshed window plus the +/// incremental changes that produced it, and the group's refreshed chat-list +/// row when it changed. +#[repr(C)] +pub struct MarmotTimelineProjectionUpdate { + pub group_id_hex: *mut c_char, + pub messages: *mut MarmotTimelineMessageRecord, + pub messages_len: usize, + pub changes: *mut MarmotTimelineMessageChange, + pub changes_len: usize, + /// Refreshed chat-list row for this group. Nullable. + pub chat_list_row: *mut MarmotChatListRow, + pub chat_list_trigger: MarmotChatListUpdateTrigger, +} + +impl From for MarmotTimelineProjectionUpdate { + fn from(value: TimelineProjectionUpdateFfi) -> Self { + let (messages, messages_len) = + owned_vec(value.messages.into_iter().map(Into::into).collect()); + let (changes, changes_len) = owned_vec(value.changes.into_iter().map(Into::into).collect()); + Self { + group_id_hex: owned_c_string(value.group_id_hex), + messages, + messages_len, + changes, + changes_len, + chat_list_row: boxed_opt(value.chat_list_row.map(Into::into)), + chat_list_trigger: value.chat_list_trigger.into(), + } + } +} + +impl CFree for MarmotTimelineProjectionUpdate { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.group_id_hex); + free_vec(self.messages, self.messages_len); + free_vec(self.changes, self.changes_len); + free_boxed(self.chat_list_row); + } + } +} + +/// A projection update tagged with the account it belongs to, for +/// runtime-wide subscriptions that span accounts. +#[repr(C)] +pub struct MarmotRuntimeProjectionUpdate { + pub account_id_hex: *mut c_char, + pub account_label: *mut c_char, + pub update: MarmotTimelineProjectionUpdate, +} + +impl From for MarmotRuntimeProjectionUpdate { + fn from(value: RuntimeProjectionUpdateFfi) -> Self { + Self { + account_id_hex: owned_c_string(value.account_id_hex), + account_label: owned_c_string(value.account_label), + update: value.update.into(), + } + } +} + +impl CFree for MarmotRuntimeProjectionUpdate { + unsafe fn free_in_place(&mut self) { + unsafe { + free_c_string(self.account_id_hex); + free_c_string(self.account_label); + self.update.free_in_place(); + } + } +} + +/// One timeline subscription update: a full replacement page or an +/// incremental projection update. Variants carry rich payloads by value so +/// hosts read them without extra dereferences. +#[repr(C)] +#[allow(clippy::large_enum_variant)] +pub enum MarmotTimelineSubscriptionUpdate { + Page { + page: MarmotTimelinePage, + }, + Projection { + update: MarmotRuntimeProjectionUpdate, + }, +} + +impl From for MarmotTimelineSubscriptionUpdate { + fn from(value: TimelineSubscriptionUpdateFfi) -> Self { + match value { + TimelineSubscriptionUpdateFfi::Page { page } => Self::Page { page: page.into() }, + TimelineSubscriptionUpdateFfi::Projection { update } => Self::Projection { + update: update.into(), + }, + } + } +} + +impl CFree for MarmotTimelineSubscriptionUpdate { + unsafe fn free_in_place(&mut self) { + match self { + Self::Page { page } => unsafe { page.free_in_place() }, + Self::Projection { update } => unsafe { update.free_in_place() }, + } + } +} + +/// Free a subscription update root (timeline subscription `next_update`). +/// NULL is a no-op. +/// +/// # Safety +/// `update` must be NULL or an unfreed pointer returned by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn marmot_timeline_subscription_update_free( + update: *mut MarmotTimelineSubscriptionUpdate, +) { + crate::memory::free_guard(|| unsafe { free_boxed(update) }); +} + +#[cfg(test)] +mod tests { + use std::ffi::CStr; + + use marmot_uniffi::conversions::{ + ChatListRowFfi, ChatListUpdateTriggerFfi, MediaAttachmentReferenceFfi, MediaLocatorFfi, + MessageTagFfi, SelfMembershipFfi, + }; + use marmot_uniffi::{MarkdownBlockFfi, MarkdownDocumentFfi, MarkdownInlineFfi}; + + use super::*; + use crate::memory::boxed; + + fn sample_markdown() -> MarkdownDocumentFfi { + MarkdownDocumentFfi { + blocks: vec![MarkdownBlockFfi::Paragraph { + inlines: vec![MarkdownInlineFfi::Text { + content: "hello burrow".to_owned(), + }], + }], + truncated: false, + } + } + + fn sample_media_reference() -> MediaAttachmentReferenceFfi { + MediaAttachmentReferenceFfi { + locators: vec![MediaLocatorFfi { + kind: "blossom-v1".to_owned(), + value: "https://media.example/blob.bin".to_owned(), + }], + ciphertext_sha256: "aa".repeat(32), + plaintext_sha256: "bb".repeat(32), + nonce_hex: "cc".repeat(12), + file_name: "diagram.png".to_owned(), + media_type: "image/png".to_owned(), + version: "encrypted-media-v1".to_owned(), + source_epoch: 7, + dim: Some("640x480".to_owned()), + thumbhash: Some("thumb".to_owned()), + } + } + + fn sample_reaction_summary() -> TimelineReactionSummaryFfi { + TimelineReactionSummaryFfi { + by_emoji: vec![TimelineReactionEmojiFfi { + emoji: "👍".to_owned(), + count: 2, + senders: vec!["alice".to_owned(), "bob".to_owned()], + }], + user_reactions: vec![TimelineUserReactionFfi { + reaction_message_id_hex: "r1".to_owned(), + target_message_id_hex: "m1".to_owned(), + sender: "alice".to_owned(), + emoji: "👍".to_owned(), + reacted_at: 41, + }], + } + } + + fn sample_reply_preview() -> TimelineReplyPreviewFfi { + TimelineReplyPreviewFfi { + message_id_hex: "parent".to_owned(), + sender: "bob".to_owned(), + plaintext: "original".to_owned(), + content_tokens: sample_markdown(), + kind: 9, + media_json: Some("{\"imeta\":[]}".to_owned()), + media: vec![sample_media_reference()], + agent_text_stream_json: Some("{\"stream\":\"s\"}".to_owned()), + deleted: false, + } + } + + fn sample_group_system_event() -> GroupSystemEventFfi { + GroupSystemEventFfi { + system_type: "retention_changed".to_owned(), + text: "alice set messages to disappear".to_owned(), + actor_account_id_hex: Some("aa".repeat(32)), + subject_account_id_hex: Some("bb".repeat(32)), + name: Some("Burrow".to_owned()), + old_name: Some("Old Burrow".to_owned()), + old_retention_seconds: Some(0), + new_retention_seconds: Some(86_400), + } + } + + fn sample_message_record() -> TimelineMessageRecordFfi { + TimelineMessageRecordFfi { + message_id_hex: "m1".to_owned(), + source_message_id_hex: Some("src1".to_owned()), + direction: "received".to_owned(), + group_id_hex: "11".repeat(16), + sender: "alice".to_owned(), + plaintext: "hello burrow".to_owned(), + content_tokens: sample_markdown(), + kind: 9, + tags: vec![MessageTagFfi { + values: vec!["e".to_owned(), "abcd".to_owned()], + }], + timeline_at: 10, + received_at: 11, + reply_to_message_id_hex: Some("parent".to_owned()), + reply_preview: Some(sample_reply_preview()), + media_json: Some("{\"imeta\":[]}".to_owned()), + media: vec![sample_media_reference()], + agent_text_stream_json: Some("{\"stream\":\"s\"}".to_owned()), + group_system: Some(sample_group_system_event()), + reactions: sample_reaction_summary(), + deleted: true, + deleted_by_message_id_hex: Some("del1".to_owned()), + invalidation_status: Some("LosingBranch".to_owned()), + } + } + + fn sample_chat_list_row() -> ChatListRowFfi { + ChatListRowFfi { + group_id_hex: "11".repeat(16), + archived: false, + pending_confirmation: false, + title: "Burrow".to_owned(), + group_name: "Burrow".to_owned(), + avatar_url: None, + avatar: None, + last_message: None, + unread_count: 1, + has_unread: true, + unread_mention_count: 0, + unread_mention: false, + first_unread_message_id_hex: None, + last_read_message_id_hex: None, + last_read_timeline_at: None, + updated_at: 12, + self_membership: SelfMembershipFfi::Member, + } + } + + fn sample_runtime_projection_update() -> RuntimeProjectionUpdateFfi { + RuntimeProjectionUpdateFfi { + account_id_hex: "cc".repeat(32), + account_label: "marmy".to_owned(), + update: TimelineProjectionUpdateFfi { + group_id_hex: "11".repeat(16), + messages: vec![sample_message_record()], + changes: vec![ + TimelineMessageChangeFfi::Upsert { + trigger: TimelineUpdateTriggerFfi::NewMessage, + message: sample_message_record(), + }, + TimelineMessageChangeFfi::Remove { + message_id_hex: "gone".to_owned(), + reason: TimelineRemoveReasonFfi::Pruned, + }, + ], + chat_list_row: Some(sample_chat_list_row()), + chat_list_trigger: ChatListUpdateTriggerFfi::NewLastMessage, + }, + } + } + + #[test] + fn timeline_message_record_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let mirror: MarmotTimelineMessageRecord = sample_message_record().into(); + assert_eq!( + unsafe { CStr::from_ptr(mirror.message_id_hex) }.to_str(), + Ok("m1") + ); + assert!(!mirror.source_message_id_hex.is_null()); + assert_eq!(mirror.content_tokens.blocks_len, 1); + assert_eq!(mirror.tags_len, 1); + assert_eq!(mirror.media_len, 1); + assert!(!mirror.reply_preview.is_null()); + let preview = unsafe { &*mirror.reply_preview }; + assert_eq!(preview.media_len, 1); + assert!(!preview.agent_text_stream_json.is_null()); + assert!(!mirror.group_system.is_null()); + let system = unsafe { &*mirror.group_system }; + assert!(system.has_new_retention_seconds); + assert_eq!(system.new_retention_seconds, 86_400); + assert!(system.has_old_retention_seconds); + assert_eq!(system.old_retention_seconds, 0); + assert_eq!(mirror.reactions.by_emoji_len, 1); + assert_eq!(mirror.reactions.user_reactions_len, 1); + let tally = unsafe { &*mirror.reactions.by_emoji }; + assert_eq!(tally.count, 2); + assert_eq!(tally.senders_len, 2); + assert!(mirror.deleted); + assert!(!mirror.invalidation_status.is_null()); + let root = boxed(mirror); + unsafe { marmot_timeline_message_record_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn timeline_page_deep_roundtrip() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let page: MarmotTimelinePage = TimelinePageFfi { + messages: vec![sample_message_record(), sample_message_record()], + has_more_before: true, + has_more_after: false, + } + .into(); + assert_eq!(page.messages_len, 2); + assert!(page.has_more_before); + assert!(!page.has_more_after); + let root = boxed(page); + unsafe { marmot_timeline_page_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn subscription_update_roundtrips_both_variants() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let page_update: MarmotTimelineSubscriptionUpdate = TimelineSubscriptionUpdateFfi::Page { + page: TimelinePageFfi { + messages: vec![sample_message_record()], + has_more_before: false, + has_more_after: true, + }, + } + .into(); + match &page_update { + MarmotTimelineSubscriptionUpdate::Page { page } => { + assert_eq!(page.messages_len, 1); + assert!(page.has_more_after); + } + MarmotTimelineSubscriptionUpdate::Projection { .. } => { + panic!("expected Page variant") + } + } + let root = boxed(page_update); + unsafe { marmot_timeline_subscription_update_free(root) }; + + let projection: MarmotTimelineSubscriptionUpdate = + TimelineSubscriptionUpdateFfi::Projection { + update: sample_runtime_projection_update(), + } + .into(); + match &projection { + MarmotTimelineSubscriptionUpdate::Projection { update } => { + assert_eq!( + unsafe { CStr::from_ptr(update.account_label) }.to_str(), + Ok("marmy") + ); + assert_eq!(update.update.messages_len, 1); + assert_eq!(update.update.changes_len, 2); + assert!(!update.update.chat_list_row.is_null()); + let changes = unsafe { + std::slice::from_raw_parts(update.update.changes, update.update.changes_len) + }; + match &changes[0] { + MarmotTimelineMessageChange::Upsert { trigger, message } => { + assert_eq!(*trigger, MarmotTimelineUpdateTrigger::NewMessage); + assert_eq!(message.kind, 9); + } + MarmotTimelineMessageChange::Remove { .. } => { + panic!("expected Upsert change") + } + } + match &changes[1] { + MarmotTimelineMessageChange::Remove { + message_id_hex, + reason, + } => { + assert_eq!( + unsafe { CStr::from_ptr(*message_id_hex) }.to_str(), + Ok("gone") + ); + assert_eq!(*reason, MarmotTimelineRemoveReason::Pruned); + } + MarmotTimelineMessageChange::Upsert { .. } => { + panic!("expected Remove change") + } + } + } + MarmotTimelineSubscriptionUpdate::Page { .. } => { + panic!("expected Projection variant") + } + } + let root = boxed(projection); + unsafe { marmot_timeline_subscription_update_free(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn query_input_roundtrips_borrowed_fields() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let owned: MarmotTimelineMessageQuery = TimelineMessageQueryFfi { + group_id_hex: Some("11".repeat(16)), + search: Some("burrow".to_owned()), + before: Some(99), + before_message_id: Some("b1".to_owned()), + after: None, + after_message_id: None, + limit: Some(50), + } + .into(); + let ffi = unsafe { owned.to_ffi() }.expect("valid strings"); + assert_eq!(ffi.group_id_hex.as_deref(), Some("11".repeat(16).as_str())); + assert_eq!(ffi.search.as_deref(), Some("burrow")); + assert_eq!(ffi.before, Some(99)); + assert_eq!(ffi.before_message_id.as_deref(), Some("b1")); + assert_eq!(ffi.after, None); + assert_eq!(ffi.after_message_id, None); + assert_eq!(ffi.limit, Some(50)); + let root = boxed(owned); + unsafe { free_boxed(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } + + #[test] + fn empty_and_none_fields_convert_to_null() { + let _guard = crate::memory::audit::test_lock(); + #[cfg(feature = "alloc-audit")] + let start = crate::memory::audit::live_allocations(); + + let page: MarmotTimelinePage = TimelinePageFfi { + messages: Vec::new(), + has_more_before: false, + has_more_after: false, + } + .into(); + assert!(page.messages.is_null()); + assert_eq!(page.messages_len, 0); + let root = boxed(page); + unsafe { marmot_timeline_page_free(root) }; + + let record: MarmotTimelineMessageRecord = TimelineMessageRecordFfi { + message_id_hex: "m1".to_owned(), + source_message_id_hex: None, + direction: "sent".to_owned(), + group_id_hex: "11".repeat(16), + sender: "me".to_owned(), + plaintext: "hi".to_owned(), + content_tokens: MarkdownDocumentFfi { + blocks: Vec::new(), + truncated: false, + }, + kind: 9, + tags: Vec::new(), + timeline_at: 1, + received_at: 2, + reply_to_message_id_hex: None, + reply_preview: None, + media_json: None, + media: Vec::new(), + agent_text_stream_json: None, + group_system: None, + reactions: TimelineReactionSummaryFfi { + by_emoji: Vec::new(), + user_reactions: Vec::new(), + }, + deleted: false, + deleted_by_message_id_hex: None, + invalidation_status: None, + } + .into(); + assert!(record.source_message_id_hex.is_null()); + assert!(record.tags.is_null()); + assert!(record.reply_to_message_id_hex.is_null()); + assert!(record.reply_preview.is_null()); + assert!(record.media_json.is_null()); + assert!(record.media.is_null()); + assert!(record.agent_text_stream_json.is_null()); + assert!(record.group_system.is_null()); + assert!(record.reactions.by_emoji.is_null()); + assert!(record.invalidation_status.is_null()); + let root = boxed(record); + unsafe { marmot_timeline_message_record_free(root) }; + + let query: MarmotTimelineMessageQuery = TimelineMessageQueryFfi::default().into(); + assert!(query.group_id_hex.is_null()); + assert!(query.search.is_null()); + assert!(!query.has_before); + assert!(!query.has_after); + assert!(!query.has_limit); + let ffi = unsafe { query.to_ffi() }.expect("all-NULL query is valid"); + assert_eq!(ffi.before, None); + assert_eq!(ffi.limit, None); + let root = boxed(query); + unsafe { free_boxed(root) }; + + #[cfg(feature = "alloc-audit")] + assert_eq!(crate::memory::audit::live_allocations(), start); + } +} diff --git a/crates/marmot-uniffi/src/lib.rs b/crates/marmot-uniffi/src/lib.rs index b7a49441..f3a321a1 100644 --- a/crates/marmot-uniffi/src/lib.rs +++ b/crates/marmot-uniffi/src/lib.rs @@ -23,11 +23,13 @@ use cgka_traits::TransportEndpoint; use marmot_app::{MarmotApp, MarmotAppRuntime, TimelineMessageQuery, TimelinePagination}; mod commands; -mod conversions; +// Public: `marmot-c` builds its `#[repr(C)]` mirrors from these modules so +// the C ABI can never drift from the Swift/Kotlin surface. +pub mod conversions; mod errors; mod external_signer; mod markdown; -mod subscriptions; +pub mod subscriptions; use conversions::group_id_from_hex; pub use errors::MarmotKitError; diff --git a/release.md b/release.md index 0034b223..5fedef32 100644 --- a/release.md +++ b/release.md @@ -18,6 +18,8 @@ Current tracks: harness install assets, including Hermes, OpenClaw, and OpenCode. - MarmotKit binding releases use tags like `marmotkit-v0.9.0`. These build generated app-consumable binding bundles from `crates/marmot-uniffi` and attach them to a GitHub Release. +- Marmot C binding releases use tags like `marmotc-v0.9.0`. These build the C ABI bundle (shared/static library, + `marmot.h`, pkg-config file) from `crates/marmot-c` and attach it to a GitHub Release. Future binary or package tracks should follow the same shape, for example `quic-broker-v0.9.0` or `wn-cli-v0.9.0`, only when those artifacts become independently consumed release surfaces. @@ -316,6 +318,36 @@ The Android zip contains: Each manifest records the release tag, source commit, workspace version, `Cargo.lock` hash, Rust toolchain versions, and package contents. App repos should pin a tag and verify the `.sha256` file before vendoring the bundle. +## Marmot C Binding Release + +Use this when native consumers (C/C++, Zig, Odin, raw FFI) need a pinned C ABI bundle instead of building +`crates/marmot-c` from a local MDK checkout. + +The workflow lives at: + +```text +.github/workflows/c-bindings.yml +``` + +It runs only when a tag matching `marmotc-v*` is pushed. The workflow validates version-like tags such as +`marmotc-v0.9.0`, builds the Linux bundle, smoke-tests it against gcc and clang under valgrind, and creates or +updates the matching GitHub Release titled `v - Marmot C`. + +Create the tag: + +```sh +git tag -a marmotc-v0.9.0 -m "Marmot C v0.9.0" +git push origin marmotc-v0.9.0 +``` + +The release job creates these assets: + +- `marmot-c-linux-x86_64-.zip` +- `marmot-c-linux-x86_64-.zip.sha256` + +The zip contains `lib/libmarmot_c.so`, `lib/libmarmot_c.a`, `include/marmot.h`, `lib/pkgconfig/marmot-c.pc`, +`README.md`, and `manifest.json`. Additional targets (macOS, Windows, musl) join the matrix as they get CI coverage. + ## App Repo Consumption For now, app repos should download a versioned MarmotKit release asset and vendor the generated files into their current