From d60571d53f532293b94fc7294f67bcf71cc81fb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:37:36 +0000 Subject: [PATCH] Bind Ditto's C ABI directly instead of the Rust SDK crate Replaces the dittolive-ditto / safer-ffi dependency behind the `ditto` feature with a hand-written binding of the `dittoffi` C ABI and a small safe wrapper, and links the prebuilt `libdittoffi.a` through the crate's own build script. Why: the Rust SDK crate's generated bindings stop compiling on Rust 1.98+ (E0690, rust-lang/rust#155299) and had pinned CI to 1.97. The native library is the same code either way; what the SDK crate added was ~45 transitive crates, deprecated-in-5.0 API surface, and the pin. - src/storage/ditto/ffi.rs: `extern "C"` declarations for the ~30 entry points the backend uses, transcribed from the C header Ditto embeds in its C++ SDK (`Ditto.h`, guard `__RUST_DITTOFFI__`) for release 4.14.7. Each declaration carries its C prototype in a doc comment. - src/storage/ditto/sdk.rs: ownership and error handling over the raw calls (open/free, DQL via the synchronous `dittoffi_try_exec_statement`, sync subscriptions, store observers with the signal-next protocol, presence, offline license, login provider for server mode, logging). CBOR via ciborium; results decoded to serde_json::Value. - build.rs: finds `libdittoffi.a` via DITTO_SDK_DIR / DITTOFFI_SEARCH_PATH, an OUT_DIR cache, or downloads it with curl (DITTO_LOCAL_BUILD=1 forbids). Platform link directives mirror the official crate. - scripts/ditto-abi-check.sh prints the declarations and struct layouts the bindings rely on for any SDK release, so a version bump is a diff. - Transactions are gone: the C ABI exposes them only through async continuations, and set/set_archive were never atomic with respect to each other; statements run one by one. - CI's ditto job is back on stable; docs and CLAUDE.md updated. The C++ package's `libditto.a` was tried first and rejected: it bundles a Rust runtime with global symbols that collide with ours at link time (`rust_eh_personality`). The Rust-SDK artifact exports the same ABI. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NkQyW3WV94GqFDqbyRH8oy --- .github/workflows/ci.yml | 13 +- CLAUDE.md | 11 +- Cargo.lock | 804 ++---------------- crates/taskbook-client/Cargo.toml | 8 +- crates/taskbook-client/build.rs | 156 ++++ crates/taskbook-client/src/auth.rs | 7 +- .../taskbook-client/src/storage/ditto/ffi.rs | 309 +++++++ .../src/storage/{ditto.rs => ditto/mod.rs} | 196 ++--- .../taskbook-client/src/storage/ditto/sdk.rs | 499 +++++++++++ crates/taskbook-client/src/storage/mod.rs | 12 + docs/ditto.md | 44 +- scripts/ditto-abi-check.sh | 72 ++ 12 files changed, 1239 insertions(+), 892 deletions(-) create mode 100644 crates/taskbook-client/build.rs create mode 100644 crates/taskbook-client/src/storage/ditto/ffi.rs rename crates/taskbook-client/src/storage/{ditto.rs => ditto/mod.rs} (85%) create mode 100644 crates/taskbook-client/src/storage/ditto/sdk.rs create mode 100755 scripts/ditto-abi-check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 504b0b5..6bc4118 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,21 +51,16 @@ jobs: run: cargo test --workspace ditto: - # The optional Ditto backend links a prebuilt native library downloaded - # by the dittolive-ditto-sys build script; keep it in its own job so the - # default build stays small. - # - # Pinned toolchain: dittolive-ditto-sys 4.14.7 (via safer-ffi 0.2.0-rc1) - # generates a #[repr(transparent)] struct that Rust 1.98+ rejects with - # E0690 (rust-lang/rust#155299). Bump once Ditto ships a fixed SDK. + # The optional Ditto backend links a prebuilt native library that the + # crate's build script downloads (see crates/taskbook-client/build.rs); + # keep it in its own job so the default build stays small. runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Rust - uses: dtolnay/rust-toolchain@master + uses: dtolnay/rust-toolchain@stable with: - toolchain: 1.97.0 components: clippy - name: Cache dependencies diff --git a/CLAUDE.md b/CLAUDE.md index 5e82cc4..8997dd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,8 +34,8 @@ cargo test # Check for issues cargo clippy -# Build/test with the optional Ditto backend (downloads a ~165 MB native lib on first build). -# Needs Rust 1.85..=1.97 until Ditto fixes an E0690 in its bindings; CI pins 1.97.0. +# Build/test with the optional Ditto backend (build.rs downloads a ~165 MB native +# lib on first build, or set DITTO_SDK_DIR to a directory holding libdittoffi.a). cargo build --package taskbook-client --features ditto cargo test --package taskbook-client --features ditto -- --test-threads=1 ``` @@ -78,7 +78,10 @@ crates/ │ │ ├── mod.rs # StorageBackend trait + config-driven factory │ │ ├── local.rs # LocalStorage (file-based) │ │ ├── remote.rs # RemoteStorage (HTTP + encryption) -│ │ └── ditto.rs # DittoStorage (peer-to-peer CRDT sync, feature "ditto") +│ │ └── ditto/ # DittoStorage (peer-to-peer CRDT sync, feature "ditto") +│ │ ├── mod.rs # backend: document model, diff writes, id renumbering +│ │ ├── sdk.rs # safe wrapper over the C ABI (open, DQL, observers, presence) +│ │ └── ffi.rs # hand-written extern "C" bindings to libdittoffi (see build.rs) │ └── tui/ # Interactive TUI (ratatui + crossterm) │ └── taskbook-server/ # Server binary (tb-server) @@ -259,7 +262,7 @@ All under `/api/v1/` unless noted: - `base64` - Encoding encrypted data - `rpassword` - Secure password input - `fs2` - File locking for local storage -- `dittolive-ditto` (optional, feature `ditto`) - Ditto peer-to-peer sync SDK +- `ciborium` + `libc` (optional, feature `ditto`) - CBOR for Ditto's C ABI; the native `libdittoffi.a` is fetched by `build.rs` (`scripts/ditto-abi-check.sh` diffs the ABI across SDK releases) ### Common (taskbook-common) - `aes-gcm` - AES-256-GCM encryption diff --git a/Cargo.lock b/Cargo.lock index 61013c3..680a45d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,18 +43,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -176,39 +164,6 @@ dependencies = [ "password-hash", ] -[[package]] -name = "async-compat" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c97d7ff3c25d6c10d64170c12acaf5d4245e76dece3779c1d92b153a64f11df" -dependencies = [ - "futures-core", - "futures-io", - "once_cell", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "async_fn_traits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc58d489c5f2d2c5be31b9004cec7af25a70d23df4d8111715eee736234cf217" -dependencies = [ - "paste", -] - [[package]] name = "atoi" version = "2.0.0" @@ -319,12 +274,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "base64ct" version = "1.8.3" @@ -379,15 +328,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -405,20 +345,6 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] [[package]] name = "byteorder" @@ -496,6 +422,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -784,18 +737,8 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" -dependencies = [ - "darling_core 0.24.1", - "darling_macro 0.24.1", + "darling_core", + "darling_macro", ] [[package]] @@ -811,86 +754,17 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_core" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 3.0.6", -] - [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" -dependencies = [ - "darling_core 0.24.1", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "dashmap" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror 2.0.18", -] - [[package]] name = "deltae" version = "0.3.2" @@ -915,7 +789,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -994,55 +867,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dittolive-ditto" -version = "4.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "472ae0d8ba4a9e16dae34827f2ac7fb018a5021540d6b5c5e2820d4249989a2e" -dependencies = [ - "async-trait", - "async_fn_traits", - "base64 0.22.1", - "bytemuck", - "bytes", - "chrono", - "dashmap", - "dittolive-ditto-sys", - "dotenvy", - "ext-trait 1.0.1", - "extern-c", - "fs_extra", - "hashbrown 0.14.5", - "macro_rules_attribute", - "never-say-never", - "rand 0.8.6", - "safer-ffi", - "serde", - "serde-transcode", - "serde_bytes", - "serde_cbor", - "serde_json", - "serde_with", - "thiserror 2.0.18", - "to_method", - "tokio", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "dittolive-ditto-sys" -version = "4.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46ba52f64e56ad99377a6f15d11a1bf3484a72f8c893d9d0fc365923ad63f209" -dependencies = [ - "macro_rules_attribute", - "paste", - "safer-ffi", - "tokio", -] - [[package]] name = "document-features" version = "0.2.12" @@ -1064,12 +888,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - [[package]] name = "either" version = "1.16.0" @@ -1143,70 +961,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "ext-trait" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d772df1c1a777963712fb68e014235e80863d6a91a85c4e06ba2d16243a310e5" -dependencies = [ - "ext-trait-proc_macros 1.0.1", -] - -[[package]] -name = "ext-trait" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1caab68b8ae950f6bc3ea2190a26208f9d83909b968914eedc63b008a51d13" -dependencies = [ - "ext-trait-proc_macros 2.0.3", -] - -[[package]] -name = "ext-trait-proc_macros" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ab7934152eaf26aa5aa9f7371408ad5af4c31357073c9e84c3b9d7f11ad639a" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "ext-trait-proc_macros" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c9c6707feeeea253f67d67fb7278bc246aa4f34e264b72f6e56d1a0c9151e1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "extension-traits" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a296e5a895621edf9fa8329c83aa1cb69a964643e36cf54d8d7a69b789089537" -dependencies = [ - "ext-trait 1.0.1", -] - -[[package]] -name = "extension-traits" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a50164f37d73444116063ec0cb91e44cea2d381d02d1c1e20b75e182624d207" -dependencies = [ - "ext-trait 2.0.3", -] - -[[package]] -name = "extern-c" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320bea982e85d42441eb25c49b41218e7eaa2657e8f90bc4eca7437376751e23" - [[package]] name = "fancy-regex" version = "0.11.0" @@ -1331,21 +1085,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -1419,7 +1158,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1528,19 +1266,13 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.14.0", + "indexmap", "slab", "tokio", "tokio-util", "tracing", ] -[[package]] -name = "half" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" - [[package]] name = "half" version = "2.7.1" @@ -1558,22 +1290,6 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -1744,7 +1460,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", @@ -1914,17 +1630,6 @@ dependencies = [ "tiff", ] -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -1961,7 +1666,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling 0.23.0", + "darling", "indoc", "proc-macro2", "quote", @@ -1995,60 +1700,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jiff" -version = "0.2.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" -dependencies = [ - "defmt", - "jiff-core", - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-link", -] - -[[package]] -name = "jiff-core" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" -dependencies = [ - "defmt", - "log", -] - -[[package]] -name = "jiff-static" -version = "0.2.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - [[package]] name = "jni" version = "0.22.4" @@ -2276,22 +1927,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "macro_rules_attribute" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf0c9b980bf4f3a37fd7b1c066941dd1b1d0152ce6ee6e8fe8c49b9f6810d862" -dependencies = [ - "macro_rules_attribute-proc_macro", - "paste", -] - -[[package]] -name = "macro_rules_attribute-proc_macro" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58093314a45e00c77d5c508f76e77c3396afbbc0d01506e7fae47b018bac2b1d" - [[package]] name = "matchers" version = "0.2.0" @@ -2354,13 +1989,13 @@ version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" dependencies = [ - "base64 0.22.1", + "base64", "evmap", "http-body-util", "hyper", "hyper-rustls", "hyper-util", - "indexmap 2.14.0", + "indexmap", "ipnet", "metrics", "metrics-util", @@ -2432,12 +2067,6 @@ dependencies = [ "pxfm", ] -[[package]] -name = "never-say-never" -version = "6.6.666" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf5a574dadd7941adeaa71823ecba5e28331b8313fb2e1c6a5c7e5981ea53ad6" - [[package]] name = "nix" version = "0.29.0" @@ -2718,12 +2347,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2904,15 +2527,6 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" -dependencies = [ - "portable-atomic", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -2947,15 +2561,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -3291,26 +2896,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "ref-cast" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - [[package]] name = "regex" version = "1.12.4" @@ -3346,7 +2931,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", @@ -3550,43 +3135,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "safer-ffi" -version = "0.2.0-rc1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ac90f8785f12476d1c96635cfff3ec766c851f104f228b82e6fe7d4022a241" -dependencies = [ - "async-compat", - "extension-traits 2.0.3", - "extern-c", - "futures", - "libc", - "macro_rules_attribute", - "never-say-never", - "paste", - "safer_ffi-proc_macros", - "scopeguard", - "seal-the-deal", - "stabby", - "tokio", - "uninit", - "unwind_safe", - "with_builtin_macros", -] - -[[package]] -name = "safer_ffi-proc_macros" -version = "0.2.0-rc1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4db5d599442323f00d164c518c38f26456934275957598e4655bdc2c79ecf16e" -dependencies = [ - "macro_rules_attribute", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "same-file" version = "1.0.6" @@ -3605,30 +3153,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "scoped-tls" version = "1.0.1" @@ -3641,26 +3165,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "seal-the-deal" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e82421a007f516184c2c3868eec8ff4ccdb9d45526d6d85349efdbed3bf85e3a" -dependencies = [ - "seal-the-deal-proc_macros", -] - -[[package]] -name = "seal-the-deal-proc_macros" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a86068a0f8a289b75e2150a89cff5c663b86fe911375090e23e40f6573372d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -3700,35 +3204,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-transcode" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590c0e25c2a5bb6e85bf5c1bce768ceb86b316e7a01bdf07d2cb4ec2271990e2" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_cbor" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" -dependencies = [ - "half 1.8.3", - "serde", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -3785,39 +3260,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "3.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" -dependencies = [ - "base64 0.23.1", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "jiff", - "schemars 0.9.0", - "schemars 1.2.2", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" -dependencies = [ - "darling 0.24.1", - "proc-macro2", - "quote", - "syn 3.0.6", -] - [[package]] name = "sha1" version = "0.10.6" @@ -3840,12 +3282,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sha2-const-stable" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" - [[package]] name = "sharded-slab" version = "0.1.7" @@ -3999,7 +3435,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "chrono", "crc", @@ -4012,7 +3448,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap 2.14.0", + "indexmap", "log", "memchr", "once_cell", @@ -4076,7 +3512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64 0.22.1", + "base64", "bitflags 2.13.0", "byteorder", "bytes", @@ -4120,7 +3556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64 0.22.1", + "base64", "bitflags 2.13.0", "byteorder", "chrono", @@ -4178,40 +3614,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "stabby" -version = "72.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d53d2428934c46277fafd2d41e39357595aa1e47954c75db2b14ed90632f3cc" -dependencies = [ - "rustversion", - "stabby-abi", -] - -[[package]] -name = "stabby-abi" -version = "72.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f375eae680bb54203ee5e47d4cd2ae7b79c0a79ed90919279f38f500ad53f190" -dependencies = [ - "rustc_version", - "rustversion", - "sha2-const-stable", - "stabby-macros", -] - -[[package]] -name = "stabby-macros" -version = "72.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea664671a576c5f7e32fee291ac123d82af5e92b0689beb3555347c00c76eef1" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4290,17 +3692,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4326,13 +3717,13 @@ name = "taskbook-client" version = "1.5.0" dependencies = [ "arboard", - "base64 0.22.1", + "base64", "chrono", + "ciborium", "clap", "colored", "crossterm", "dirs", - "dittolive-ditto", "fs2", "libc", "ratatui", @@ -4364,7 +3755,7 @@ version = "1.5.0" dependencies = [ "argon2", "axum", - "base64 0.22.1", + "base64", "chrono", "futures-util", "metrics", @@ -4412,7 +3803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64 0.22.1", + "base64", "bitflags 2.13.0", "fancy-regex", "filedescriptor", @@ -4504,7 +3895,7 @@ checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", - "half 2.7.1", + "half", "quick-error", "weezl", "zune-jpeg", @@ -4517,14 +3908,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", - "time-macros", ] [[package]] @@ -4533,16 +3922,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -4568,12 +3947,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "to_method" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8" - [[package]] name = "tokio" version = "1.52.3" @@ -4583,7 +3956,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -4637,36 +4009,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.15+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow", -] - [[package]] name = "tower" version = "0.5.3" @@ -4852,15 +4194,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "uninit" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e130f2ed46ca5d8ec13c7ff95836827f92f5f5f37fd2b2bf16f33c408d98bb6" -dependencies = [ - "extension-traits 1.0.1", -] - [[package]] name = "universal-hash" version = "0.5.1" @@ -4877,12 +4210,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "unwind_safe" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0976c77def3f1f75c4ef892a292c31c0bbe9e3d0702c63044d7c76db298171a3" - [[package]] name = "url" version = "2.5.8" @@ -4893,7 +4220,6 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] @@ -4917,7 +4243,6 @@ dependencies = [ "atomic", "getrandom 0.4.2", "js-sys", - "serde_core", "wasm-bindgen", ] @@ -5069,7 +4394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.14.0", + "indexmap", "wasm-encoder", "wasmparser", ] @@ -5082,7 +4407,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.13.0", "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap", "semver", ] @@ -5542,15 +4867,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5585,7 +4901,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.14.0", + "indexmap", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -5616,7 +4932,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.13.0", - "indexmap 2.14.0", + "indexmap", "log", "serde", "serde_derive", @@ -5635,7 +4951,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.14.0", + "indexmap", "log", "semver", "serde", @@ -5645,26 +4961,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "with_builtin_macros" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a59d55032495429b87f9d69954c6c8602e4d3f3e0a747a12dea6b0b23de685da" -dependencies = [ - "with_builtin_macros-proc_macros", -] - -[[package]] -name = "with_builtin_macros-proc_macros" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15bd7679c15e22924f53aee34d4e448c45b674feb6129689af88593e129f8f42" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "writeable" version = "0.6.3" diff --git a/crates/taskbook-client/Cargo.toml b/crates/taskbook-client/Cargo.toml index c6845b9..50ebfd7 100644 --- a/crates/taskbook-client/Cargo.toml +++ b/crates/taskbook-client/Cargo.toml @@ -29,14 +29,14 @@ rpassword = "7" fs2 = "0.4" unicode-width = "0.2" # Optional Ditto peer-to-peer backend (enable with `--features ditto`). -# Pulls in a prebuilt native library (libdittoffi) and currently needs -# Rust 1.85..=1.97 (E0690 in the SDK bindings on 1.98+); see docs/ditto.md. -dittolive-ditto = { version = "4.14", optional = true } +# Binds Ditto's C ABI directly (see src/storage/ditto/ffi.rs and build.rs); +# the prebuilt native library is located or downloaded by build.rs. +ciborium = { version = "0.2", optional = true } libc = { version = "0.2", optional = true } [features] default = [] -ditto = ["dep:dittolive-ditto", "dep:libc"] +ditto = ["dep:ciborium", "dep:libc"] [[bin]] name = "tb" diff --git a/crates/taskbook-client/build.rs b/crates/taskbook-client/build.rs new file mode 100644 index 0000000..0ea3c2c --- /dev/null +++ b/crates/taskbook-client/build.rs @@ -0,0 +1,156 @@ +//! Build script for the optional `ditto` feature: finds (or downloads) the +//! prebuilt Ditto native library and emits the link directives. +//! +//! Resolution order for `libdittoffi.a`: +//! 1. `DITTO_SDK_DIR` — a directory containing the library. +//! 2. `DITTOFFI_SEARCH_PATH` — same, kept for parity with the official crate. +//! 3. The download cache: `DITTO_SDK_CACHE`, else +//! `$XDG_CACHE_HOME/taskbook/ditto-sdk` (`~/.cache/...`), else `OUT_DIR`. +//! It is keyed by SDK version and target so check/build/test and +//! different toolchains share one copy. +//! 4. Download into that cache from `software.ditto.live` with `curl`, +//! unless `DITTO_LOCAL_BUILD=1`, which forbids network access (Nix, +//! offline CI). +//! +//! The library is the per-target artifact Ditto publishes for its Rust SDK; +//! it carries the same `dittoffi` C ABI as the C++ SDK's `libditto.a`, minus +//! the C++ wrapper (whose bundled Rust runtime collides with ours). + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Ditto SDK release the FFI declarations in `src/storage/ditto/ffi.rs` were +/// transcribed from. Bumping it means re-checking that file (see +/// `scripts/ditto-abi-check.sh`). +const DITTO_SDK_VERSION: &str = "4.14.7"; +const LIB_NAME: &str = "dittoffi"; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rustc-env=TB_DITTO_SDK_VERSION={DITTO_SDK_VERSION}"); + + if env::var_os("CARGO_FEATURE_DITTO").is_none() { + return; + } + + for var in [ + "DITTO_SDK_DIR", + "DITTOFFI_SEARCH_PATH", + "DITTO_SDK_CACHE", + "DITTO_LOCAL_BUILD", + ] { + println!("cargo:rerun-if-env-changed={var}"); + } + + let target = env::var("TARGET").expect("TARGET is set by cargo"); + let lib_file = lib_filename(&target); + + let lib_dir = find_or_fetch(&target, &lib_file); + + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + println!("cargo:rustc-link-lib=static={LIB_NAME}"); + + // Platform libraries the static archive relies on (mirrors the official + // crate's build script). + if target.contains("apple") { + println!("cargo:rustc-link-lib=framework=Security"); + println!("cargo:rustc-link-lib=framework=SystemConfiguration"); + } else if target.contains("windows") { + for lib in ["ntdll", "iphlpapi", "oleaut32", "ole32"] { + println!("cargo:rustc-link-lib={lib}"); + } + } +} + +fn lib_filename(target: &str) -> String { + if target.contains("windows-msvc") { + format!("{LIB_NAME}.lib") + } else { + format!("lib{LIB_NAME}.a") + } +} + +fn find_or_fetch(target: &str, lib_file: &str) -> PathBuf { + for var in ["DITTO_SDK_DIR", "DITTOFFI_SEARCH_PATH"] { + if let Some(dir) = env::var_os(var).map(PathBuf::from) { + let candidate = dir.join(lib_file); + if candidate.is_file() { + return dir; + } + panic!("{var}={} does not contain {lib_file}", dir.display()); + } + } + + let cache_dir = cache_root().join(DITTO_SDK_VERSION).join(target); + let cached = cache_dir.join(lib_file); + if cached.is_file() { + return cache_dir; + } + + if env::var("DITTO_LOCAL_BUILD") + .map(|v| v != "0") + .unwrap_or(false) + { + panic!( + "DITTO_LOCAL_BUILD is set and {lib_file} was not found; point DITTO_SDK_DIR at a \ + directory containing the Ditto {DITTO_SDK_VERSION} library for {target}" + ); + } + + fs::create_dir_all(&cache_dir).expect("create cache dir"); + download(target, lib_file, &cached); + cache_dir +} + +/// Where downloaded libraries are kept across builds. +fn cache_root() -> PathBuf { + if let Some(dir) = env::var_os("DITTO_SDK_CACHE") { + return PathBuf::from(dir); + } + let base = env::var_os("XDG_CACHE_HOME") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))) + .or_else(|| env::var_os("LOCALAPPDATA").map(PathBuf::from)); + match base { + Some(base) => base.join("taskbook").join("ditto-sdk"), + None => { + PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set by cargo")).join("ditto-sdk") + } + } +} + +fn download(target: &str, lib_file: &str, dest: &Path) { + let url = format!( + "https://software.ditto.live/rust/Ditto/{DITTO_SDK_VERSION}/{target}/release/{lib_file}" + ); + println!("cargo:warning=downloading Ditto native library {url}"); + + let tmp = dest.with_extension("part"); + let status = Command::new("curl") + .args([ + "--fail", + "--silent", + "--show-error", + "--location", + "--output", + ]) + .arg(&tmp) + .arg(&url) + .status() + .unwrap_or_else(|e| { + panic!("could not run curl to fetch the Ditto library ({e}); set DITTO_SDK_DIR instead") + }); + if !status.success() { + let _ = fs::remove_file(&tmp); + panic!("downloading {url} failed ({status}); set DITTO_SDK_DIR to a local copy"); + } + let size = fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0); + if size < 1024 * 1024 { + let _ = fs::remove_file(&tmp); + panic!("downloaded {url} is implausibly small ({size} bytes)"); + } + fs::rename(&tmp, dest).expect("move downloaded library into place"); +} diff --git a/crates/taskbook-client/src/auth.rs b/crates/taskbook-client/src/auth.rs index b64ba79..f4a6918 100644 --- a/crates/taskbook-client/src/auth.rs +++ b/crates/taskbook-client/src/auth.rs @@ -221,13 +221,14 @@ pub fn status() -> Result<()> { fn ditto_status(config: &Config) -> Result<()> { let ditto = &config.ditto; println!("Mode: {}", "ditto".green().bold()); - if !cfg!(feature = "ditto") { - println!( + match crate::storage::ditto_sdk_version() { + Some(version) => println!("SDK: Ditto {version} (dittoffi C ABI)"), + None => println!( "{}", "This build of tb has no Ditto support; rebuild with `--features ditto`." .red() .bold() - ); + ), } println!( "App ID: {}", diff --git a/crates/taskbook-client/src/storage/ditto/ffi.rs b/crates/taskbook-client/src/storage/ditto/ffi.rs new file mode 100644 index 0000000..70cebc2 --- /dev/null +++ b/crates/taskbook-client/src/storage/ditto/ffi.rs @@ -0,0 +1,309 @@ +//! Raw bindings to the subset of Ditto's C ABI (`dittoffi`) that the backend +//! uses. +//! +//! Source of truth: the `extern "C"` header embedded in the Ditto C++ SDK's +//! `Ditto.h` (include guard `__RUST_DITTOFFI__`), generated by safer-ffi for +//! each release. The declarations below were transcribed from SDK version +//! `DITTO_SDK_VERSION` (see `build.rs`); `scripts/ditto-abi-check.sh` prints +//! the same declarations from any release so a bump can be diffed. The ABI +//! is Ditto-internal and has no stability promise: treat every SDK upgrade +//! as a re-verification of this file. +//! +//! Conventions, as safer-ffi emits them: +//! - `*_throws` functions return a `Result*` struct: `error` is non-null on +//! failure (free with [`dittoffi_error_free`]), `success` is valid +//! otherwise. +//! - Slices are `{ptr, len}`. Borrowed slices (`SliceRef`) are ours; +//! owned slices (`SliceBoxed`) come from Ditto and are returned with +//! [`ditto_c_bytes_free`]. Owned C strings go back via +//! [`ditto_c_string_free`]. +//! - Boxed callbacks are `{env_ptr, call, free}`; Ditto calls `free` exactly +//! once when it is done with the callback. +#![allow(dead_code, non_camel_case_types)] + +use std::os::raw::{c_char, c_void}; + +/// `CDitto_t` — an opaque Ditto instance. +#[repr(C)] +pub struct CDitto { + _private: [u8; 0], +} + +/// `dittoffi_error_t` — an opaque error object. +#[repr(C)] +pub struct Error { + _private: [u8; 0], +} + +/// `dittoffi_query_result_t` +#[repr(C)] +pub struct QueryResult { + _private: [u8; 0], +} + +/// `dittoffi_query_result_item_t` +#[repr(C)] +pub struct QueryResultItem { + _private: [u8; 0], +} + +/// `dittoffi_sync_subscription_t` +#[repr(C)] +pub struct SyncSubscription { + _private: [u8; 0], +} + +/// `dittoffi_store_observer_t` +#[repr(C)] +pub struct StoreObserver { + _private: [u8; 0], +} + +/// `CLoginProvider_t` +#[repr(C)] +pub struct LoginProvider { + _private: [u8; 0], +} + +/// `slice_ref_uint8_t` — a borrowed byte slice. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct SliceRef { + pub ptr: *const u8, + pub len: usize, +} + +impl SliceRef { + pub fn from_bytes(bytes: &[u8]) -> Self { + Self { + ptr: bytes.as_ptr(), + len: bytes.len(), + } + } + + /// The representation safer-ffi uses for `Option::None`. + pub const NONE: Self = Self { + ptr: std::ptr::null(), + len: 0, + }; +} + +/// `slice_boxed_uint8_t` — an owned byte slice allocated by Ditto. +#[repr(C)] +pub struct SliceBoxed { + pub ptr: *mut u8, + pub len: usize, +} + +/// `TransportConfigMode_t` +#[repr(C)] +#[derive(Clone, Copy)] +pub enum TransportConfigMode { + PlatformDependent = 0, + PlatformIndependent = 1, +} + +/// `CLogLevel_t` +#[repr(C)] +#[derive(Clone, Copy)] +pub enum CLogLevel { + Error = 1, + Warning = 2, + Info = 3, + Debug = 4, + Verbose = 5, +} + +/// `dittoffi_error_code_t` is a C enum; only its integer value is used here. +pub type ErrorCode = i32; + +/// `dittoffi_result_void_t` +#[repr(C)] +pub struct ResultVoid { + pub error: *mut Error, +} + +/// `dittoffi_result_CDitto_ptr_t` +#[repr(C)] +pub struct ResultDitto { + pub error: *mut Error, + pub success: *mut CDitto, +} + +/// `dittoffi_result_dittoffi_query_result_ptr_t` +#[repr(C)] +pub struct ResultQueryResult { + pub error: *mut Error, + pub success: *mut QueryResult, +} + +/// `dittoffi_result_dittoffi_sync_subscription_ptr_t` +#[repr(C)] +pub struct ResultSyncSubscription { + pub error: *mut Error, + pub success: *mut SyncSubscription, +} + +/// `dittoffi_result_dittoffi_store_observer_ptr_t` +#[repr(C)] +pub struct ResultStoreObserver { + pub error: *mut Error, + pub success: *mut StoreObserver, +} + +/// `ArcDynFn0_void_t` — `Arc`. Handed to observer +/// callbacks as the "signal next" function; the receiver owns one strong +/// reference and must `release` it. +#[repr(C)] +pub struct ArcDynFn0 { + pub env_ptr: *mut c_void, + pub call: Option, + pub release: Option, + pub retain: Option, +} + +/// `BoxDynFnMut3_void_dittoffi_query_result_ptr_ArcDynFn0_void_t` — the +/// store observer callback: `Box`. +#[repr(C)] +pub struct StoreObserverCallback { + pub env_ptr: *mut c_void, + pub call: unsafe extern "C" fn(*mut c_void, *mut QueryResult, ArcDynFn0), + pub free: unsafe extern "C" fn(*mut c_void), +} + +extern "C" { + // --- instance lifecycle ------------------------------------------------- + + /// `dittoffi_result_CDitto_ptr_t dittoffi_ditto_open_throws(slice_ref_uint8_t config_cbor, TransportConfigMode_t, char const *default_root_directory)` + pub fn dittoffi_ditto_open_throws( + config_cbor: SliceRef, + transport_config_mode: TransportConfigMode, + default_root_directory: *const c_char, + ) -> ResultDitto; + + /// `void ditto_free(CDitto_t *ditto)` + pub fn ditto_free(ditto: *mut CDitto); + + /// `int32_t ditto_disable_sync_with_v3(CDitto_t const *ditto)` — 0 on success. + pub fn ditto_disable_sync_with_v3(ditto: *const CDitto) -> i32; + + /// `dittoffi_result_void_t dittoffi_ditto_set_offline_only_license_token_throws(CDitto_t const *, char const *license)` + pub fn dittoffi_ditto_set_offline_only_license_token_throws( + ditto: *const CDitto, + license: *const c_char, + ) -> ResultVoid; + + /// `dittoffi_result_void_t dittoffi_ditto_try_start_sync(CDitto_t const *)` + pub fn dittoffi_ditto_try_start_sync(ditto: *const CDitto) -> ResultVoid; + + /// `void dittoffi_ditto_stop_sync(CDitto_t const *)` + pub fn dittoffi_ditto_stop_sync(ditto: *const CDitto); + + // --- DQL --------------------------------------------------------------- + + /// `dittoffi_result_dittoffi_query_result_ptr_t dittoffi_try_exec_statement(CDitto_t const *, char const *statement, slice_ref_uint8_t args_cbor)` + pub fn dittoffi_try_exec_statement( + ditto: *const CDitto, + statement: *const c_char, + args_cbor: SliceRef, + ) -> ResultQueryResult; + + /// `size_t dittoffi_query_result_item_count(dittoffi_query_result_t const *)` + pub fn dittoffi_query_result_item_count(result: *const QueryResult) -> usize; + + /// `dittoffi_query_result_item_t *dittoffi_query_result_item_at(dittoffi_query_result_t const *, size_t idx)` + pub fn dittoffi_query_result_item_at( + result: *const QueryResult, + idx: usize, + ) -> *mut QueryResultItem; + + /// `slice_boxed_uint8_t dittoffi_query_result_item_cbor(dittoffi_query_result_item_t const *)` + pub fn dittoffi_query_result_item_cbor(item: *const QueryResultItem) -> SliceBoxed; + + /// `void dittoffi_query_result_item_free(dittoffi_query_result_item_t *)` + pub fn dittoffi_query_result_item_free(item: *mut QueryResultItem); + + /// `void dittoffi_query_result_free(dittoffi_query_result_t *)` + pub fn dittoffi_query_result_free(result: *mut QueryResult); + + // --- sync subscriptions ------------------------------------------------ + + /// `dittoffi_result_dittoffi_sync_subscription_ptr_t dittoffi_sync_register_subscription_throws(CDitto_t const *, char const *query, slice_ref_uint8_t query_args_cbor)` + pub fn dittoffi_sync_register_subscription_throws( + ditto: *const CDitto, + query: *const c_char, + query_args_cbor: SliceRef, + ) -> ResultSyncSubscription; + + /// `void dittoffi_sync_subscription_cancel(dittoffi_sync_subscription_t const *)` + pub fn dittoffi_sync_subscription_cancel(subscription: *const SyncSubscription); + + /// `void dittoffi_sync_subscription_free(dittoffi_sync_subscription_t *)` + pub fn dittoffi_sync_subscription_free(subscription: *mut SyncSubscription); + + // --- store observers --------------------------------------------------- + + /// `dittoffi_result_dittoffi_store_observer_ptr_t dittoffi_store_register_observer_throws(CDitto_t const *, char const *query, slice_ref_uint8_t query_args_cbor, BoxDynFnMut3_... callback)` + pub fn dittoffi_store_register_observer_throws( + ditto: *const CDitto, + query: *const c_char, + query_args_cbor: SliceRef, + callback: StoreObserverCallback, + ) -> ResultStoreObserver; + + /// `void dittoffi_store_observer_cancel(dittoffi_store_observer_t const *)` + pub fn dittoffi_store_observer_cancel(observer: *const StoreObserver); + + /// `void dittoffi_store_observer_free(dittoffi_store_observer_t *)` + pub fn dittoffi_store_observer_free(observer: *mut StoreObserver); + + // --- presence ---------------------------------------------------------- + + /// `slice_boxed_uint8_t dittoffi_presence_graph(CDitto_t const *)` — JSON `PresenceGraph`. + pub fn dittoffi_presence_graph(ditto: *const CDitto) -> SliceBoxed; + + // --- authentication (server connect mode) ------------------------------ + + /// `CLoginProvider_t *ditto_auth_client_make_login_provider(void *ctx, void (*retain)(void *), void (*release)(void *), void (*expiring_cb)(void *, uint32_t))` + pub fn ditto_auth_client_make_login_provider( + ctx: *mut c_void, + retain: unsafe extern "C" fn(*mut c_void), + release: unsafe extern "C" fn(*mut c_void), + expiring_cb: unsafe extern "C" fn(*mut c_void, u32), + ) -> *mut LoginProvider; + + /// `void ditto_auth_set_login_provider(CDitto_t const *, CLoginProvider_t *login_provider)` + pub fn ditto_auth_set_login_provider(ditto: *const CDitto, login_provider: *mut LoginProvider); + + /// `int32_t ditto_auth_client_login_with_token(CDitto_t *, char const *token, char const *provider)` + pub fn ditto_auth_client_login_with_token( + ditto: *mut CDitto, + token: *const c_char, + provider: *const c_char, + ) -> i32; + + // --- logging ----------------------------------------------------------- + + /// `void ditto_logger_enabled(bool enabled)` + pub fn ditto_logger_enabled(enabled: bool); + + /// `void ditto_logger_minimum_log_level(CLogLevel_t log_level)` + pub fn ditto_logger_minimum_log_level(log_level: CLogLevel); + + // --- errors and memory ------------------------------------------------- + + /// `dittoffi_error_code_t dittoffi_error_code(dittoffi_error_t const *)` + pub fn dittoffi_error_code(error: *const Error) -> ErrorCode; + + /// `char *dittoffi_error_description(dittoffi_error_t const *)` — free with `ditto_c_string_free`. + pub fn dittoffi_error_description(error: *const Error) -> *mut c_char; + + /// `void dittoffi_error_free(dittoffi_error_t *)` + pub fn dittoffi_error_free(error: *mut Error); + + /// `void ditto_c_string_free(char *s)` + pub fn ditto_c_string_free(s: *mut c_char); + + /// `void ditto_c_bytes_free(slice_boxed_uint8_t bytes)` + pub fn ditto_c_bytes_free(bytes: SliceBoxed); +} diff --git a/crates/taskbook-client/src/storage/ditto.rs b/crates/taskbook-client/src/storage/ditto/mod.rs similarity index 85% rename from crates/taskbook-client/src/storage/ditto.rs rename to crates/taskbook-client/src/storage/ditto/mod.rs index a5745f9..f165cd6 100644 --- a/crates/taskbook-client/src/storage/ditto.rs +++ b/crates/taskbook-client/src/storage/ditto/mod.rs @@ -23,14 +23,21 @@ //! HTTP server backend does. Field-level merging is deliberately given up. //! //! * `set` / `set_archive` diff the new map against the last read snapshot -//! and only upsert changed items and soft-delete missing ones, inside one -//! Ditto transaction. The collection is never replaced wholesale, which -//! would fight the CRDT and clobber concurrent edits from other devices. +//! and only upsert changed items and soft-delete missing ones. The +//! collection is never replaced wholesale, which would fight the CRDT and +//! clobber concurrent edits from other devices. //! //! * Taskbook ids are sequential (`max + 1`), so two devices working offline //! can both create item 8. Both documents survive the merge; on the next //! read the duplicates are renumbered to fresh ids and written back. Ids //! can therefore shift after a sync, but nothing is lost. +//! +//! The native library is driven through a hand-written binding of Ditto's +//! C ABI ([`ffi`]) wrapped by [`sdk`]; see `build.rs` for how the library is +//! obtained and `docs/ditto.md` for the ABI-tracking consequences. + +mod ffi; +pub mod sdk; use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -39,11 +46,8 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use base64::Engine; -use dittolive_ditto::dql::QueryResult; -use dittolive_ditto::prelude::*; -use dittolive_ditto::sync::SyncSubscription; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{json, Value}; use taskbook_common::encryption::{decrypt_item, encrypt_item, EncryptedItem}; use taskbook_common::StorageItem; @@ -102,13 +106,12 @@ impl Snapshots { } pub struct DittoStorage { - ditto: Ditto, - rt: tokio::runtime::Runtime, + ditto: sdk::Ditto, collection: String, key: Option<[u8; 32]>, flush_timeout: Duration, sync_started: bool, - _subscription: Option>, + _subscription: Option, snapshots: Mutex, dirty: AtomicBool, } @@ -143,7 +146,7 @@ impl DittoStorage { } None => None, }; - DittoConfigConnect::SmallPeersOnly { private_key } + sdk::Connect::SmallPeersOnly { private_key } } DittoConnect::Server => { let url = settings @@ -151,10 +154,9 @@ impl DittoStorage { .as_deref() .filter(|u| !u.is_empty()) .ok_or_else(|| general("ditto.url is required for connect = \"server\""))?; - let url = url - .parse() - .map_err(|e| general(format!("invalid ditto.url {url:?}: {e}")))?; - DittoConfigConnect::Server { url } + sdk::Connect::Server { + url: url.to_string(), + } } }; @@ -175,7 +177,7 @@ impl DittoStorage { /// (used by tests). fn open( settings: &DittoSettings, - connect: DittoConfigConnect, + connect: sdk::Connect, key: Option<[u8; 32]>, token: Option, license: Option, @@ -189,15 +191,7 @@ impl DittoStorage { let dir = settings.persistence_path(); std::fs::create_dir_all(&dir)?; - let config = DittoConfig::new(settings.app_id.clone(), connect) - .with_persistence_directory(absolute(&dir)); - - let rt = tokio::runtime::Builder::new_multi_thread() - .worker_threads(1) - .enable_all() - .build()?; - - let ditto = { + let mut ditto = { // Ditto's native init (triggered by the first SDK call) prints a // tracing-filter warning straight to stderr regardless of log // settings; keep it off the terminal for one-shot CLI commands @@ -208,13 +202,25 @@ impl DittoStorage { StderrSilencer::new() }; configure_logging(); - let ditto = Ditto::open_sync(config).map_err(ditto_err)?; + let ditto = + sdk::Ditto::open(&settings.app_id, &connect, &absolute(&dir)).map_err(sdk_err)?; // Mutating DQL (INSERT/UPDATE) is only enabled once legacy v3 sync // is switched off; every peer here runs this version or newer. - ditto.disable_sync_with_v3().map_err(ditto_err)?; + ditto.disable_sync_with_v3().map_err(sdk_err)?; ditto }; + // `Server` connect mode authenticates with a token whenever Ditto + // asks for it (initially and before expiry). + if matches!(connect, sdk::Connect::Server { .. }) { + let token = token.ok_or_else(|| { + general("ditto connect = \"server\" needs an auth token — run `tb --ditto-init`") + })?; + ditto + .set_login(&token, &settings.provider) + .map_err(sdk_err)?; + } + // Offline (peers) identities must be activated with a license token // before sync can start; online identities activate through login. if let Some(license) = license.as_deref() { @@ -223,33 +229,11 @@ impl DittoStorage { .map_err(|e| general(format!("ditto: invalid license token: {e}")))?; } - // `Server` connect mode authenticates with a token supplied from the - // expiration handler, which Ditto also invokes for the initial login. - if let Some(auth) = ditto.auth() { - let token = token.ok_or_else(|| { - general("ditto connect = \"server\" needs an auth token — run `tb --ditto-init`") - })?; - let provider = settings.provider.clone(); - auth.set_expiration_handler(move |ditto: &Ditto, _remaining: Duration| { - let token = token.clone(); - let provider = provider.clone(); - let auth = ditto.auth(); - async move { - if let Some(auth) = auth { - if let Err(e) = auth.login(&token, &provider) { - eprintln!("ditto: login failed: {e}"); - } - } - } - }); - } - let subscription = if start_sync { let subscription = ditto - .sync() - .register_subscription_v2(format!("SELECT * FROM {}", settings.collection)) - .map_err(ditto_err)?; - ditto.sync().start().map_err(ditto_err)?; + .register_subscription(&format!("SELECT * FROM {}", settings.collection)) + .map_err(sdk_err)?; + ditto.start_sync().map_err(sdk_err)?; Some(subscription) } else { None @@ -257,7 +241,6 @@ impl DittoStorage { Ok(Self { ditto, - rt, collection: settings.collection.clone(), key, flush_timeout: Duration::from_millis(settings.flush_timeout_ms), @@ -268,10 +251,8 @@ impl DittoStorage { }) } - fn execute(&self, query: String, args: serde_json::Value) -> Result { - self.rt - .block_on(self.ditto.store().execute_v2((query, args))) - .map_err(ditto_err) + fn execute(&self, query: &str, args: Value) -> Result> { + self.ditto.execute(query, Some(&args)).map_err(sdk_err) } fn select_query(&self) -> String { @@ -356,11 +337,11 @@ impl DittoStorage { /// Read one logical collection, resolving duplicate taskbook ids, and /// refresh the diff snapshot. fn read(&self, archived: bool) -> Result> { - let result = self.execute(self.select_query(), json!({ "archived": archived }))?; + let result = self.execute(&self.select_query(), json!({ "archived": archived }))?; - let mut rows: Vec<(Doc, StorageItem)> = Vec::with_capacity(result.item_count()); - for row in result.iter() { - let doc: Doc = row.deserialize_value().map_err(ditto_err)?; + let mut rows: Vec<(Doc, StorageItem)> = Vec::with_capacity(result.len()); + for row in result { + let doc: Doc = serde_json::from_value(row)?; let item = self.decode(&doc)?; rows.push((doc, item)); } @@ -463,37 +444,35 @@ impl DittoStorage { Ok(()) } - /// Run upserts and soft-deletes in one transaction. + /// Apply upserts and soft-deletes, one statement each. + /// + /// Not wrapped in a Ditto transaction: the C ABI only exposes + /// transactions through asynchronous continuations, and `set` / + /// `set_archive` were never atomic with respect to each other anyway. A + /// crash mid-batch leaves individually valid documents that the next + /// read reconciles. fn apply(&self, upserts: Vec, deletes: Vec) -> Result<()> { let upsert_query = self.upsert_query(); let delete_query = self.soft_delete_query(); let now = now_millis(); - let _committed: TransactionCompletionAction = self - .rt - .block_on(self.ditto.store().transaction(async |tx| { - for doc in &upserts { - tx.execute((upsert_query.as_str(), json!({ "doc": doc }))) - .await?; - } - for id in &deletes { - tx.execute((delete_query.as_str(), json!({ "id": id, "now": now }))) - .await?; - } - Ok::<_, DittoError>(TransactionCompletionAction::Commit) - })) - .map_err(ditto_err)?; + for doc in &upserts { + self.execute(&upsert_query, json!({ "doc": doc }))?; + } + for id in &deletes { + self.execute(&delete_query, json!({ "id": id, "now": now }))?; + } Ok(()) } /// Number of soft-deleted documents (for tests and diagnostics). #[cfg(test)] fn tombstone_count(&self) -> Result { - let result = self.execute( - format!("SELECT _id FROM {} WHERE deleted = true", self.collection), + let rows = self.execute( + &format!("SELECT _id FROM {} WHERE deleted = true", self.collection), json!({}), )?; - Ok(result.item_count()) + Ok(rows.len()) } } @@ -524,17 +503,17 @@ impl StorageBackend for DittoStorage { let mut first = true; let observer = self .ditto - .store() - .register_observer_v2( - (self.select_query(), json!({ "archived": archived })), - move |_result: QueryResult| { + .register_observer( + &self.select_query(), + Some(&json!({ "archived": archived })), + move || { if std::mem::take(&mut first) { return; } on_change(notice); }, ) - .map_err(ditto_err)?; + .map_err(sdk_err)?; observers.push(observer); } Ok(Some(WatchHandle::new(observers))) @@ -552,7 +531,7 @@ impl Drop for DittoStorage { if now >= deadline { break; } - if !self.ditto.presence().graph().remote_peers.is_empty() { + if self.ditto.remote_peer_count().unwrap_or(0) > 0 { std::thread::sleep(FLUSH_GRACE.min(deadline - now)); break; } @@ -560,8 +539,12 @@ impl Drop for DittoStorage { } } if self.sync_started { - self.ditto.sync().stop(); + self.ditto.stop_sync(); } + // Subscription and observers must go before the instance they hang + // off; `_subscription` is dropped explicitly here, observers are + // owned by the TUI's watch handle and dropped before `Taskbook`. + self._subscription = None; } } @@ -616,19 +599,15 @@ fn configure_logging() { match std::env::var(LOG_ENV_VAR) { Ok(level) => { let level = match level.to_ascii_lowercase().as_str() { - "error" => LogLevel::Error, - "warn" | "warning" => LogLevel::Warning, - "info" => LogLevel::Info, - "debug" => LogLevel::Debug, - _ => LogLevel::Verbose, + "error" => sdk::LogLevel::Error, + "warn" | "warning" => sdk::LogLevel::Warning, + "info" => sdk::LogLevel::Info, + "debug" => sdk::LogLevel::Debug, + _ => sdk::LogLevel::Verbose, }; - DittoLogger::set_logging_enabled(true); - DittoLogger::set_minimum_log_level(level); - } - Err(_) => { - DittoLogger::set_minimum_log_level(LogLevel::Error); - DittoLogger::set_logging_enabled(false); + sdk::set_logging(true, level); } + Err(_) => sdk::set_logging(false, sdk::LogLevel::Error), } } @@ -687,7 +666,7 @@ fn general(msg: impl Into) -> TaskbookError { TaskbookError::General(msg.into()) } -fn ditto_err(e: DittoError) -> TaskbookError { +fn sdk_err(e: sdk::SdkError) -> TaskbookError { TaskbookError::General(format!("ditto: {e}")) } @@ -712,7 +691,7 @@ mod tests { let key = encrypt.then(taskbook_common::encryption::generate_key); DittoStorage::open( &settings, - DittoConfigConnect::SmallPeersOnly { private_key: None }, + sdk::Connect::SmallPeersOnly { private_key: None }, key, None, None, @@ -808,9 +787,9 @@ mod tests { storage.set(&map(vec![task(1, "secret")])).unwrap(); let raw = storage - .execute(format!("SELECT * FROM {}", storage.collection), json!({})) + .execute(&format!("SELECT * FROM {}", storage.collection), json!({})) .unwrap(); - let doc: Doc = raw.iter().next().unwrap().deserialize_value().unwrap(); + let doc: Doc = serde_json::from_value(raw.into_iter().next().unwrap()).unwrap(); assert!(doc.nonce.is_some()); assert!(!doc.payload.contains("secret")); @@ -851,11 +830,11 @@ mod tests { let raw = storage .execute( - format!("SELECT * FROM {} WHERE deleted = false", storage.collection), + &format!("SELECT * FROM {} WHERE deleted = false", storage.collection), json!({}), ) .unwrap(); - assert_eq!(raw.item_count(), 1); + assert_eq!(raw.len(), 1); assert_eq!(storage.get().unwrap()["1"].description(), "b"); } @@ -863,7 +842,7 @@ mod tests { fn watch_reports_changes() { let storage = open_local(false); let (tx, rx) = mpsc::channel(); - let _watch = storage + let watch = storage .watch(Box::new(move |notice| { let _ = tx.send(notice); })) @@ -875,6 +854,9 @@ mod tests { .recv_timeout(Duration::from_secs(5)) .expect("change notice"); assert_eq!(notice, ChangeNotice::Archive); + + // Observers must be released before the instance. + drop(watch); } #[test] @@ -893,4 +875,10 @@ mod tests { data.insert("abc".to_string(), task(1, "x")); assert!(storage.set(&data).is_err()); } + + #[test] + fn presence_graph_decodes_without_peers() { + let storage = open_local(false); + assert_eq!(storage.ditto.remote_peer_count().unwrap(), 0); + } } diff --git a/crates/taskbook-client/src/storage/ditto/sdk.rs b/crates/taskbook-client/src/storage/ditto/sdk.rs new file mode 100644 index 0000000..10eb8c1 --- /dev/null +++ b/crates/taskbook-client/src/storage/ditto/sdk.rs @@ -0,0 +1,499 @@ +//! A small, safe wrapper over the raw `dittoffi` bindings in [`super::ffi`]: +//! just what the storage backend needs, with ownership and error handling +//! made explicit so the backend itself stays free of `unsafe`. + +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_void}; +use std::path::Path; +use std::sync::Arc; + +use base64::Engine; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; + +use super::ffi; + +/// SDK release the bindings were transcribed from (set by `build.rs`). +pub const SDK_VERSION: &str = env!("TB_DITTO_SDK_VERSION"); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SdkError { + pub code: i32, + pub message: String, +} + +impl std::fmt::Display for SdkError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (code {})", self.message, self.code) + } +} + +impl std::error::Error for SdkError {} + +pub type Result = std::result::Result; + +/// Consume a `dittoffi_error_t *` from a result struct. +/// +/// # Safety +/// `error` must be null or a pointer returned by a `*_throws` function that +/// has not been freed. +unsafe fn take_error(error: *mut ffi::Error) -> Result<()> { + if error.is_null() { + return Ok(()); + } + let code = ffi::dittoffi_error_code(error); + let description = ffi::dittoffi_error_description(error); + let message = if description.is_null() { + "unknown Ditto error".to_string() + } else { + let s = CStr::from_ptr(description).to_string_lossy().into_owned(); + ffi::ditto_c_string_free(description); + s + }; + ffi::dittoffi_error_free(error); + Err(SdkError { code, message }) +} + +/// Take ownership of an owned byte slice from Ditto and free it. +/// +/// # Safety +/// `slice` must have been returned by a `dittoffi` function and not freed. +unsafe fn take_bytes(slice: ffi::SliceBoxed) -> Vec { + let bytes = if slice.ptr.is_null() || slice.len == 0 { + Vec::new() + } else { + std::slice::from_raw_parts(slice.ptr, slice.len).to_vec() + }; + ffi::ditto_c_bytes_free(slice); + bytes +} + +fn c_string(s: &str) -> Result { + CString::new(s).map_err(|_| SdkError { + code: -1, + message: format!("string contains an interior NUL: {s:?}"), + }) +} + +fn encode_cbor(value: &Value) -> Result> { + let mut buf = Vec::new(); + ciborium::into_writer(value, &mut buf).map_err(|e| SdkError { + code: -1, + message: format!("CBOR encoding failed: {e}"), + })?; + Ok(buf) +} + +fn decode_cbor(bytes: &[u8]) -> Result { + ciborium::from_reader(bytes).map_err(|e| SdkError { + code: -1, + message: format!("CBOR decoding failed: {e}"), + }) +} + +/// How to connect, mirroring the SDK's `DittoConfigConnect`. +#[derive(Debug, Clone)] +pub enum Connect { + /// Ditto Cloud / Big Peer at `url`; needs a [`Ditto::set_login`]. + Server { url: String }, + /// Small peers only (LAN / P2P mesh), optionally with a shared private + /// key (DER bytes). + SmallPeersOnly { private_key: Option> }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + Error, + Warning, + Info, + Debug, + Verbose, +} + +/// Configure Ditto's process-wide logger. Off by default. +pub fn set_logging(enabled: bool, level: LogLevel) { + let level = match level { + LogLevel::Error => ffi::CLogLevel::Error, + LogLevel::Warning => ffi::CLogLevel::Warning, + LogLevel::Info => ffi::CLogLevel::Info, + LogLevel::Debug => ffi::CLogLevel::Debug, + LogLevel::Verbose => ffi::CLogLevel::Verbose, + }; + // SAFETY: plain setters on global logger state. + unsafe { + ffi::ditto_logger_minimum_log_level(level); + ffi::ditto_logger_enabled(enabled); + } +} + +/// An open Ditto instance. Dropping it stops sync and frees the native +/// object; observers and subscriptions must be dropped before it. +pub struct Ditto { + ptr: *mut ffi::CDitto, + login: Option>, +} + +// SAFETY: the Ditto SDK documents the instance as usable from any thread +// (its own Rust wrapper is `Send + Sync`); all mutation goes through the +// native library's internal locking. +unsafe impl Send for Ditto {} +unsafe impl Sync for Ditto {} + +impl Ditto { + /// Open (or create) the database at `persistence_dir` for `database_id`. + pub fn open(database_id: &str, connect: &Connect, persistence_dir: &Path) -> Result { + let engine = base64::engine::general_purpose::STANDARD; + let connect_value = match connect { + Connect::Server { url } => json!({ "type": "server", "url": url }), + Connect::SmallPeersOnly { private_key: None } => { + json!({ "type": "small_peers_only" }) + } + Connect::SmallPeersOnly { + private_key: Some(key), + } => json!({ "type": "small_peers_only", "private_key": engine.encode(key) }), + }; + let dir = persistence_dir.to_string_lossy().into_owned(); + let config = json!({ + "database_id": database_id, + "connect": connect_value, + "persistence_directory": dir, + "experimental": {}, + }); + let config_cbor = encode_cbor(&config)?; + let root = c_string(&dir)?; + + // SAFETY: the CBOR buffer and C string outlive the call; the + // returned pointer is owned by us until `ditto_free`. + let ptr = unsafe { + let result = ffi::dittoffi_ditto_open_throws( + ffi::SliceRef::from_bytes(&config_cbor), + ffi::TransportConfigMode::PlatformIndependent, + root.as_ptr(), + ); + take_error(result.error)?; + result.success + }; + if ptr.is_null() { + return Err(SdkError { + code: -1, + message: "dittoffi_ditto_open_throws returned no instance".into(), + }); + } + Ok(Self { ptr, login: None }) + } + + /// Enable mutating DQL (INSERT/UPDATE) by giving up sync with v3 peers. + pub fn disable_sync_with_v3(&self) -> Result<()> { + // SAFETY: `ptr` is a live instance. + let rc = unsafe { ffi::ditto_disable_sync_with_v3(self.ptr) }; + if rc == 0 { + Ok(()) + } else { + Err(SdkError { + code: rc, + message: "ditto_disable_sync_with_v3 failed".into(), + }) + } + } + + /// Activate an offline (small peers only) instance with a license token. + pub fn set_offline_only_license_token(&self, token: &str) -> Result<()> { + let token = c_string(token)?; + // SAFETY: `ptr` is live; the C string outlives the call. + unsafe { + let result = + ffi::dittoffi_ditto_set_offline_only_license_token_throws(self.ptr, token.as_ptr()); + take_error(result.error) + } + } + + /// Register a login provider for `Connect::Server`: whenever Ditto + /// reports that authentication is required or about to expire, log in + /// with `token` through the named auth `provider`. + pub fn set_login(&mut self, token: &str, provider: &str) -> Result<()> { + let ctx = Arc::new(LoginContext { + ditto: self.ptr, + token: c_string(token)?, + provider: c_string(provider)?, + }); + // SAFETY: the Arc is leaked into `ctx` with one strong count that + // `release` gives back; `retain`/`release` adjust the count. + unsafe { + let raw = Arc::into_raw(Arc::clone(&ctx)) as *mut c_void; + let provider_handle = ffi::ditto_auth_client_make_login_provider( + raw, + login_retain, + login_release, + login_expiring, + ); + ffi::ditto_auth_set_login_provider(self.ptr, provider_handle); + } + self.login = Some(ctx); + Ok(()) + } + + pub fn start_sync(&self) -> Result<()> { + // SAFETY: `ptr` is live. + unsafe { take_error(ffi::dittoffi_ditto_try_start_sync(self.ptr).error) } + } + + pub fn stop_sync(&self) { + // SAFETY: `ptr` is live. + unsafe { ffi::dittoffi_ditto_stop_sync(self.ptr) } + } + + /// Execute a DQL statement with optional named arguments and return the + /// result rows as JSON values. + pub fn execute(&self, statement: &str, args: Option<&Value>) -> Result> { + let statement = c_string(statement)?; + let args_cbor = match args { + Some(args) => Some(encode_cbor(args)?), + None => None, + }; + let args_slice = args_cbor + .as_deref() + .map(ffi::SliceRef::from_bytes) + .unwrap_or(ffi::SliceRef::NONE); + + // SAFETY: inputs outlive the call; the result and each item are freed + // exactly once below. + unsafe { + let result = ffi::dittoffi_try_exec_statement(self.ptr, statement.as_ptr(), args_slice); + take_error(result.error)?; + let result = QueryResultHandle(result.success); + if result.0.is_null() { + return Ok(Vec::new()); + } + let count = ffi::dittoffi_query_result_item_count(result.0); + let mut rows = Vec::with_capacity(count); + for idx in 0..count { + let item = ffi::dittoffi_query_result_item_at(result.0, idx); + if item.is_null() { + continue; + } + let bytes = take_bytes(ffi::dittoffi_query_result_item_cbor(item)); + ffi::dittoffi_query_result_item_free(item); + rows.push(decode_cbor::(&bytes)?); + } + Ok(rows) + } + } + + /// Subscribe to sync for the documents matching `query`. + pub fn register_subscription(&self, query: &str) -> Result { + let query = c_string(query)?; + // SAFETY: `ptr` is live; the string outlives the call. + unsafe { + let result = ffi::dittoffi_sync_register_subscription_throws( + self.ptr, + query.as_ptr(), + ffi::SliceRef::NONE, + ); + take_error(result.error)?; + Ok(Subscription(result.success)) + } + } + + /// Observe `query`; `on_change` runs on a Ditto thread whenever the + /// result set changes (including once right after registration). + pub fn register_observer( + &self, + query: &str, + args: Option<&Value>, + on_change: F, + ) -> Result + where + F: FnMut() + Send + 'static, + { + let query = c_string(query)?; + let args_cbor = match args { + Some(args) => Some(encode_cbor(args)?), + None => None, + }; + let args_slice = args_cbor + .as_deref() + .map(ffi::SliceRef::from_bytes) + .unwrap_or(ffi::SliceRef::NONE); + + let env: Box = Box::new(Box::new(on_change)); + let callback = ffi::StoreObserverCallback { + env_ptr: Box::into_raw(env) as *mut c_void, + call: observer_call, + free: observer_free, + }; + // SAFETY: `env_ptr` is owned by Ditto from here on and released by + // `observer_free`, which Ditto calls exactly once. + unsafe { + let result = ffi::dittoffi_store_register_observer_throws( + self.ptr, + query.as_ptr(), + args_slice, + callback, + ); + take_error(result.error)?; + Ok(Observer(result.success)) + } + } + + /// Number of remote peers currently known to the mesh. + pub fn remote_peer_count(&self) -> Result { + // SAFETY: `ptr` is live; the slice is consumed by `take_bytes`. + let bytes = unsafe { take_bytes(ffi::dittoffi_presence_graph(self.ptr)) }; + if bytes.is_empty() { + return Ok(0); + } + // Unlike query results, the presence graph is delivered as JSON + // (Ditto's own SDK parses it with serde_json). + let graph: Value = serde_json::from_slice(&bytes).map_err(|e| SdkError { + code: -1, + message: format!("presence graph is not valid JSON: {e}"), + })?; + Ok(graph + .get("remotePeers") + .and_then(Value::as_array) + .map_or(0, Vec::len)) + } +} + +impl Drop for Ditto { + fn drop(&mut self) { + // SAFETY: `ptr` is live and freed exactly once here. Detaching the + // login provider first keeps its context from outliving the instance + // it points at. + unsafe { + if self.login.take().is_some() { + ffi::ditto_auth_set_login_provider(self.ptr, std::ptr::null_mut()); + } + ffi::ditto_free(self.ptr); + } + } +} + +/// Frees a query result on drop. +struct QueryResultHandle(*mut ffi::QueryResult); + +impl Drop for QueryResultHandle { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: owned result pointer, freed once. + unsafe { ffi::dittoffi_query_result_free(self.0) } + } + } +} + +/// A registered sync subscription; cancelled and freed on drop. +pub struct Subscription(*mut ffi::SyncSubscription); + +// SAFETY: opaque handle to a thread-safe native object. +unsafe impl Send for Subscription {} +unsafe impl Sync for Subscription {} + +impl Drop for Subscription { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: owned handle, cancelled then freed once. + unsafe { + ffi::dittoffi_sync_subscription_cancel(self.0); + ffi::dittoffi_sync_subscription_free(self.0); + } + } + } +} + +/// A registered store observer; cancelled and freed on drop. +pub struct Observer(*mut ffi::StoreObserver); + +// SAFETY: opaque handle to a thread-safe native object. +unsafe impl Send for Observer {} +unsafe impl Sync for Observer {} + +impl Drop for Observer { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: owned handle, cancelled then freed once. + unsafe { + ffi::dittoffi_store_observer_cancel(self.0); + ffi::dittoffi_store_observer_free(self.0); + } + } + } +} + +type ObserverEnv = Box; + +unsafe extern "C" fn observer_call( + env: *mut c_void, + result: *mut ffi::QueryResult, + signal_next: ffi::ArcDynFn0, +) { + // The result set itself is not needed: the backend re-reads on notice. + if !result.is_null() { + ffi::dittoffi_query_result_free(result); + } + if !env.is_null() { + let on_change = &mut *(env as *mut ObserverEnv); + // A panic must not unwind across the FFI boundary. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(on_change)); + } + // We own one strong reference to the signal: call it (ready for the next + // callback) and release it. + if let Some(call) = signal_next.call { + call(signal_next.env_ptr); + } + if let Some(release) = signal_next.release { + release(signal_next.env_ptr); + } +} + +unsafe extern "C" fn observer_free(env: *mut c_void) { + if !env.is_null() { + drop(Box::from_raw(env as *mut ObserverEnv)); + } +} + +struct LoginContext { + ditto: *mut ffi::CDitto, + token: CString, + provider: CString, +} + +// SAFETY: the raw instance pointer is only used while the owning `Ditto` +// is alive (it detaches the provider before freeing), and the login call is +// thread-safe on the native side. +unsafe impl Send for LoginContext {} +unsafe impl Sync for LoginContext {} + +unsafe extern "C" fn login_retain(ctx: *mut c_void) { + if !ctx.is_null() { + Arc::increment_strong_count(ctx as *const LoginContext); + } +} + +unsafe extern "C" fn login_release(ctx: *mut c_void) { + if !ctx.is_null() { + Arc::decrement_strong_count(ctx as *const LoginContext); + } +} + +unsafe extern "C" fn login_expiring(ctx: *mut c_void, _seconds_remaining: u32) { + if ctx.is_null() { + return; + } + Arc::increment_strong_count(ctx as *const LoginContext); + let ctx = Arc::from_raw(ctx as *const LoginContext); + // Log in off the callback thread so the native side is never re-entered + // from within its own notification. + std::thread::spawn(move || { + // SAFETY: see `LoginContext`. + let rc = unsafe { + ffi::ditto_auth_client_login_with_token( + ctx.ditto, + ctx.token.as_ptr() as *const c_char, + ctx.provider.as_ptr() as *const c_char, + ) + }; + if rc != 0 { + eprintln!("ditto: login failed (code {rc})"); + } + }); +} diff --git a/crates/taskbook-client/src/storage/mod.rs b/crates/taskbook-client/src/storage/mod.rs index 54a28ef..0524cce 100644 --- a/crates/taskbook-client/src/storage/mod.rs +++ b/crates/taskbook-client/src/storage/mod.rs @@ -83,6 +83,18 @@ fn ditto_backend(config: &Config) -> Result> { Ok(Box::new(DittoStorage::new(&config.ditto)?)) } +/// Ditto SDK release the native bindings were built against, when compiled in. +pub fn ditto_sdk_version() -> Option<&'static str> { + #[cfg(feature = "ditto")] + { + Some(ditto::sdk::SDK_VERSION) + } + #[cfg(not(feature = "ditto"))] + { + None + } +} + #[cfg(not(feature = "ditto"))] fn ditto_backend(_config: &Config) -> Result> { Err(crate::error::TaskbookError::General( diff --git a/docs/ditto.md b/docs/ditto.md index e2f42c5..cab4657 100644 --- a/docs/ditto.md +++ b/docs/ditto.md @@ -13,24 +13,37 @@ The backend is optional and compiled in with a Cargo feature: cargo build --release --features ditto ``` -The feature pulls in the `dittolive-ditto` crate, whose build script -downloads a prebuilt native library (`libdittoffi`, ~165 MB static archive) -for your target. Point `DITTOFFI_SEARCH_PATH` at a local copy for sandboxed -builds (Nix, offline CI); `DITTO_LOCAL_BUILD=1` forbids the download. - -**Toolchain**: Rust 1.85 through 1.97. Rust 1.98 and newer reject a -`#[repr(transparent)]` struct in the SDK's generated bindings -(`dittolive-ditto-sys` 4.14.7 via `safer-ffi` 0.2.0-rc1) with error E0690, -after [rust-lang/rust#155299](https://github.com/rust-lang/rust/pull/155299) -made that check a hard error. Until Ditto ships a fixed SDK, build the -feature with a pinned toolchain: +The feature does not use Ditto's Rust SDK crate. It binds Ditto's C ABI +(`dittoffi`) directly from `crates/taskbook-client/src/storage/ditto/ffi.rs` +and links the prebuilt native library that Ditto publishes per target +(`libdittoffi.a`, ~165 MB archive, ~41 MB after linking). The crate's +`build.rs` finds the library in this order: + +1. `DITTO_SDK_DIR` — a directory containing `libdittoffi.a`. +2. `DITTOFFI_SEARCH_PATH` — same, for parity with Ditto's own crate. +3. The download cache: `DITTO_SDK_CACHE`, else + `$XDG_CACHE_HOME/taskbook/ditto-sdk` (`~/.cache/taskbook/ditto-sdk`), + keyed by SDK version and target. +4. Download into that cache with `curl` from `software.ditto.live`, unless + `DITTO_LOCAL_BUILD=1` forbids network access (Nix, offline CI). + +Any stable Rust toolchain the workspace supports builds it; there is no +toolchain pin. + +**ABI tracking.** The bindings are a transcription of the C header Ditto +embeds in its C++ SDK (`Ditto.h`, include guard `__RUST_DITTOFFI__`), which +is generated per release with no stability promise. The version is pinned +in `build.rs` (`DITTO_SDK_VERSION`). To bump it, print the declarations the +bindings rely on for both releases and diff them: ```bash -rustup toolchain install 1.97.0 -cargo +1.97.0 build --release --features ditto +scripts/ditto-abi-check.sh 4.14.7 > /tmp/abi-old.txt +scripts/ditto-abi-check.sh 4.15.0 > /tmp/abi-new.txt +diff /tmp/abi-old.txt /tmp/abi-new.txt ``` -The default build (without the feature) is unaffected. +Any change in a signature or struct layout must be mirrored in `ffi.rs` +before the version constant moves. ## Setup @@ -187,6 +200,9 @@ from other devices as they arrive. asks for it. - **"this build of tb has no Ditto support"** — rebuild with `--features ditto`. +- **build fails downloading the native library** — set `DITTO_SDK_DIR` to a + directory containing `libdittoffi.a` for your target (Ditto publishes it at + `https://software.ditto.live/rust/Ditto///release/`). - **Devices don't see each other in `peers` mode** — they need the same `appId`, the same `privateKeyPath` (or none on both), and a network that allows mDNS/multicast. `TB_DITTO_LOG=info tb` shows transport activity. diff --git a/scripts/ditto-abi-check.sh b/scripts/ditto-abi-check.sh new file mode 100755 index 0000000..7ae105d --- /dev/null +++ b/scripts/ditto-abi-check.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Print the C declarations that crates/taskbook-client/src/storage/ditto/ffi.rs +# depends on, as published in a given Ditto release, so an SDK bump can be +# diffed against the current bindings: +# +# scripts/ditto-abi-check.sh 4.14.7 > /tmp/abi-4.14.7.txt +# scripts/ditto-abi-check.sh 4.15.0 > /tmp/abi-4.15.0.txt +# diff /tmp/abi-4.14.7.txt /tmp/abi-4.15.0.txt +# +# The declarations come from the C header that Ditto embeds in its C++ SDK +# (`Ditto.h`, include guard `__RUST_DITTOFFI__`); the library we link +# (`libdittoffi.a`, the Rust-SDK artifact) exports the same ABI. +set -euo pipefail + +version="${1:-}" +if [[ -z "$version" ]]; then + echo "usage: $0 [cpp-platform]" >&2 + exit 2 +fi +platform="${2:-cpp-linux-x86_64}" +ffi_rs="$(dirname "$0")/../crates/taskbook-client/src/storage/ditto/ffi.rs" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +url="https://software.ditto.live/${platform}/Ditto/${version}/dist/Ditto.tar.gz" +echo "# source: $url" +curl --fail --silent --show-error --location --output "$work/Ditto.tar.gz" "$url" +tar -xzf "$work/Ditto.tar.gz" -C "$work" Ditto.h + +# Extract the embedded C header. +start="$(grep -n '^#ifndef __RUST_DITTOFFI__' "$work/Ditto.h" | head -1 | cut -d: -f1)" +end="$(awk -v s="$start" 'NR>s && /^#endif.*__RUST_DITTOFFI__/ {print NR; exit}' "$work/Ditto.h")" +sed -n "${start},${end}p" "$work/Ditto.h" > "$work/dittoffi.h" + +# Every function our bindings declare. +names="$(grep -oE 'pub fn (dittoffi|ditto)_[a-z0-9_]+' "$ffi_rs" | awk '{print $3}' | sort -u)" + +python3 - "$work/dittoffi.h" $names <<'PY' +import re, sys +src = open(sys.argv[1], encoding="utf-8", errors="replace").read() +for name in sys.argv[2:]: + marker = "/* fn */ " + name + " (" + i = src.find(marker) + if i < 0: + marker = "/* fn */ " + name + "(" + i = src.find(marker) + if i < 0: + print(f"{name}: NOT FOUND") + continue + pre = src[:i] + j = max(pre.rfind(";"), pre.rfind("*/"), pre.rfind("}")) + ret = " ".join(pre[j + 2:].split()) + k = src.find(");", i) + params = " ".join(src[i + len("/* fn */ "):k].split()) + print(f"{ret} {params});") +PY + +echo +echo "# struct layouts referenced above" +for t in slice_ref_uint8 slice_boxed_uint8 dittoffi_result_void dittoffi_result_CDitto_ptr \ + dittoffi_result_dittoffi_query_result_ptr dittoffi_result_dittoffi_sync_subscription_ptr \ + dittoffi_result_dittoffi_store_observer_ptr ArcDynFn0_void \ + BoxDynFnMut3_void_dittoffi_query_result_ptr_ArcDynFn0_void TransportConfigMode CLogLevel; do + python3 - "$work/dittoffi.h" "$t" <<'PY' +import re, sys +src = re.sub(r"/\*.*?\*/", "", open(sys.argv[1], encoding="utf-8", errors="replace").read(), flags=re.S) +t = sys.argv[2] +m = re.search(r"typedef (struct|enum) " + re.escape(t) + r"\b\s*\{(.*?)\}\s*(\w+);", src, flags=re.S) +print(f"{t}: " + (" ".join(m.group(2).split()) if m else "NOT FOUND")) +PY +done