diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1c4cea..504b0b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,32 @@ jobs: - name: Run tests 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. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.97.0 + components: clippy + + - name: Cache dependencies + uses: Swatinem/rust-cache@v2 + with: + key: ditto + + - name: Clippy (ditto feature) + run: cargo clippy --package taskbook-client --features ditto -- -D warnings + + - name: Test (ditto feature) + run: cargo test --package taskbook-client --features ditto -- --test-threads=1 diff --git a/CLAUDE.md b/CLAUDE.md index 9202098..5e82cc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,11 @@ 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. +cargo build --package taskbook-client --features ditto +cargo test --package taskbook-client --features ditto -- --test-threads=1 ``` The client binary is named `tb` and the server binary is `tb-server`. @@ -70,9 +75,10 @@ crates/ │ │ ├── mod.rs # MCP stdio server (JSON-RPC 2.0 loop) │ │ └── tools.rs # MCP tool schemas + dispatch into Taskbook │ ├── storage/ -│ │ ├── mod.rs # StorageBackend trait +│ │ ├── mod.rs # StorageBackend trait + config-driven factory │ │ ├── local.rs # LocalStorage (file-based) -│ │ └── remote.rs # RemoteStorage (HTTP + encryption) +│ │ ├── remote.rs # RemoteStorage (HTTP + encryption) +│ │ └── ditto.rs # DittoStorage (peer-to-peer CRDT sync, feature "ditto") │ └── tui/ # Interactive TUI (ratatui + crossterm) │ └── taskbook-server/ # Server binary (tb-server) @@ -99,7 +105,7 @@ crates/ ### Key Design Decisions -1. **StorageBackend Trait**: `Taskbook` business logic is storage-agnostic via `Box`. Backend selection is config-driven — local file storage by default, remote server when `sync.enabled = true`. +1. **StorageBackend Trait**: `Taskbook` business logic is storage-agnostic via `Box`. Backend selection is config-driven through `storage::from_config` — local file storage by default, the HTTP server when `sync.enabled = true`, or Ditto when additionally `sync.backend = "ditto"` (only in builds with `--features ditto`). Backends may implement `watch` to push change notifications; the TUI uses it when available and falls back to the server's SSE stream. 2. **Client-Side Encryption**: All data is encrypted with AES-256-GCM before being sent to the server. The 32-byte encryption key is generated on registration and never leaves the client. Each item is encrypted individually with a unique random nonce. @@ -123,6 +129,8 @@ crates/ ├── storage/storage.json # Active items ├── archive/archive.json # Deleted items ├── credentials.json # Server token + encryption key + ├── ditto-credentials.json # Ditto license/auth token, encryption key, private key path + ├── ditto/ # Ditto local database (ditto backend) └── .temp/ # Atomic write temp files ``` @@ -160,7 +168,8 @@ tb --register --server --username --email --password tb --login --server --username --password --key tb --logout tb --status -tb --migrate # Push local data to server +tb --migrate # Push local data to the sync backend (server or Ditto) +tb --ditto-init # Create Ditto secrets (encryption key, auth token); --key imports a key # Mode tb --cli # Force non-interactive CLI mode @@ -250,6 +259,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 ### Common (taskbook-common) - `aes-gcm` - AES-256-GCM encryption diff --git a/Cargo.lock b/Cargo.lock index 90e1064..61013c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,18 @@ 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" @@ -164,6 +176,39 @@ 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" @@ -274,6 +319,12 @@ 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" @@ -328,6 +379,15 @@ 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" @@ -345,6 +405,20 @@ 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" @@ -710,8 +784,18 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "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", ] [[package]] @@ -727,17 +811,86 @@ 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", + "darling_core 0.23.0", "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" @@ -762,6 +915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -840,6 +994,55 @@ 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" @@ -861,6 +1064,12 @@ 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" @@ -934,6 +1143,70 @@ 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" @@ -1058,6 +1331,21 @@ 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" @@ -1131,6 +1419,7 @@ 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", @@ -1239,13 +1528,19 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "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" @@ -1263,6 +1558,22 @@ 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" @@ -1433,7 +1744,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1603,6 +1914,17 @@ 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" @@ -1639,7 +1961,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -1673,6 +1995,60 @@ 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" @@ -1900,6 +2276,22 @@ 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" @@ -1962,13 +2354,13 @@ version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" dependencies = [ - "base64", + "base64 0.22.1", "evmap", "http-body-util", "hyper", "hyper-rustls", "hyper-util", - "indexmap", + "indexmap 2.14.0", "ipnet", "metrics", "metrics-util", @@ -2040,6 +2432,12 @@ 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" @@ -2320,6 +2718,12 @@ 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" @@ -2500,6 +2904,15 @@ 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" @@ -2534,6 +2947,15 @@ 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" @@ -2869,6 +3291,26 @@ 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" @@ -2904,7 +3346,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -3108,6 +3550,43 @@ 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" @@ -3126,6 +3605,30 @@ 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" @@ -3138,6 +3641,26 @@ 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" @@ -3177,6 +3700,35 @@ 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" @@ -3199,9 +3751,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3233,6 +3785,39 @@ 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" @@ -3255,6 +3840,12 @@ 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" @@ -3408,7 +3999,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -3421,7 +4012,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.14.0", "log", "memchr", "once_cell", @@ -3485,7 +4076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "byteorder", "bytes", @@ -3529,7 +4120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "byteorder", "chrono", @@ -3587,6 +4178,40 @@ 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" @@ -3665,6 +4290,17 @@ 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" @@ -3690,13 +4326,15 @@ name = "taskbook-client" version = "1.5.0" dependencies = [ "arboard", - "base64", + "base64 0.22.1", "chrono", "clap", "colored", "crossterm", "dirs", + "dittolive-ditto", "fs2", + "libc", "ratatui", "reqwest", "rpassword", @@ -3726,7 +4364,7 @@ version = "1.5.0" dependencies = [ "argon2", "axum", - "base64", + "base64 0.22.1", "chrono", "futures-util", "metrics", @@ -3774,7 +4412,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "fancy-regex", "filedescriptor", @@ -3866,7 +4504,7 @@ checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", - "half", + "half 2.7.1", "quick-error", "weezl", "zune-jpeg", @@ -3879,12 +4517,14 @@ 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]] @@ -3893,6 +4533,16 @@ 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" @@ -3918,6 +4568,12 @@ 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" @@ -3927,6 +4583,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -3980,6 +4637,36 @@ 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" @@ -4165,6 +4852,15 @@ 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" @@ -4181,6 +4877,12 @@ 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" @@ -4191,6 +4893,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4214,6 +4917,7 @@ dependencies = [ "atomic", "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -4365,7 +5069,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -4378,7 +5082,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.13.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -4838,6 +5542,15 @@ 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" @@ -4872,7 +5585,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -4903,7 +5616,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.13.0", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -4922,7 +5635,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -4932,6 +5645,26 @@ 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/README.md b/README.md index d9b00e4..fc7eed5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A Rust port of [taskbook](https://github.com/klaussinani/taskbook) - tasks, boar - Timeline and archive views - Customizable themes (including Catppuccin) - **Optional server sync** with end-to-end encryption (AES-256-GCM) +- **Optional peer-to-peer sync** via [Ditto](docs/ditto.md) (`--features ditto`), same encryption ## Installation @@ -124,6 +125,19 @@ All data is encrypted client-side with AES-256-GCM before being sent to the serv See [Server Setup](docs/server.md) for running your own server. +## Peer-to-Peer Sync (Ditto) + +Builds with `--features ditto` can sync through a [Ditto](https://ditto.com) mesh instead of a server: devices find each other over LAN, Bluetooth or peer-to-peer Wi-Fi, or go through Ditto Cloud. Payloads use the same client-side encryption. + +```bash +# Set sync.enabled = true, sync.backend = "ditto" and ditto.appId in the config, then +tb --ditto-init # generate the encryption key (prints it once) +tb --migrate # push existing local data (first device only) +tb --ditto-init --key # import the key on other devices +``` + +See [Ditto Backend](docs/ditto.md) for setup, configuration and the sync semantics. + ## Configuration Configuration is stored in `~/.config/taskbook/taskbook.json` (honoring `$XDG_CONFIG_HOME`; a legacy `~/.taskbook.json` is still used if present): @@ -156,6 +170,7 @@ See the [docs](docs/) folder for detailed documentation: - [Configuration](docs/configuration.md) - [Server Setup](docs/server.md) - [Sync & Encryption](docs/sync.md) +- [Ditto Backend](docs/ditto.md) - [Kubernetes Deployment](docs/kubernetes.md) ## Data Compatibility diff --git a/crates/taskbook-client/Cargo.toml b/crates/taskbook-client/Cargo.toml index 9c5bf4f..c6845b9 100644 --- a/crates/taskbook-client/Cargo.toml +++ b/crates/taskbook-client/Cargo.toml @@ -28,6 +28,15 @@ tokio = { version = "1", features = ["rt-multi-thread"] } 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 } +libc = { version = "0.2", optional = true } + +[features] +default = [] +ditto = ["dep:dittolive-ditto", "dep:libc"] [[bin]] name = "tb" diff --git a/crates/taskbook-client/src/auth.rs b/crates/taskbook-client/src/auth.rs index 614e04b..b64ba79 100644 --- a/crates/taskbook-client/src/auth.rs +++ b/crates/taskbook-client/src/auth.rs @@ -4,8 +4,8 @@ use base64::Engine; use colored::Colorize; use crate::api_client::{ApiClient, LoginRequest, RegisterRequest}; -use crate::config::Config; -use crate::credentials::Credentials; +use crate::config::{Config, DittoConnect, SyncBackend}; +use crate::credentials::{Credentials, DittoCredentials}; use crate::error::{Result, TaskbookError}; fn prompt(message: &str) -> Result { @@ -180,6 +180,10 @@ pub fn logout() -> Result<()> { pub fn status() -> Result<()> { let config = Config::load_or_default(); + if config.sync.enabled && config.sync.backend == SyncBackend::Ditto { + return ditto_status(&config); + } + if config.sync.enabled { println!("Mode: {}", "remote".green().bold()); println!("Server: {}", config.sync.server_url); @@ -213,3 +217,156 @@ pub fn status() -> Result<()> { Ok(()) } + +fn ditto_status(config: &Config) -> Result<()> { + let ditto = &config.ditto; + println!("Mode: {}", "ditto".green().bold()); + if !cfg!(feature = "ditto") { + println!( + "{}", + "This build of tb has no Ditto support; rebuild with `--features ditto`." + .red() + .bold() + ); + } + println!( + "App ID: {}", + if ditto.app_id.is_empty() { + "(unset)".red().to_string() + } else { + ditto.app_id.clone() + } + ); + match ditto.connect { + DittoConnect::Peers => println!("Connect: peers (LAN / P2P mesh)"), + DittoConnect::Server => println!( + "Connect: server {}", + ditto.url.as_deref().unwrap_or("(url unset)") + ), + } + println!("Encrypt: {}", if ditto.encrypt { "on" } else { "off" }); + println!("Data: {}", ditto.persistence_path().display()); + + match DittoCredentials::load()? { + Some(creds) => { + println!("Credentials: {}", "saved".green()); + let yes_no = |present: bool| { + if present { + "set".green() + } else { + "missing".red() + } + }; + println!( + " encryption key: {}", + yes_no(creds.encryption_key.is_some()) + ); + match ditto.connect { + DittoConnect::Server => { + println!(" auth token: {}", yes_no(creds.token.is_some())); + } + DittoConnect::Peers => { + println!( + " license token: {}", + yes_no(creds.license_token.is_some()) + ); + } + } + if let Some(path) = &creds.private_key_path { + println!(" private key: {path}"); + } + } + None => { + println!("Credentials: {} (run `tb --ditto-init`)", "none".dimmed()); + } + } + + Ok(()) +} + +/// Set up secrets for the Ditto backend: generate (or import) the item +/// encryption key and, in `server` mode, store the auth token. +pub fn ditto_init(key: Option<&str>) -> Result<()> { + println!("{}", "Ditto backend setup".bold()); + println!(); + + let config = Config::load_or_default(); + let ditto = &config.ditto; + let mut creds = DittoCredentials::load()?.unwrap_or_default(); + let mut changed = false; + + if let Some(key) = key { + creds.encryption_key = Some(key.to_string()); + creds.encryption_key_bytes()?; // validate + changed = true; + println!("Imported encryption key."); + } else if ditto.encrypt && creds.encryption_key.is_none() { + let key = taskbook_common::encryption::generate_key(); + let encoded = base64::engine::general_purpose::STANDARD.encode(key); + creds.encryption_key = Some(encoded.clone()); + changed = true; + println!("Generated a new encryption key. Other devices need the same key"); + println!("(`tb --ditto-init --key `); it cannot be recovered if lost:"); + println!(); + println!(" {}", encoded.bold()); + println!(); + } + + match ditto.connect { + DittoConnect::Server if creds.token.is_none() => { + let token = prompt_password("Ditto auth token (not echoed): ")?; + if token.is_empty() { + return Err(TaskbookError::Auth("token cannot be empty".to_string())); + } + creds.token = Some(token); + changed = true; + } + DittoConnect::Peers if creds.license_token.is_none() => { + println!( + "Peers mode needs an offline license token (free) from https://portal.ditto.live." + ); + let token = prompt("Ditto offline license token: ")?; + if token.is_empty() { + return Err(TaskbookError::Auth( + "license token cannot be empty".to_string(), + )); + } + creds.license_token = Some(token); + changed = true; + } + _ => {} + } + + let path = DittoCredentials::path()?; + if changed { + creds.save()?; + println!("{}", format!("Saved {}", path.display()).green()); + } else { + println!( + "Nothing to do; credentials already present at {}", + path.display() + ); + } + + if ditto.app_id.is_empty() { + println!( + "{}", + format!( + "Next: set ditto.appId (and sync.enabled = true, sync.backend = \"ditto\") in {}", + Config::config_file_path().display() + ) + .yellow() + ); + } else if !(config.sync.enabled && config.sync.backend == SyncBackend::Ditto) { + println!( + "{}", + format!( + "Next: set sync.enabled = true and sync.backend = \"ditto\" in {}", + Config::config_file_path().display() + ) + .yellow() + ); + } + + Ok(()) +} diff --git a/crates/taskbook-client/src/commands.rs b/crates/taskbook-client/src/commands.rs index 255aca2..059f08e 100644 --- a/crates/taskbook-client/src/commands.rs +++ b/crates/taskbook-client/src/commands.rs @@ -1,16 +1,12 @@ use std::path::PathBuf; -use base64::Engine; use colored::Colorize; -use crate::api_client::{ApiClient, EncryptedItemData}; use crate::config::Config; -use crate::credentials::Credentials; use crate::directory::resolve_taskbook_directory; -use crate::error::{Result, TaskbookError}; -use crate::storage::{LocalStorage, StorageBackend}; +use crate::error::Result; +use crate::storage::{self, LocalStorage, StorageBackend}; use crate::taskbook::Taskbook; -use taskbook_common::encryption::encrypt_item; /// Execute CLI commands #[allow(clippy::too_many_arguments)] @@ -132,15 +128,9 @@ pub fn run( taskbook.display_stats() } -/// Migrate local data to the remote server. +/// Migrate local data to the configured sync backend (server or Ditto). pub fn migrate(taskbook_dir: Option) -> Result<()> { - let creds = Credentials::load()?.ok_or_else(|| { - TaskbookError::Auth("not logged in — run `tb register` or `tb login` first".to_string()) - })?; - let config = Config::load_or_default(); - let encryption_key = creds.encryption_key_bytes()?; - let engine = base64::engine::general_purpose::STANDARD; // Load local data let resolved_dir = resolve_taskbook_directory(taskbook_dir.as_deref())?; @@ -149,55 +139,32 @@ pub fn migrate(taskbook_dir: Option) -> Result<()> { let items = local.get()?; let archive = local.get_archive()?; - // Encrypt and upload items - let client = ApiClient::new(&config.sync.server_url, Some(&creds.token)); - - let mut encrypted_items = std::collections::HashMap::new(); - for (key, item) in &items { - let encrypted = encrypt_item(&encryption_key, item) - .map_err(|e| TaskbookError::General(format!("encryption failed: {e}")))?; - encrypted_items.insert( - key.clone(), - EncryptedItemData { - data: engine.encode(&encrypted.data), - nonce: engine.encode(&encrypted.nonce), - }, - ); - } - client.put_items(&encrypted_items)?; - - let mut encrypted_archive = std::collections::HashMap::new(); - for (key, item) in &archive { - let encrypted = encrypt_item(&encryption_key, item) - .map_err(|e| TaskbookError::General(format!("encryption failed: {e}")))?; - encrypted_archive.insert( - key.clone(), - EncryptedItemData { - data: engine.encode(&encrypted.data), - nonce: engine.encode(&encrypted.nonce), - }, - ); - } - client.put_archive(&encrypted_archive)?; + // The sync backend handles encryption and transport for its target. + let target = storage::sync_backend(&config)?; + target.set(&items)?; + target.set_archive(&archive)?; println!( "{}", format!( - "Migrated {} items and {} archived items to server.", + "Migrated {} items and {} archived items to {}.", items.len(), - archive.len() + archive.len(), + config.sync.backend.display_name() ) .green() .bold() ); - println!( - "{}", - format!( - "To enable sync, set sync.enabled = true in {}", - crate::config::Config::config_file_path().display() - ) - .dimmed() - ); + if !config.sync.enabled { + println!( + "{}", + format!( + "To enable sync, set sync.enabled = true in {}", + crate::config::Config::config_file_path().display() + ) + .dimmed() + ); + } Ok(()) } diff --git a/crates/taskbook-client/src/config.rs b/crates/taskbook-client/src/config.rs index 4eeb276..a0296a2 100644 --- a/crates/taskbook-client/src/config.rs +++ b/crates/taskbook-client/src/config.rs @@ -194,6 +194,26 @@ impl SortMethod { } } +/// Which storage backend to use when `sync.enabled` is true. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SyncBackend { + /// The taskbook HTTP server (`tb-server`) with client-side encryption. + #[default] + Server, + /// A Ditto peer-to-peer mesh (requires a build with `--features ditto`). + Ditto, +} + +impl SyncBackend { + pub fn display_name(self) -> &'static str { + match self { + SyncBackend::Server => "server", + SyncBackend::Ditto => "ditto", + } + } +} + /// Sync configuration for remote server #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -203,6 +223,17 @@ pub struct SyncConfig { #[serde(default = "default_server_url")] pub server_url: String, + + /// Backend selected when `enabled` is true. Omitted from the file while it + /// is the default so existing configs round-trip unchanged. + #[serde(default, skip_serializing_if = "SyncBackend::is_default")] + pub backend: SyncBackend, +} + +impl SyncBackend { + fn is_default(&self) -> bool { + *self == SyncBackend::default() + } } fn default_server_url() -> String { @@ -214,6 +245,109 @@ impl Default for SyncConfig { Self { enabled: false, server_url: default_server_url(), + backend: SyncBackend::default(), + } + } +} + +/// How the Ditto backend connects to other peers. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DittoConnect { + /// Small peers only: LAN / peer-to-peer mesh, no cloud account. Uses the + /// shared private key from the Ditto credentials file when one is set. + #[default] + Peers, + /// Connect to a Ditto Cloud app or a self-hosted Big Peer at `url`, + /// authenticating with the token from the Ditto credentials file. + Server, +} + +/// Settings for the Ditto peer-to-peer backend (`sync.backend = "ditto"`). +/// +/// Secrets (auth token, encryption key, private key) are not stored here; they +/// live in `~/.taskbook/ditto-credentials.json`. See docs/ditto.md. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DittoConfig { + /// Ditto app / database ID. Every device that should share data uses the + /// same value. For `peers` mode any string works; Ditto Cloud issues a UUID. + #[serde(default)] + pub app_id: String, + + #[serde(default)] + pub connect: DittoConnect, + + /// Auth / sync URL for `connect = "server"` (e.g. the Ditto portal's + /// "Auth URL"). Ignored in `peers` mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + + /// Name of the authentication provider (webhook) configured on the Ditto + /// portal, used with the token on login in `server` mode. + #[serde(default = "default_ditto_provider")] + pub provider: String, + + /// Where Ditto keeps its local database. Defaults to `~/.taskbook/ditto`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub persistence_dir: Option, + + /// Ditto collection that holds the items. + #[serde(default = "default_ditto_collection")] + pub collection: String, + + /// Encrypt item payloads client-side (AES-256-GCM) with the key from the + /// Ditto credentials file before they enter the Ditto store. + #[serde(default = "default_true")] + pub encrypt: bool, + + /// After a one-shot CLI write, wait up to this long for a peer connection + /// so the change has a chance to sync before the process exits. `0` + /// disables the wait. The TUI is long-lived and syncs continuously. + #[serde(default = "default_flush_timeout_ms")] + pub flush_timeout_ms: u64, +} + +fn default_ditto_provider() -> String { + "development".to_string() +} + +fn default_ditto_collection() -> String { + "taskbook_items".to_string() +} + +fn default_flush_timeout_ms() -> u64 { + 1000 +} + +impl Default for DittoConfig { + fn default() -> Self { + Self { + app_id: String::new(), + connect: DittoConnect::default(), + url: None, + provider: default_ditto_provider(), + persistence_dir: None, + collection: default_ditto_collection(), + encrypt: true, + flush_timeout_ms: default_flush_timeout_ms(), + } + } +} + +impl DittoConfig { + fn is_default(&self) -> bool { + *self == DittoConfig::default() + } + + /// Resolved persistence directory (`~` expanded). + pub fn persistence_path(&self) -> PathBuf { + match &self.persistence_dir { + Some(dir) => Config::format_taskbook_dir(dir), + None => { + let home = dirs::home_dir().expect("Could not find home directory"); + home.join(".taskbook").join("ditto") + } } } } @@ -237,6 +371,10 @@ pub struct Config { #[serde(default)] pub sync: SyncConfig, + /// Ditto backend settings; only written to the file once customised. + #[serde(default, skip_serializing_if = "DittoConfig::is_default")] + pub ditto: DittoConfig, + #[serde(default)] pub sort_method: SortMethod, @@ -260,6 +398,7 @@ impl Default for Config { display_progress_overview: true, theme: ThemeConfig::default(), sync: SyncConfig::default(), + ditto: DittoConfig::default(), sort_method: SortMethod::default(), default_view: ViewMode::default(), } @@ -326,7 +465,7 @@ impl Config { } /// Format a taskbook directory path, expanding ~ to home directory - fn format_taskbook_dir(path: &str) -> PathBuf { + pub(crate) fn format_taskbook_dir(path: &str) -> PathBuf { if path.starts_with('~') { let home = dirs::home_dir().expect("Could not find home directory"); let rest = path.trim_start_matches('~').trim_start_matches('/'); @@ -433,6 +572,38 @@ mod tests { assert_eq!(config.default_view, ViewMode::Board); } + #[test] + fn sync_backend_defaults_to_server_and_is_not_serialized() { + let json = r#"{ "sync": { "enabled": true, "serverUrl": "http://x" } }"#; + let config: Config = serde_json::from_str(json).unwrap(); + assert_eq!(config.sync.backend, SyncBackend::Server); + let out = serde_json::to_string(&config).unwrap(); + assert!(!out.contains("\"backend\"")); + assert!(!out.contains("\"ditto\"")); + } + + #[test] + fn ditto_config_parses_and_round_trips() { + let json = r#"{ + "sync": { "enabled": true, "backend": "ditto" }, + "ditto": { "appId": "abc", "connect": "server", "url": "https://x.cloud.ditto.live", "encrypt": false } + }"#; + let config: Config = serde_json::from_str(json).unwrap(); + assert_eq!(config.sync.backend, SyncBackend::Ditto); + assert_eq!(config.ditto.app_id, "abc"); + assert_eq!(config.ditto.connect, DittoConnect::Server); + assert_eq!( + config.ditto.url.as_deref(), + Some("https://x.cloud.ditto.live") + ); + assert!(!config.ditto.encrypt); + assert_eq!(config.ditto.collection, "taskbook_items"); + assert_eq!(config.ditto.flush_timeout_ms, 1000); + let out = serde_json::to_string(&config).unwrap(); + assert!(out.contains("\"backend\":\"ditto\"")); + assert!(out.contains("\"appId\":\"abc\"")); + } + #[test] fn xdg_path_defaults_to_dot_config() { let home = Path::new("/home/alice"); diff --git a/crates/taskbook-client/src/credentials.rs b/crates/taskbook-client/src/credentials.rs index e667442..0debfb4 100644 --- a/crates/taskbook-client/src/credentials.rs +++ b/crates/taskbook-client/src/credentials.rs @@ -82,3 +82,89 @@ impl Credentials { Ok(key) } } + +/// Secrets for the Ditto backend. Kept apart from the config file so a +/// declaratively managed `taskbook.json` never carries key material. +/// Stored at `~/.taskbook/ditto-credentials.json` (mode 0600). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DittoCredentials { + /// Auth token for `connect = "server"` (a playground token, or a token + /// your auth webhook accepts). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, + + /// base64-encoded 32-byte AES-256-GCM key used when `ditto.encrypt` is on. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_key: Option, + + /// Path to a DER-encoded private key for a shared-key `peers` mesh. + /// When unset, `peers` mode runs as an unauthenticated development mesh. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub private_key_path: Option, + + /// Offline license token from the Ditto portal; required to start sync + /// in `peers` mode (not needed in `server` mode). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license_token: Option, +} + +impl DittoCredentials { + pub fn path() -> Result { + let home = dirs::home_dir().ok_or_else(|| { + crate::error::TaskbookError::General("could not find home directory".to_string()) + })?; + Ok(home.join(".taskbook").join("ditto-credentials.json")) + } + + /// Load from disk. Returns None if the file doesn't exist. + pub fn load() -> Result> { + let path = Self::path()?; + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(&path)?; + Ok(Some(serde_json::from_str(&content)?)) + } + + /// Save to disk with restrictive permissions (0600). + pub fn save(&self) -> Result<()> { + let path = Self::path()?; + if let Some(parent) = path.parent() { + if !parent.exists() { + fs::create_dir_all(parent)?; + } + } + fs::write(&path, serde_json::to_string_pretty(self)?)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + } + + Ok(()) + } + + /// Decode the encryption key, if one is set. + pub fn encryption_key_bytes(&self) -> Result> { + let Some(encoded) = &self.encryption_key else { + return Ok(None); + }; + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|e| { + crate::error::TaskbookError::General(format!("invalid ditto encryption key: {e}")) + })?; + if bytes.len() != 32 { + return Err(crate::error::TaskbookError::General(format!( + "ditto encryption key must be 32 bytes, got {}", + bytes.len() + ))); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + Ok(Some(key)) + } +} diff --git a/crates/taskbook-client/src/main.rs b/crates/taskbook-client/src/main.rs index ae9e276..e96bd94 100644 --- a/crates/taskbook-client/src/main.rs +++ b/crates/taskbook-client/src/main.rs @@ -52,7 +52,8 @@ const HELP_TEXT: &str = r#" --login Log in to an existing account --logout Log out and delete credentials --status Show sync status - --migrate Push local data to server + --migrate Push local data to the sync backend + --ditto-init Set up Ditto backend secrets Examples $ tb @@ -87,6 +88,8 @@ const HELP_TEXT: &str = r#" $ tb --logout $ tb --status $ tb --migrate + $ tb --ditto-init + $ tb --ditto-init --key "#; #[derive(Parser)] @@ -206,10 +209,14 @@ struct Cli { #[arg(long)] status: bool, - /// Push local data to server + /// Push local data to the configured sync backend #[arg(long)] migrate: bool, + /// Set up Ditto backend secrets (encryption key, auth token) + #[arg(long)] + ditto_init: bool, + /// Server URL for register/login #[arg(long)] server: Option, @@ -277,6 +284,14 @@ fn main() { return; } + if cli.ditto_init { + if let Err(e) = auth::ditto_init(cli.key.as_deref()) { + eprintln!("Error: {}", e); + process::exit(1); + } + return; + } + if cli.migrate { if let Err(e) = commands::migrate(cli.taskbook_dir) { eprintln!("Error: {}", e); diff --git a/crates/taskbook-client/src/storage/ditto.rs b/crates/taskbook-client/src/storage/ditto.rs new file mode 100644 index 0000000..a5745f9 --- /dev/null +++ b/crates/taskbook-client/src/storage/ditto.rs @@ -0,0 +1,896 @@ +//! Ditto peer-to-peer storage backend. +//! +//! Ditto is a CRDT database that syncs between devices over LAN, Bluetooth, +//! peer-to-peer Wi-Fi, or through a Ditto Cloud / Big Peer server. Taskbook +//! maps its whole-map [`StorageBackend`] contract onto Ditto like this: +//! +//! * One Ditto document per item in a single collection: +//! +//! ```json +//! { +//! "_id": "", // stable identity across devices +//! "tbId": 7, // taskbook id (the storage map key) +//! "archived": false, // active items vs. archive +//! "deleted": false, // soft-delete tombstone +//! "payload": "...", // item JSON, or base64 AES-256-GCM ciphertext +//! "nonce": "...", // base64 nonce when encrypted, null otherwise +//! "updatedAt": 1700000000000 +//! } +//! ``` +//! +//! The payload is one opaque register so an item merges last-writer-wins +//! as a whole, and so it can be encrypted client-side exactly like the +//! 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. +//! +//! * 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. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +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 taskbook_common::encryption::{decrypt_item, encrypt_item, EncryptedItem}; +use taskbook_common::StorageItem; + +use super::{ChangeCallback, ChangeNotice, StorageBackend, WatchHandle}; +use crate::config::{Config, DittoConfig as DittoSettings, DittoConnect}; +use crate::credentials::DittoCredentials; +use crate::error::{Result, TaskbookError}; + +/// Environment variable that turns Ditto's own logging on (`error`, `warn`, +/// `info`, `debug`, `verbose`). Off by default so the TUI stays clean. +const LOG_ENV_VAR: &str = "TB_DITTO_LOG"; + +/// Grace period after the first peer shows up, so a one-shot CLI write has a +/// chance to be delivered before the process exits. +const FLUSH_GRACE: Duration = Duration::from_millis(300); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Doc { + #[serde(rename = "_id")] + id: String, + tb_id: u64, + archived: bool, + #[serde(default)] + deleted: bool, + payload: String, + #[serde(default)] + nonce: Option, + #[serde(default)] + updated_at: i64, +} + +/// What was last read for one taskbook id; used to diff on write. +#[derive(Debug, Clone)] +struct Entry { + doc_id: String, + json: String, +} + +type Snapshot = HashMap; + +#[derive(Default)] +struct Snapshots { + active: Option, + archive: Option, +} + +impl Snapshots { + fn slot(&mut self, archived: bool) -> &mut Option { + if archived { + &mut self.archive + } else { + &mut self.active + } + } +} + +pub struct DittoStorage { + ditto: Ditto, + rt: tokio::runtime::Runtime, + collection: String, + key: Option<[u8; 32]>, + flush_timeout: Duration, + sync_started: bool, + _subscription: Option>, + snapshots: Mutex, + dirty: AtomicBool, +} + +impl DittoStorage { + /// Open the backend described by the config, using secrets from + /// `~/.taskbook/ditto-credentials.json`, and start syncing. + pub fn new(settings: &DittoSettings) -> Result { + let creds = DittoCredentials::load()?.unwrap_or_default(); + + let key = if settings.encrypt { + Some(creds.encryption_key_bytes()?.ok_or_else(|| { + general( + "ditto.encrypt is on but no encryption key is saved — run `tb --ditto-init`", + ) + })?) + } else { + None + }; + + let connect = match settings.connect { + DittoConnect::Peers => { + let private_key = match &creds.private_key_path { + Some(path) => { + let path = Config::format_taskbook_dir(path); + Some(std::fs::read(&path).map_err(|e| { + general(format!( + "cannot read ditto private key {}: {e}", + path.display() + )) + })?) + } + None => None, + }; + DittoConfigConnect::SmallPeersOnly { private_key } + } + DittoConnect::Server => { + let url = settings + .url + .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 } + } + }; + + let license = match settings.connect { + DittoConnect::Peers => Some(creds.license_token.clone().ok_or_else(|| { + general( + "ditto connect = \"peers\" needs an offline license token from \ + https://portal.ditto.live — run `tb --ditto-init`", + ) + })?), + DittoConnect::Server => None, + }; + + Self::open(settings, connect, key, creds.token.clone(), license, true) + } + + /// Open a Ditto instance. `start_sync = false` keeps everything local + /// (used by tests). + fn open( + settings: &DittoSettings, + connect: DittoConfigConnect, + key: Option<[u8; 32]>, + token: Option, + license: Option, + start_sync: bool, + ) -> Result { + validate_collection_name(&settings.collection)?; + if settings.app_id.is_empty() { + return Err(general("ditto.appId is required")); + } + + 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 = { + // 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 + // unless the user asked for Ditto logs. + let _quiet = if std::env::var_os(LOG_ENV_VAR).is_some() { + None + } else { + StderrSilencer::new() + }; + configure_logging(); + let ditto = Ditto::open_sync(config).map_err(ditto_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 + }; + + // 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() { + ditto + .set_offline_only_license_token(license) + .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)?; + Some(subscription) + } else { + None + }; + + Ok(Self { + ditto, + rt, + collection: settings.collection.clone(), + key, + flush_timeout: Duration::from_millis(settings.flush_timeout_ms), + sync_started: start_sync, + _subscription: subscription, + snapshots: Mutex::new(Snapshots::default()), + dirty: AtomicBool::new(false), + }) + } + + 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 select_query(&self) -> String { + format!( + "SELECT * FROM {} WHERE archived = :archived AND deleted = false", + self.collection + ) + } + + fn upsert_query(&self) -> String { + format!( + "INSERT INTO {} DOCUMENTS (:doc) ON ID CONFLICT DO UPDATE", + self.collection + ) + } + + fn soft_delete_query(&self) -> String { + format!( + "UPDATE {} SET deleted = true, updatedAt = :now WHERE _id = :id", + self.collection + ) + } + + /// Serialize an item into a document, encrypting the payload when a key + /// is configured. + fn make_doc( + &self, + doc_id: String, + tb_id: u64, + archived: bool, + item: &StorageItem, + now: i64, + ) -> Result { + let (payload, nonce) = match &self.key { + Some(key) => { + let engine = base64::engine::general_purpose::STANDARD; + let encrypted = encrypt_item(key, item) + .map_err(|e| general(format!("encryption failed: {e}")))?; + ( + engine.encode(&encrypted.data), + Some(engine.encode(&encrypted.nonce)), + ) + } + None => (serde_json::to_string(item)?, None), + }; + Ok(Doc { + id: doc_id, + tb_id, + archived, + deleted: false, + payload, + nonce, + updated_at: now, + }) + } + + fn decode(&self, doc: &Doc) -> Result { + match (&self.key, &doc.nonce) { + (Some(key), Some(nonce)) => { + let engine = base64::engine::general_purpose::STANDARD; + let data = engine + .decode(&doc.payload) + .map_err(|e| general(format!("invalid base64 payload: {e}")))?; + let nonce = engine + .decode(nonce) + .map_err(|e| general(format!("invalid base64 nonce: {e}")))?; + decrypt_item(key, &EncryptedItem { data, nonce }) + .map_err(|e| general(format!("decryption failed: {e}"))) + } + (Some(_), None) => Err(general(format!( + "document {} is not encrypted but ditto.encrypt is on", + doc.id + ))), + (None, Some(_)) => Err(general(format!( + "document {} is encrypted but ditto.encrypt is off", + doc.id + ))), + (None, None) => Ok(serde_json::from_str(&doc.payload)?), + } + } + + /// 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 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 item = self.decode(&doc)?; + rows.push((doc, item)); + } + + // Deterministic order so every device resolves a collision the same + // way: the oldest write keeps its id, later ones get renumbered. + rows.sort_by(|a, b| { + (a.0.tb_id, a.0.updated_at, &a.0.id).cmp(&(b.0.tb_id, b.0.updated_at, &b.0.id)) + }); + + let mut next_id = rows.iter().map(|r| r.0.tb_id).max().unwrap_or(0); + let mut seen = HashSet::with_capacity(rows.len()); + let mut renumbered = Vec::new(); + for (doc, item) in rows.iter_mut() { + if !seen.insert(doc.tb_id) { + next_id += 1; + doc.tb_id = next_id; + set_item_id(item, next_id); + renumbered.push((doc.id.clone(), next_id, item.clone())); + } + } + + if !renumbered.is_empty() { + let now = now_millis(); + let mut docs = Vec::with_capacity(renumbered.len()); + for (doc_id, tb_id, item) in &renumbered { + docs.push(self.make_doc(doc_id.clone(), *tb_id, archived, item, now)?); + } + self.apply(docs, Vec::new())?; + } + + let mut snapshot = Snapshot::with_capacity(rows.len()); + let mut data = HashMap::with_capacity(rows.len()); + for (doc, item) in rows { + let key = doc.tb_id.to_string(); + snapshot.insert( + key.clone(), + Entry { + doc_id: doc.id, + json: serde_json::to_string(&item)?, + }, + ); + data.insert(key, item); + } + + *lock(&self.snapshots).slot(archived) = Some(snapshot); + Ok(data) + } + + /// Diff `data` against the last read snapshot and apply only the changes. + fn write(&self, archived: bool, data: &HashMap) -> Result<()> { + // Bind the clone first: a guard living in the `match` scrutinee would + // still be held while `read` below takes the same lock. + let existing = lock(&self.snapshots).slot(archived).clone(); + let snapshot = match existing { + Some(snapshot) => snapshot, + None => { + // No read yet (e.g. `--migrate`): establish the baseline so + // existing documents are updated rather than duplicated. + self.read(archived)?; + lock(&self.snapshots) + .slot(archived) + .clone() + .unwrap_or_default() + } + }; + + let now = now_millis(); + let mut upserts = Vec::new(); + let mut deletes = Vec::new(); + let mut next_snapshot = Snapshot::with_capacity(data.len()); + + for (key, item) in data { + let tb_id: u64 = key + .parse() + .map_err(|_| general(format!("non-numeric item key {key:?}")))?; + let json = serde_json::to_string(item)?; + let (doc_id, unchanged) = match snapshot.get(key) { + Some(entry) => (entry.doc_id.clone(), entry.json == json), + None => (uuid::Uuid::new_v4().to_string(), false), + }; + if !unchanged { + upserts.push(self.make_doc(doc_id.clone(), tb_id, archived, item, now)?); + } + next_snapshot.insert(key.clone(), Entry { doc_id, json }); + } + + for (key, entry) in &snapshot { + if !data.contains_key(key) { + deletes.push(entry.doc_id.clone()); + } + } + + if !upserts.is_empty() || !deletes.is_empty() { + self.apply(upserts, deletes)?; + self.dirty.store(true, Ordering::SeqCst); + } + + *lock(&self.snapshots).slot(archived) = Some(next_snapshot); + Ok(()) + } + + /// Run upserts and soft-deletes in one transaction. + 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)?; + 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), + json!({}), + )?; + Ok(result.item_count()) + } +} + +impl StorageBackend for DittoStorage { + fn get(&self) -> Result> { + self.read(false) + } + + fn get_archive(&self) -> Result> { + self.read(true) + } + + fn set(&self, data: &HashMap) -> Result<()> { + self.write(false, data) + } + + fn set_archive(&self, data: &HashMap) -> Result<()> { + self.write(true, data) + } + + fn watch(&self, on_change: ChangeCallback) -> Result> { + let on_change = Arc::new(on_change); + let mut observers = Vec::with_capacity(2); + for (archived, notice) in [(false, ChangeNotice::Active), (true, ChangeNotice::Archive)] { + let on_change = Arc::clone(&on_change); + // Ditto delivers the current result set once right after + // registration; skip that so the TUI doesn't reload needlessly. + let mut first = true; + let observer = self + .ditto + .store() + .register_observer_v2( + (self.select_query(), json!({ "archived": archived })), + move |_result: QueryResult| { + if std::mem::take(&mut first) { + return; + } + on_change(notice); + }, + ) + .map_err(ditto_err)?; + observers.push(observer); + } + Ok(Some(WatchHandle::new(observers))) + } +} + +impl Drop for DittoStorage { + fn drop(&mut self) { + // A one-shot CLI process would otherwise exit before Ditto has even + // found a peer. Wait (bounded) for one, then a short grace period. + if self.sync_started && self.dirty.load(Ordering::SeqCst) && !self.flush_timeout.is_zero() { + let deadline = Instant::now() + self.flush_timeout; + loop { + let now = Instant::now(); + if now >= deadline { + break; + } + if !self.ditto.presence().graph().remote_peers.is_empty() { + std::thread::sleep(FLUSH_GRACE.min(deadline - now)); + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + } + if self.sync_started { + self.ditto.sync().stop(); + } + } +} + +fn set_item_id(item: &mut StorageItem, id: u64) { + match item { + StorageItem::Task(t) => t.id = id, + StorageItem::Note(n) => n.id = id, + } +} + +fn now_millis() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn absolute(path: &Path) -> std::path::PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + } +} + +/// Collection names are interpolated into DQL, so restrict them to plain +/// identifiers. +fn validate_collection_name(name: &str) -> Result<()> { + let mut chars = name.chars(); + let valid = match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => { + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + } + _ => false, + }; + if valid { + Ok(()) + } else { + Err(general(format!( + "invalid ditto.collection {name:?}: use letters, digits and underscores only" + ))) + } +} + +/// Apply `TB_DITTO_LOG`; Ditto logging is off unless it is set. +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, + }; + DittoLogger::set_logging_enabled(true); + DittoLogger::set_minimum_log_level(level); + } + Err(_) => { + DittoLogger::set_minimum_log_level(LogLevel::Error); + DittoLogger::set_logging_enabled(false); + } + } +} + +/// Points fd 2 at /dev/null for its lifetime and restores it on drop. +/// Best effort: any failure leaves stderr untouched. +struct StderrSilencer { + #[cfg(unix)] + saved: libc::c_int, +} + +impl StderrSilencer { + #[cfg(unix)] + fn new() -> Option { + use std::io::Write; + let _ = std::io::stderr().flush(); + // SAFETY: plain POSIX fd calls on descriptors we own; errors are + // checked and nothing is left half-swapped. + unsafe { + let saved = libc::dup(libc::STDERR_FILENO); + if saved < 0 { + return None; + } + let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY); + if devnull < 0 { + libc::close(saved); + return None; + } + let swapped = libc::dup2(devnull, libc::STDERR_FILENO); + libc::close(devnull); + if swapped < 0 { + libc::close(saved); + return None; + } + Some(Self { saved }) + } + } + + #[cfg(not(unix))] + fn new() -> Option { + None + } +} + +#[cfg(unix)] +impl Drop for StderrSilencer { + fn drop(&mut self) { + // SAFETY: `saved` is a descriptor this struct owns. + unsafe { + libc::dup2(self.saved, libc::STDERR_FILENO); + libc::close(self.saved); + } + } +} + +fn general(msg: impl Into) -> TaskbookError { + TaskbookError::General(msg.into()) +} + +fn ditto_err(e: DittoError) -> TaskbookError { + TaskbookError::General(format!("ditto: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + use taskbook_common::{Note, Task}; + + fn temp_settings(encrypt: bool) -> DittoSettings { + let dir = std::env::temp_dir().join(format!("tb-ditto-test-{}", uuid::Uuid::new_v4())); + DittoSettings { + app_id: "taskbook-test".to_string(), + persistence_dir: Some(dir.to_string_lossy().to_string()), + encrypt, + ..DittoSettings::default() + } + } + + fn open_local(encrypt: bool) -> DittoStorage { + let settings = temp_settings(encrypt); + let key = encrypt.then(taskbook_common::encryption::generate_key); + DittoStorage::open( + &settings, + DittoConfigConnect::SmallPeersOnly { private_key: None }, + key, + None, + None, + false, + ) + .expect("open ditto") + } + + fn task(id: u64, desc: &str) -> StorageItem { + StorageItem::Task(Task::new(id, desc.to_string(), vec!["My Board".into()], 1)) + } + + fn note(id: u64, desc: &str) -> StorageItem { + StorageItem::Note(Note::new(id, desc.to_string(), vec!["My Board".into()])) + } + + fn map(items: Vec) -> HashMap { + items + .into_iter() + .map(|item| (item.id().to_string(), item)) + .collect() + } + + #[test] + fn empty_store_reads_empty() { + let storage = open_local(false); + assert!(storage.get().unwrap().is_empty()); + assert!(storage.get_archive().unwrap().is_empty()); + } + + #[test] + fn round_trips_items_and_archive() { + let storage = open_local(false); + storage + .set(&map(vec![task(1, "one"), note(2, "two")])) + .unwrap(); + storage.set_archive(&map(vec![task(1, "old")])).unwrap(); + + let items = storage.get().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items["1"].description(), "one"); + assert!(!items["2"].is_task()); + + let archive = storage.get_archive().unwrap(); + assert_eq!(archive.len(), 1); + assert_eq!(archive["1"].description(), "old"); + } + + #[test] + fn writes_are_diffs_with_soft_deletes() { + let storage = open_local(false); + storage + .set(&map(vec![task(1, "one"), task(2, "two")])) + .unwrap(); + storage.get().unwrap(); + + let mut edited = task(1, "one edited"); + if let StorageItem::Task(t) = &mut edited { + t.is_complete = true; + } + storage.set(&map(vec![edited, task(3, "three")])).unwrap(); + + let items = storage.get().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items["1"].description(), "one edited"); + assert!(items["1"].as_task().unwrap().is_complete); + assert_eq!(items["3"].description(), "three"); + assert_eq!(storage.tombstone_count().unwrap(), 1); + + // The document identity for item 1 survived the edit. + let snapshot = lock(&storage.snapshots).active.clone().unwrap(); + assert_eq!(snapshot.len(), 2); + } + + #[test] + fn keeps_document_identity_across_edits() { + let storage = open_local(false); + storage.set(&map(vec![task(1, "a")])).unwrap(); + let before = lock(&storage.snapshots).active.clone().unwrap()["1"] + .doc_id + .clone(); + storage.set(&map(vec![task(1, "b")])).unwrap(); + let after = lock(&storage.snapshots).active.clone().unwrap()["1"] + .doc_id + .clone(); + assert_eq!(before, after); + assert_eq!(storage.get().unwrap()["1"].description(), "b"); + } + + #[test] + fn encrypted_payloads_round_trip() { + let storage = open_local(true); + storage.set(&map(vec![task(1, "secret")])).unwrap(); + + let raw = storage + .execute(format!("SELECT * FROM {}", storage.collection), json!({})) + .unwrap(); + let doc: Doc = raw.iter().next().unwrap().deserialize_value().unwrap(); + assert!(doc.nonce.is_some()); + assert!(!doc.payload.contains("secret")); + + assert_eq!(storage.get().unwrap()["1"].description(), "secret"); + } + + #[test] + fn duplicate_ids_are_renumbered_on_read() { + let storage = open_local(false); + // Simulate two devices that each created item 1 while offline. + let older = storage + .make_doc("doc-a".into(), 1, false, &task(1, "first"), 100) + .unwrap(); + let newer = storage + .make_doc("doc-b".into(), 1, false, &task(1, "second"), 200) + .unwrap(); + storage.apply(vec![older, newer], Vec::new()).unwrap(); + + let items = storage.get().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items["1"].description(), "first"); + assert_eq!(items["2"].description(), "second"); + assert_eq!(items["2"].id(), 2); + + // The renumbering was persisted. + let again = storage.get().unwrap(); + assert_eq!(again["2"].description(), "second"); + assert_eq!(storage.tombstone_count().unwrap(), 0); + } + + #[test] + fn set_without_prior_read_updates_existing_documents() { + let storage = open_local(false); + storage.set(&map(vec![task(1, "a")])).unwrap(); + // Fresh snapshot state, as `--migrate` would see it. + *lock(&storage.snapshots) = Snapshots::default(); + storage.set(&map(vec![task(1, "b")])).unwrap(); + + let raw = storage + .execute( + format!("SELECT * FROM {} WHERE deleted = false", storage.collection), + json!({}), + ) + .unwrap(); + assert_eq!(raw.item_count(), 1); + assert_eq!(storage.get().unwrap()["1"].description(), "b"); + } + + #[test] + fn watch_reports_changes() { + let storage = open_local(false); + let (tx, rx) = mpsc::channel(); + let _watch = storage + .watch(Box::new(move |notice| { + let _ = tx.send(notice); + })) + .unwrap() + .expect("ditto supports watch"); + + storage.set_archive(&map(vec![task(1, "a")])).unwrap(); + let notice = rx + .recv_timeout(Duration::from_secs(5)) + .expect("change notice"); + assert_eq!(notice, ChangeNotice::Archive); + } + + #[test] + fn rejects_bad_collection_names() { + assert!(validate_collection_name("taskbook_items").is_ok()); + assert!(validate_collection_name("_x1").is_ok()); + assert!(validate_collection_name("").is_err()); + assert!(validate_collection_name("1abc").is_err()); + assert!(validate_collection_name("items; DROP").is_err()); + } + + #[test] + fn rejects_non_numeric_keys() { + let storage = open_local(false); + let mut data = HashMap::new(); + data.insert("abc".to_string(), task(1, "x")); + assert!(storage.set(&data).is_err()); + } +} diff --git a/crates/taskbook-client/src/storage/mod.rs b/crates/taskbook-client/src/storage/mod.rs index 3e9cb24..54a28ef 100644 --- a/crates/taskbook-client/src/storage/mod.rs +++ b/crates/taskbook-client/src/storage/mod.rs @@ -1,18 +1,92 @@ +#[cfg(feature = "ditto")] +mod ditto; mod local; mod remote; +#[cfg(feature = "ditto")] +pub use ditto::DittoStorage; pub use local::LocalStorage; pub use remote::RemoteStorage; +use std::any::Any; use std::collections::HashMap; +use std::path::Path; +use crate::config::{Config, SyncBackend}; +use crate::directory::resolve_taskbook_directory; use crate::error::Result; use taskbook_common::StorageItem; +/// Which logical collection changed, as reported through [`StorageBackend::watch`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] // only constructed by backends with push notifications +pub enum ChangeNotice { + Active, + Archive, +} + +/// Callback invoked by a backend when another writer (device or process) +/// changed the data. May be called from a background thread. +pub type ChangeCallback = Box; + +/// Keeps a change watch registered for as long as it is alive; dropping it +/// unregisters the watch. +pub struct WatchHandle(#[allow(dead_code)] Box); + +impl WatchHandle { + #[allow(dead_code)] // only used by backends with push notifications + pub fn new(inner: impl Any) -> Self { + Self(Box::new(inner)) + } +} + /// Trait abstracting storage backends (local file, remote server, etc.) pub trait StorageBackend { fn get(&self) -> Result>; fn get_archive(&self) -> Result>; fn set(&self, data: &HashMap) -> Result<()>; fn set_archive(&self, data: &HashMap) -> Result<()>; + + /// Subscribe to change notifications pushed by the backend. Backends + /// without push notifications return `Ok(None)` and callers fall back to + /// whatever polling or out-of-band mechanism they have. + fn watch(&self, _on_change: ChangeCallback) -> Result> { + Ok(None) + } +} + +/// Build the backend the config selects: the sync backend when +/// `sync.enabled` is true, local files otherwise. +pub fn from_config( + config: &Config, + taskbook_dir: Option<&Path>, +) -> Result> { + if config.sync.enabled { + sync_backend(config) + } else { + let resolved_dir = resolve_taskbook_directory(taskbook_dir)?; + Ok(Box::new(LocalStorage::new(&resolved_dir)?)) + } +} + +/// Build the configured sync backend regardless of `sync.enabled` +/// (used by `--migrate`, which runs before sync is switched on). +pub fn sync_backend(config: &Config) -> Result> { + match config.sync.backend { + SyncBackend::Server => Ok(Box::new(RemoteStorage::new(&config.sync.server_url)?)), + SyncBackend::Ditto => ditto_backend(config), + } +} + +#[cfg(feature = "ditto")] +fn ditto_backend(config: &Config) -> Result> { + Ok(Box::new(DittoStorage::new(&config.ditto)?)) +} + +#[cfg(not(feature = "ditto"))] +fn ditto_backend(_config: &Config) -> Result> { + Err(crate::error::TaskbookError::General( + "this build of tb has no Ditto support; rebuild with `cargo build --features ditto`" + .to_string(), + )) } diff --git a/crates/taskbook-client/src/taskbook.rs b/crates/taskbook-client/src/taskbook.rs index 186a5e9..447d7f5 100644 --- a/crates/taskbook-client/src/taskbook.rs +++ b/crates/taskbook-client/src/taskbook.rs @@ -4,11 +4,10 @@ use std::path::Path; use arboard::Clipboard; use crate::config::Config; -use crate::directory::resolve_taskbook_directory; use crate::editor; use crate::error::{Result, TaskbookError}; use crate::render::{Render, Stats}; -use crate::storage::{LocalStorage, RemoteStorage, StorageBackend}; +use crate::storage::{self, ChangeCallback, StorageBackend, WatchHandle}; use taskbook_common::board::{self, DEFAULT_BOARD}; use taskbook_common::due; use taskbook_common::{Note, StorageItem, Task}; @@ -30,14 +29,7 @@ pub struct Taskbook { impl Taskbook { pub fn new(taskbook_dir: Option<&Path>) -> Result { let config = Config::load_or_default(); - - let storage: Box = if config.sync.enabled { - Box::new(RemoteStorage::new(&config.sync.server_url)?) - } else { - let resolved_dir = resolve_taskbook_directory(taskbook_dir)?; - Box::new(LocalStorage::new(&resolved_dir)?) - }; - + let storage = storage::from_config(&config, taskbook_dir)?; let render = Render::new(config); Ok(Self { storage, render }) @@ -52,6 +44,12 @@ impl Taskbook { } } + /// Subscribe to data changes pushed by the storage backend, if it + /// supports that. See [`StorageBackend::watch`]. + pub fn watch_changes(&self, on_change: ChangeCallback) -> Result> { + self.storage.watch(on_change) + } + fn get_data(&self) -> Result> { self.storage.get() } diff --git a/crates/taskbook-client/src/tui/event.rs b/crates/taskbook-client/src/tui/event.rs index 016c599..e09b439 100644 --- a/crates/taskbook-client/src/tui/event.rs +++ b/crates/taskbook-client/src/tui/event.rs @@ -7,6 +7,8 @@ use std::time::Duration; use crossterm::event::{self, KeyEvent}; use crate::error::{Result, TaskbookError}; +use crate::storage::{ChangeNotice, WatchHandle}; +use crate::taskbook::Taskbook; /// Terminal events #[derive(Debug)] @@ -51,6 +53,9 @@ pub struct EventHandler { handler: thread::JoinHandle<()>, #[allow(dead_code)] sse_handler: Option>, + /// Keeps a backend change watch alive for the lifetime of the handler. + #[allow(dead_code)] + watch: Option, } impl EventHandler { @@ -63,9 +68,34 @@ impl EventHandler { receiver, handler, sse_handler: None, + watch: None, } } + /// Create an event handler fed by the storage backend's own change + /// notifications (see `StorageBackend::watch`). Returns `Ok(None)` when + /// the backend has no push mechanism. + pub fn new_with_watch(tick_rate: u64, taskbook: &Taskbook) -> Result> { + let (sender, receiver) = mpsc::channel(); + let watch_sender = sender.clone(); + let Some(watch) = taskbook.watch_changes(Box::new(move |notice| { + let archived = notice == ChangeNotice::Archive; + // Ignore send errors: the TUI has shut down. + let _ = watch_sender.send(Event::DataChanged { archived }); + }))? + else { + return Ok(None); + }; + let handler = spawn_input_thread(sender, tick_rate); + + Ok(Some(Self { + receiver, + handler, + sse_handler: None, + watch: Some(watch), + })) + } + /// Create an event handler that also listens for SSE sync notifications. pub fn new_with_sse(tick_rate: u64, server_url: String, token: String) -> Self { let (sender, receiver) = mpsc::channel(); @@ -76,6 +106,7 @@ impl EventHandler { receiver, handler, sse_handler: Some(sse_handler), + watch: None, } } diff --git a/crates/taskbook-client/src/tui/mod.rs b/crates/taskbook-client/src/tui/mod.rs index 55533c3..86822d4 100644 --- a/crates/taskbook-client/src/tui/mod.rs +++ b/crates/taskbook-client/src/tui/mod.rs @@ -8,7 +8,7 @@ mod theme; mod ui; pub mod widgets; -use crate::config::Config; +use crate::config::SyncBackend; use crate::credentials::Credentials; use crate::error::{Result, TaskbookError}; pub use app::{App, ViewMode}; @@ -133,7 +133,7 @@ pub fn run(taskbook_dir: Option<&Path>) -> Result<()> { } fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> { - let events = create_event_handler(&app.config); + let events = create_event_handler(app); while app.running { // Force full redraw if requested (e.g. after returning from external editor) @@ -176,8 +176,16 @@ fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> } /// Create the appropriate event handler based on sync configuration. -fn create_event_handler(config: &Config) -> event::EventHandler { - if config.sync.enabled { +/// +/// Backends that push change notifications themselves (see +/// `StorageBackend::watch`) are preferred; the HTTP server backend falls back +/// to its SSE stream. +fn create_event_handler(app: &App) -> event::EventHandler { + if let Ok(Some(handler)) = event::EventHandler::new_with_watch(250, &app.taskbook) { + return handler; + } + let config = &app.config; + if config.sync.enabled && config.sync.backend == SyncBackend::Server { if let Ok(Some(creds)) = Credentials::load() { return event::EventHandler::new_with_sse(250, creds.server_url, creds.token); } diff --git a/docs/README.md b/docs/README.md index ea11408..e493bc6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,7 @@ tb --begin 2 | [Configuration](configuration.md) | Client configuration options | | [Server Setup](server.md) | Running the sync server | | [Sync & Encryption](sync.md) | Setting up sync between devices | +| [Ditto Backend](ditto.md) | Peer-to-peer sync with Ditto (optional feature) | | [Kubernetes Deployment](kubernetes.md) | Deploying the server to Kubernetes | | [Observability](observability.md) | OpenTelemetry traces, metrics & logs | @@ -48,6 +49,7 @@ tb --begin 2 - **Clipboard**: Copy item descriptions - **Themes**: Customizable color schemes including Catppuccin - **Server Sync**: Optional encrypted sync with real-time SSE notifications +- **Ditto Sync**: Optional peer-to-peer sync (LAN, Bluetooth, Ditto Cloud) with the same client-side encryption - **End-to-End Encryption**: Your data is encrypted client-side with AES-256-GCM ## Architecture @@ -61,6 +63,7 @@ tb --begin 2 ~/.config/taskbook/taskbook.json # Configuration file (legacy: ~/.taskbook.json) ~/.taskbook/credentials.json # Server credentials (when using sync) +~/.taskbook/ditto-credentials.json # Ditto secrets (when using the Ditto backend) ``` ## Data Compatibility diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 77167f6..f851c2b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -337,7 +337,7 @@ Logs out and deletes stored credentials. Sync is disabled and the client returns tb --status ``` -Shows current sync status, server URL, and whether credentials are saved. +Shows current sync status, server URL (or Ditto app ID and connect mode), and whether credentials are saved. ### Migrate Local Data @@ -345,7 +345,16 @@ Shows current sync status, server URL, and whether credentials are saved. tb --migrate ``` -Pushes existing local data to the server. Use this after registering to upload your existing tasks. +Pushes existing local data to the configured sync backend (the server, or Ditto when `sync.backend = "ditto"`). Use this once, after registering or setting up Ditto, to upload your existing tasks. + +### Ditto Setup + +```bash +tb --ditto-init +tb --ditto-init --key +``` + +Creates `~/.taskbook/ditto-credentials.json` for the [Ditto backend](ditto.md): generates the item encryption key (printed once) or imports one with `--key`, and prompts for the auth token in `server` mode. Only available for its effect in builds with `--features ditto`. ## Global Options diff --git a/docs/configuration.md b/docs/configuration.md index 119fdc5..00d1d97 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -167,11 +167,41 @@ Configuration for server sync. This is typically managed automatically by the `- | Field | Type | Description | |-------|------|-------------| | `enabled` | `boolean` | Whether sync is active | -| `serverUrl` | `string` | URL of the sync server | +| `serverUrl` | `string` | URL of the sync server (`backend = "server"`) | +| `backend` | `"server"` \| `"ditto"` | Which sync backend to use; defaults to `"server"` | -When `enabled` is `true`, all task operations are synced to the server. The client stores encrypted data locally as a cache and syncs with the server on each operation. +When `enabled` is `true`, all task operations go to the selected backend instead of local files. -See [Sync & Encryption](sync.md) for setup instructions. +See [Sync & Encryption](sync.md) for the server backend and [Ditto Backend](ditto.md) for the peer-to-peer backend. + +### ditto + +**Type**: `object` +**Default**: `{ "connect": "peers", "provider": "development", "collection": "taskbook_items", "encrypt": true, "flushTimeoutMs": 1000 }` + +Settings for the Ditto backend (`sync.backend = "ditto"`, requires a build with `--features ditto`). Secrets live in `~/.taskbook/ditto-credentials.json`, not here. + +```json +{ + "ditto": { + "appId": "my-taskbook", + "connect": "peers" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `appId` | `string` | Ditto app / database ID shared by all devices (required) | +| `connect` | `"peers"` \| `"server"` | LAN / P2P mesh, or Ditto Cloud / Big Peer | +| `url` | `string` | Auth / sync URL for `server` mode | +| `provider` | `string` | Auth provider name used with the token in `server` mode | +| `persistenceDir` | `string` | Ditto's local database directory (default `~/.taskbook/ditto`) | +| `collection` | `string` | Collection holding the items | +| `encrypt` | `boolean` | Encrypt payloads client-side (AES-256-GCM) | +| `flushTimeoutMs` | `number` | Bounded wait for a peer after a one-shot CLI write | + +See [Ditto Backend](ditto.md) for the full reference. ## Environment Variables diff --git a/docs/ditto.md b/docs/ditto.md new file mode 100644 index 0000000..e2f42c5 --- /dev/null +++ b/docs/ditto.md @@ -0,0 +1,192 @@ +# Ditto Backend + +Taskbook can store its data in [Ditto](https://ditto.com), a peer-to-peer +CRDT database that syncs between devices over LAN, Bluetooth, peer-to-peer +Wi-Fi, or through a Ditto Cloud / self-hosted Big Peer server. Unlike the +[HTTP server backend](sync.md) there is no taskbook server to run: every +device holds a full copy and changes merge whenever devices can see each +other. + +The backend is optional and compiled in with a Cargo feature: + +```bash +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: + +```bash +rustup toolchain install 1.97.0 +cargo +1.97.0 build --release --features ditto +``` + +The default build (without the feature) is unaffected. + +## Setup + +1. **Pick an app ID.** Every device that should share data uses the same + `ditto.appId`. For a LAN-only mesh any string works; Ditto Cloud issues a + UUID per app. + +2. **Configure** `~/.config/taskbook/taskbook.json`: + + ```json + { + "sync": { "enabled": true, "backend": "ditto" }, + "ditto": { + "appId": "my-taskbook", + "connect": "peers" + } + } + ``` + + For Ditto Cloud or a self-hosted Big Peer: + + ```json + { + "sync": { "enabled": true, "backend": "ditto" }, + "ditto": { + "appId": "00000000-0000-0000-0000-000000000000", + "connect": "server", + "url": "https://.cloud.ditto.live", + "provider": "development" + } + } + ``` + +3. **Create the secrets** with + + ```bash + tb --ditto-init + ``` + + This writes `~/.taskbook/ditto-credentials.json` (mode 0600). On the first + device it generates the item encryption key and prints it once. In + `peers` mode it prompts for an offline license token (free, from the + [Ditto portal](https://portal.ditto.live); Ditto refuses to start sync + without one), in `server` mode for the auth token. On every other device + import the same key (and enter the same license token): + + ```bash + tb --ditto-init --key + ``` + + The file can also be written by hand: + + ```json + { + "encryptionKey": "", + "licenseToken": "", + "privateKeyPath": "~/.taskbook/ditto-shared.der", + "token": "" + } + ``` + +4. **Migrate existing local data** (first device only): + + ```bash + tb --migrate + ``` + +5. Check with `tb --status`. + +## Configuration reference + +| Field | Default | Description | +|-------|---------|-------------| +| `ditto.appId` | `""` (required) | Ditto app / database ID shared by all devices | +| `ditto.connect` | `"peers"` | `peers` (LAN / P2P mesh, no account) or `server` (Ditto Cloud or Big Peer) | +| `ditto.url` | unset | Auth / sync URL for `server` mode | +| `ditto.provider` | `"development"` | Authentication provider (webhook) name used with the token in `server` mode | +| `ditto.persistenceDir` | `~/.taskbook/ditto` | Ditto's local database directory | +| `ditto.collection` | `"taskbook_items"` | Collection holding the items | +| `ditto.encrypt` | `true` | Encrypt payloads client-side with AES-256-GCM | +| `ditto.flushTimeoutMs` | `1000` | After a one-shot CLI write, wait up to this long for a peer before exiting; `0` disables | + +Set `TB_DITTO_LOG=info` (or `error`, `warn`, `debug`, `verbose`) to see +Ditto's own log output. It is off by default so it cannot corrupt the TUI. + +### Peers mode and the shared key + +With no `privateKeyPath`, `peers` mode runs as an open development mesh: +any device with the same app ID on the same network can join. For anything +beyond a trusted home network, generate a shared private key (DER) as +described in Ditto's authentication docs and point `privateKeyPath` at it on +every device. Item payloads stay encrypted with the taskbook key either +way, but the shared key is what stops strangers from joining the mesh and +seeing the (encrypted) documents at all. + +## How data is stored + +Each item is one Ditto document in the configured collection: + +```json +{ + "_id": "6f1b…", // stable identity across devices + "tbId": 7, // the id you see in tb + "archived": false, + "deleted": false, // soft-delete tombstone + "payload": "…", // item JSON, or base64 AES-256-GCM ciphertext + "nonce": "…", // base64 nonce when encrypted + "updatedAt": 1700000000000 +} +``` + +The payload is a single opaque value, so an item merges last-writer-wins as +a whole and can be encrypted exactly like the HTTP server backend's blobs. +What Ditto (and Ditto Cloud) can see is the id, archived/deleted flags and +timestamps; the description, boards, tags and dates are inside the +encrypted payload. Field-level merging (one device stars an item while +another edits its description) is deliberately not attempted: the later +write wins for that item. + +Writes are diffs. `tb` compares the new state against what it last read and +only upserts changed items and tombstones removed ones, inside one Ditto +transaction. Archiving an item therefore tombstones the active document and +creates a new archived one, which is what the whole-map storage contract +implies. + +### Id collisions + +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 sorted deterministically (oldest write keeps its id) and the +rest are renumbered to fresh ids and written back. Nothing is lost, but an +id you just used can change after a sync. Run `tb` again before acting on +an id you saw before devices reconnected. + +### One-shot commands and sync + +`tb --task "…"` opens Ditto, writes, and exits. Ditto persists the change +locally immediately, but delivering it to peers needs a connection, which +can take a second to establish. The backend therefore waits (bounded by +`flushTimeoutMs`) for a peer to appear plus a short grace period before +exiting. The TUI is long-lived and syncs continuously, and shows changes +from other devices as they arrive. + +## Troubleshooting + +- **"ditto.encrypt is on but no encryption key is saved"** — run + `tb --ditto-init` (or `--key ` on a second device). +- **"document … is encrypted but ditto.encrypt is off"** (or the reverse) — + all devices must agree on `ditto.encrypt`; it cannot be changed after data + exists without clearing the collection. +- **"decryption failed"** — the devices use different encryption keys. +- **"needs an offline license token"** / **"has not yet been activated"** — + `peers` mode needs `licenseToken` in the credentials file; `tb --ditto-init` + asks for it. +- **"this build of tb has no Ditto support"** — rebuild with + `--features ditto`. +- **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.