M12 — the Android client, end to end #2
@@ -24,6 +24,11 @@ on:
|
||||
# crate since the Android client binds the same code, so a change there is a
|
||||
# change to this app even though nothing under desktop/ moved.
|
||||
- "core/**"
|
||||
# The Android uniffi shim. It builds no desktop artifact, but it is a
|
||||
# workspace member, so this lane's `cargo clippy --all-targets` is what
|
||||
# compiles and lints it — and until the Android lane exists (M12 step 5),
|
||||
# it is the ONLY thing that does.
|
||||
- "android/**"
|
||||
# The workspace manifest and lockfile, which now live at the repo root.
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
|
||||
Generated
+372
-2
@@ -70,6 +70,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
@@ -85,6 +91,72 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc"
|
||||
dependencies = [
|
||||
"askama_macros",
|
||||
"itoa",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_derive"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738"
|
||||
dependencies = [
|
||||
"askama_parser",
|
||||
"basic-toml",
|
||||
"glob",
|
||||
"memchr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_macros"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a"
|
||||
dependencies = [
|
||||
"askama_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_parser"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da"
|
||||
dependencies = [
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"unicode-ident",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compat"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"once_cell",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -132,6 +204,15 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "basic-toml"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -280,6 +361,16 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo-platform"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo_metadata"
|
||||
version = "0.19.2"
|
||||
@@ -287,7 +378,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"cargo-platform",
|
||||
"cargo-platform 0.1.9",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo_metadata"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"cargo-platform 0.3.3",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -359,6 +464,45 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -953,6 +1097,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
@@ -1263,6 +1416,17 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "goblin"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
|
||||
dependencies = [
|
||||
"log",
|
||||
"plain",
|
||||
"scroll",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk"
|
||||
version = "0.18.2"
|
||||
@@ -1996,6 +2160,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
@@ -2091,6 +2261,16 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -2543,6 +2723,12 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.10.0"
|
||||
@@ -3076,6 +3262,26 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scroll"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
|
||||
dependencies = [
|
||||
"scroll_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scroll_derive"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -3356,6 +3562,12 @@ version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "smawk"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
@@ -3420,6 +3632,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
@@ -3842,7 +4060,7 @@ checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brotli",
|
||||
"cargo_metadata",
|
||||
"cargo_metadata 0.19.2",
|
||||
"ctor",
|
||||
"dom_query",
|
||||
"dunce",
|
||||
@@ -3905,6 +4123,15 @@ dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||
dependencies = [
|
||||
"smawk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -3974,6 +4201,18 @@ dependencies = [
|
||||
"thoughtsync-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thoughtsync-ffi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
"thoughtsync-core",
|
||||
"tokio",
|
||||
"uniffi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
@@ -4355,6 +4594,128 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "uniffi"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"camino",
|
||||
"cargo_metadata 0.23.1",
|
||||
"clap",
|
||||
"uniffi_bindgen",
|
||||
"uniffi_core",
|
||||
"uniffi_macros",
|
||||
"uniffi_pipeline",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_bindgen"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"askama",
|
||||
"camino",
|
||||
"cargo_metadata 0.23.1",
|
||||
"fs-err",
|
||||
"glob",
|
||||
"goblin",
|
||||
"heck 0.5.0",
|
||||
"indexmap 2.14.0",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"textwrap",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"uniffi_internal_macros",
|
||||
"uniffi_meta",
|
||||
"uniffi_pipeline",
|
||||
"uniffi_udl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_core"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-compat",
|
||||
"bytes",
|
||||
"once_cell",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_internal_macros"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap 2.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_macros"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"fs-err",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.119",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"uniffi_meta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_meta"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"siphasher",
|
||||
"uniffi_internal_macros",
|
||||
"uniffi_pipeline",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_pipeline"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"indexmap 2.14.0",
|
||||
"tempfile",
|
||||
"uniffi_internal_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_udl"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"textwrap",
|
||||
"uniffi_meta",
|
||||
"weedle2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -4655,6 +5016,15 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weedle2"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
# Rust workspace. Two members today: the framework-free client core, and the Tauri
|
||||
# desktop app that wraps it. The Android client becomes a third consumer of `core`
|
||||
# through uniffi (Scribe note 2730) — which is the reason the core is a crate at all
|
||||
# rather than a module inside the desktop app.
|
||||
# Rust workspace. The framework-free client core, and the two shims that wrap it:
|
||||
# the Tauri desktop app and the uniffi bindings the native Android client loads.
|
||||
# Neither shim owns the core — that is the reason it is a crate at all rather than a
|
||||
# module inside the desktop app (Scribe note 2730).
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["core", "desktop/src-tauri"]
|
||||
members = ["core", "desktop/src-tauri", "android/ffi"]
|
||||
|
||||
# Shared pins, so two consumers of the core cannot drift onto different versions of
|
||||
# the same dependency and resolve differently.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "thoughtsync-ffi"
|
||||
version = "0.1.0"
|
||||
description = "uniffi bindings exposing thoughtsync-core to the native Android client"
|
||||
authors = ["bvandeusen"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
# cdylib is the `.so` Android's System.loadLibrary opens. `lib` alongside it so the
|
||||
# bindgen binary below — and this crate's own tests — can use the crate normally;
|
||||
# a cdylib-only crate is unusable from Rust.
|
||||
crate-type = ["cdylib", "lib"]
|
||||
name = "thoughtsync_ffi"
|
||||
|
||||
[dependencies]
|
||||
thoughtsync-core = { path = "../../core" }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
# tokio lets an exported `async fn` be driven by a tokio runtime, which the sync
|
||||
# engine needs: it is reqwest all the way down.
|
||||
uniffi = { version = "0.32", features = ["tokio"] }
|
||||
|
||||
# reqwest requires a reactor; uniffi's `async_runtime = "tokio"` needs one to exist.
|
||||
# rt-multi-thread rather than current_thread: a sync cycle is network-bound and a
|
||||
# Compose UI may have more than one call in flight.
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
|
||||
# Display + Error impls for the error enum uniffi turns into a Kotlin exception.
|
||||
thiserror = "2"
|
||||
|
||||
[features]
|
||||
# The Kotlin generator, off by default.
|
||||
#
|
||||
# uniffi's `cli` feature drags in clap, askama and goblin — ~15 crates that exist
|
||||
# only to serve a three-line binary. Until the Android lane lands, this crate is
|
||||
# compiled on every DESKTOP push (it is a workspace member, so `cargo clippy
|
||||
# --all-targets` picks it up), and paying for a code generator on a lane that never
|
||||
# runs one is the wrong trade. `required-features` on the bin means
|
||||
# `--all-targets` skips it rather than failing.
|
||||
#
|
||||
# Generate bindings with:
|
||||
# cargo run --features bindgen --bin uniffi-bindgen -- generate ...
|
||||
bindgen = ["uniffi/cli"]
|
||||
|
||||
[[bin]]
|
||||
name = "uniffi-bindgen"
|
||||
path = "src/bin/uniffi-bindgen.rs"
|
||||
required-features = ["bindgen"]
|
||||
@@ -0,0 +1,14 @@
|
||||
//! The Kotlin generator, as a binary in THIS workspace.
|
||||
//!
|
||||
//! uniffi's generated bindings and the `uniffi` runtime crate linked into the `.so`
|
||||
//! have to be the same version — they are two halves of one ABI. Running the
|
||||
//! generator from here guarantees that by construction, because it compiles against
|
||||
//! the very same dependency. A `cargo install uniffi-bindgen` in the CI image would
|
||||
//! instead be a second version that has to be kept in step by hand, which is why
|
||||
//! ci-rust-android deliberately doesn't ship one.
|
||||
//!
|
||||
//! Invoked as: cargo run --bin uniffi-bindgen -- generate --library <path/to/.so> \
|
||||
//! --language kotlin --out-dir <app/src/main/java>
|
||||
fn main() {
|
||||
uniffi::uniffi_bindgen_main()
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! uniffi bindings: `thoughtsync-core` as seen from Kotlin.
|
||||
//!
|
||||
//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop
|
||||
//! — a thin shim over the shared core, holding no logic of its own. If something here
|
||||
//! starts making decisions about notes or sync, it belongs in the core where the
|
||||
//! desktop gets it too (Scribe note 2730).
|
||||
//!
|
||||
//! ## Shape
|
||||
//!
|
||||
//! One `ThoughtSync` object holds the store and the blob directory, mirroring how
|
||||
//! Tauri manages them as app state. Kotlin constructs it once, keeps it for the
|
||||
//! process lifetime, and calls methods on it.
|
||||
//!
|
||||
//! ## Async
|
||||
//!
|
||||
//! The sync engine is reqwest all the way down, so it needs a reactor. Async methods
|
||||
//! are exported with `async_runtime = "tokio"`, which uniffi turns into Kotlin
|
||||
//! `suspend` functions driven by a tokio runtime on the Rust side.
|
||||
//!
|
||||
//! Cancellation works, and not by accident: when a coroutine is cancelled uniffi
|
||||
//! drops the Rust future, and none of the core's async paths hold the store lock
|
||||
//! across an `await` — a `std::sync::MutexGuard` isn't `Send`, so the compiler has
|
||||
//! been enforcing that all along. A cancelled sync therefore leaves the store
|
||||
//! consistent; it simply hasn't stamped `last_sync_at`, which is only written after
|
||||
//! BOTH halves of a cycle succeed. The next cycle resumes from the stored cursor.
|
||||
//!
|
||||
//! ## A known consequence of the release profile
|
||||
//!
|
||||
//! The workspace sets `panic = "abort"` (Tauri's profile, for binary size). uniffi
|
||||
//! would otherwise catch a panic crossing the FFI boundary and raise it in Kotlin as
|
||||
//! an exception; with `abort` it takes the process down instead. That is the same
|
||||
//! behaviour the desktop already has, so no surface is worse off than another — but
|
||||
//! it is a deliberate cost, not an oversight. Revisit if a panic in the core ever
|
||||
//! turns out to be recoverable enough that a phone should survive it.
|
||||
|
||||
pub mod models;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use thoughtsync_core::local::{self, Db};
|
||||
use thoughtsync_core::sync::blobs::BlobStore;
|
||||
use thoughtsync_core::sync::{client, compat, engine, push, state};
|
||||
|
||||
use models::{
|
||||
patch_from, Identity, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult, RevokeOutcome,
|
||||
SyncOutcome, SyncStatus,
|
||||
};
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
|
||||
/// Everything that can go wrong, as a Kotlin exception.
|
||||
///
|
||||
/// The core reports failures as plain `String`s today, so most of them land in
|
||||
/// `Store` or `Network` by where they were raised rather than by a distinction the
|
||||
/// core actually draws. `NotLinked` is the exception and earns its own variant: it
|
||||
/// is the one failure that is a NORMAL state rather than a fault — an unlinked app is
|
||||
/// working exactly as intended — and the UI's response is to offer linking, not to
|
||||
/// show an error.
|
||||
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
||||
pub enum CoreError {
|
||||
/// No server is linked. Not a fault; the app is local-first and this is its
|
||||
/// resting state.
|
||||
#[error("this device isn't linked to a server")]
|
||||
NotLinked,
|
||||
|
||||
/// The on-device store failed.
|
||||
#[error("{message}")]
|
||||
Store { message: String },
|
||||
|
||||
/// Talking to the server failed, or it refused.
|
||||
#[error("{message}")]
|
||||
Network { message: String },
|
||||
}
|
||||
|
||||
impl CoreError {
|
||||
fn store(e: impl std::fmt::Display) -> Self {
|
||||
CoreError::Store {
|
||||
message: e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn network(e: impl std::fmt::Display) -> Self {
|
||||
CoreError::Network {
|
||||
message: e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The client handle: the on-device store plus the attachment directory beside it.
|
||||
///
|
||||
/// Held by Kotlin for the process lifetime. Both halves are `Send + Sync` — the store
|
||||
/// behind its mutex, the blob store being a path — which is what lets uniffi share
|
||||
/// one instance across coroutines.
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct ThoughtSync {
|
||||
db: Db,
|
||||
blobs: BlobStore,
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
impl ThoughtSync {
|
||||
/// Open (creating on first run) the store under `data_dir`, and the attachment
|
||||
/// directory beside it.
|
||||
///
|
||||
/// `data_dir` comes from Kotlin because only Android knows where its app-private
|
||||
/// storage is; the core must not guess at a platform path. The layout inside is
|
||||
/// the core's business and matches the desktop's exactly — `thoughtsync.db` and
|
||||
/// `blobs/` — so a store is readable by any client that opens it.
|
||||
#[uniffi::constructor]
|
||||
pub fn new(data_dir: String) -> Result<Arc<Self>, CoreError> {
|
||||
let dir = PathBuf::from(data_dir);
|
||||
std::fs::create_dir_all(&dir).map_err(CoreError::store)?;
|
||||
|
||||
let db = local::open(&dir.join("thoughtsync.db")).map_err(CoreError::store)?;
|
||||
log::info!("local store ready — {}", local::summary(&db));
|
||||
|
||||
let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?;
|
||||
Ok(Arc::new(ThoughtSync { db, blobs }))
|
||||
}
|
||||
|
||||
/// A one-line count summary, for the boot log.
|
||||
pub fn summary(&self) -> String {
|
||||
local::summary(&self.db)
|
||||
}
|
||||
|
||||
// ─────────────────────────────── notes ───────────────────────────────
|
||||
|
||||
pub fn list_notes(&self, query: NoteQuery) -> Result<Vec<Note>, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
let notes = local::store::list_notes(&conn, &query.into()).map_err(CoreError::store)?;
|
||||
Ok(notes.into_iter().map(Note::from).collect())
|
||||
}
|
||||
|
||||
pub fn get_note(&self, id: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::get_note(&conn, &id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
pub fn create_note(&self, draft: NoteDraft) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::create_note(&conn, &draft.into())
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Apply a batch of field edits. See `NoteEdit` for why this is a list rather
|
||||
/// than a struct of nullable fields.
|
||||
pub fn update_note(&self, id: String, edits: Vec<NoteEdit>) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::update_note(&conn, &id, &patch_from(edits))
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
pub fn trash_note(&self, id: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::trash(&conn, &id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
pub fn restore_note(&self, id: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::restore(&conn, &id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
// ─────────────────────────────── sync ────────────────────────────────
|
||||
|
||||
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
state::status(&conn)
|
||||
.map(SyncStatus::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Whether anything is waiting to be sent — so the UI can show an honest
|
||||
/// "unsynced changes" state without running a sync to find out.
|
||||
pub fn has_pending(&self) -> Result<bool, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
push::has_pending(&conn).map_err(CoreError::store)
|
||||
}
|
||||
}
|
||||
|
||||
/// Async methods, driven by a tokio runtime and surfaced to Kotlin as `suspend`
|
||||
/// functions. Split into its own impl block so the runtime attribute — and the fact
|
||||
/// that everything in here touches the network — is visible at a glance.
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
impl ThoughtSync {
|
||||
/// Ask a server who it is, without committing to anything. Called as the user
|
||||
/// finishes typing an address, so they see what answered before handing over
|
||||
/// credentials.
|
||||
pub async fn probe(&self, url: String) -> Result<ProbeResult, CoreError> {
|
||||
client::probe(&url)
|
||||
.await
|
||||
.map(ProbeResult::from)
|
||||
.map_err(CoreError::network)
|
||||
}
|
||||
|
||||
/// Pair with a server using an email/password, minting a device token named for
|
||||
/// this phone.
|
||||
///
|
||||
/// The handshake runs FIRST, and an incompatible server aborts before any
|
||||
/// credential is sent — an incompatible server is exactly the case where a later
|
||||
/// failure would be hardest to attribute.
|
||||
pub async fn link_with_password(
|
||||
&self,
|
||||
url: String,
|
||||
email: String,
|
||||
password: String,
|
||||
device_name: String,
|
||||
) -> Result<Identity, CoreError> {
|
||||
let probe = client::probe(&url).await.map_err(CoreError::network)?;
|
||||
if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility {
|
||||
return Err(CoreError::Network {
|
||||
message: reason.clone(),
|
||||
});
|
||||
}
|
||||
let (token, identity) =
|
||||
client::device_login(&probe.base_url, &email, &password, &device_name)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?;
|
||||
Ok(identity.into())
|
||||
}
|
||||
|
||||
/// Pair using a device token pasted from the web app — for anyone who would
|
||||
/// rather not type a password into an app, or whose account is behind SSO.
|
||||
///
|
||||
/// The token is verified before it is stored, so a copy/paste slip fails here
|
||||
/// rather than at the next sync.
|
||||
pub async fn link_with_token(&self, url: String, token: String) -> Result<Identity, CoreError> {
|
||||
let probe = client::probe(&url).await.map_err(CoreError::network)?;
|
||||
if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility {
|
||||
return Err(CoreError::Network {
|
||||
message: reason.clone(),
|
||||
});
|
||||
}
|
||||
let identity = client::fetch_identity(&probe.base_url, &token)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?;
|
||||
Ok(identity.into())
|
||||
}
|
||||
|
||||
/// Stop syncing, and retire this device's token on the server.
|
||||
///
|
||||
/// The local half is unconditional. Someone unlinking because the phone is being
|
||||
/// sold or handed on must not be held to it by a server that is offline or gone,
|
||||
/// so the revoke is attempted first, its outcome returned for the UI to report
|
||||
/// honestly, and the link cleared either way.
|
||||
pub async fn unlink(&self) -> Result<RevokeOutcome, CoreError> {
|
||||
// Read and release before the network call: a std MutexGuard isn't Send, so
|
||||
// it cannot be held across an await, and holding the store through a
|
||||
// round-trip would freeze every note operation in the UI.
|
||||
let link = {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
let current = state::read(&conn).map_err(CoreError::store)?;
|
||||
current.server_url.zip(current.device_token)
|
||||
};
|
||||
let revoked = match &link {
|
||||
Some((base_url, token)) => client::revoke_self(base_url, token).await,
|
||||
None => client::RevokeOutcome::Skipped,
|
||||
};
|
||||
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
state::clear_link(&conn).map_err(CoreError::store)?;
|
||||
log::info!("unlinked from server (server-side token: {revoked:?})");
|
||||
Ok(revoked.into())
|
||||
}
|
||||
|
||||
/// Run one full sync: push local changes, then pull the server's.
|
||||
///
|
||||
/// The only sync entry point, on purpose. Push and pull exist separately inside
|
||||
/// the core, but offering a bare "pull" would let the UI overwrite unsent local
|
||||
/// edits — the ordering isn't a suggestion, it's what keeps them.
|
||||
pub async fn sync_now(&self) -> Result<SyncOutcome, CoreError> {
|
||||
let (base_url, token) = self.credentials()?;
|
||||
engine::run_cycle(&self.db, &self.blobs, &base_url, &token)
|
||||
.await
|
||||
.map(SyncOutcome::from)
|
||||
.map_err(CoreError::network)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
||||
/// block names, so these stay Rust-side.
|
||||
impl ThoughtSync {
|
||||
/// The server URL + token, or the `NotLinked` state. Every networked call needs
|
||||
/// exactly this, and none of them may hold the lock past it.
|
||||
fn credentials(&self) -> Result<(String, String), CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
let current = state::read(&conn).map_err(CoreError::store)?;
|
||||
match (current.server_url, current.device_token) {
|
||||
(Some(url), Some(token)) => Ok((url, token)),
|
||||
_ => Err(CoreError::NotLinked),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a fresh link, adopting the server's retention window at the same time
|
||||
/// so the Trash view stops counting down against this device's offline default
|
||||
/// the moment it is no longer the policy in force.
|
||||
fn store_link(
|
||||
&self,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
retention_days: Option<u32>,
|
||||
) -> Result<(), CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
state::set_link(&conn, base_url, token).map_err(CoreError::store)?;
|
||||
if let Some(days) = retention_days {
|
||||
state::set_server_retention(&conn, days as i64).map_err(CoreError::store)?;
|
||||
}
|
||||
log::info!("linked to {base_url}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A scratch directory unique to this process and call.
|
||||
///
|
||||
/// Process id + a counter rather than a uuid dependency: the FFI crate has no
|
||||
/// business pulling one in to name a temp folder, and this is the same approach
|
||||
/// the desktop's updater tests settled on.
|
||||
fn scratch_dir() -> String {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"thoughtsync-ffi-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
dir.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn draft(title: &str, body: &str) -> NoteDraft {
|
||||
NoteDraft {
|
||||
title: title.to_string(),
|
||||
body: body.to_string(),
|
||||
color: "default".to_string(),
|
||||
kind: None,
|
||||
items: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The round trip the Android skeleton has to make: open a store in a directory
|
||||
/// that doesn't exist yet, write a note, read it back through the FFI types.
|
||||
/// Proving it here means a failure on device is an Android problem, not a
|
||||
/// binding problem.
|
||||
#[test]
|
||||
fn creates_a_store_and_round_trips_a_note() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let created = app
|
||||
.create_note(draft("Groceries", "milk"))
|
||||
.expect("create should succeed");
|
||||
assert_eq!(created.title.as_deref(), Some("Groceries"));
|
||||
assert_eq!(created.body, "milk");
|
||||
|
||||
let fetched = app
|
||||
.get_note(created.id.clone())
|
||||
.expect("get should succeed");
|
||||
assert_eq!(fetched.id, created.id);
|
||||
assert_eq!(fetched.display_title, "Groceries");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A body-only note still has to be nameable — that is what `display_title` is
|
||||
/// for, and the Android board relies on it exactly as the desktop does.
|
||||
#[test]
|
||||
fn body_only_notes_still_have_a_display_title() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let created = app
|
||||
.create_note(draft("", "just a thought"))
|
||||
.expect("create should succeed");
|
||||
assert_eq!(created.title, None);
|
||||
assert_eq!(created.display_title, "just a thought");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Clearing a field and setting one are different edits, and the difference has
|
||||
/// to survive the trip through the patch object.
|
||||
#[test]
|
||||
fn edits_can_both_set_and_clear_a_title() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("First", "body")).expect("create");
|
||||
|
||||
let renamed = app
|
||||
.update_note(
|
||||
note.id.clone(),
|
||||
vec![NoteEdit::Title {
|
||||
value: "Second".to_string(),
|
||||
}],
|
||||
)
|
||||
.expect("rename");
|
||||
assert_eq!(renamed.title.as_deref(), Some("Second"));
|
||||
|
||||
let cleared = app
|
||||
.update_note(note.id.clone(), vec![NoteEdit::ClearTitle])
|
||||
.expect("clear");
|
||||
assert_eq!(
|
||||
cleared.title, None,
|
||||
"ClearTitle must null the column, not set it to an empty string — the \
|
||||
distinction is why NoteEdit is a list rather than a struct of options"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// An unlinked app is a normal, working app. Asking it to sync is the one
|
||||
/// failure that isn't a fault, and it has to arrive as `NotLinked` so the UI can
|
||||
/// offer linking rather than show an error.
|
||||
#[test]
|
||||
fn syncing_unlinked_reports_not_linked() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let status = app.sync_status().expect("status should read");
|
||||
assert!(!status.linked);
|
||||
assert_eq!(status.server_url, None);
|
||||
|
||||
assert!(matches!(app.credentials(), Err(CoreError::NotLinked)));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
//! The types that cross into Kotlin.
|
||||
//!
|
||||
//! These MIRROR `thoughtsync_core::local::models` rather than reusing it. The core's
|
||||
//! shapes are serde structs whose field names and optionality are contracted with the
|
||||
//! shared Vue frontend; hanging uniffi derives on them would couple two very
|
||||
//! different consumers to one definition and put a `serde_json::Value` (which has no
|
||||
//! uniffi representation) in the middle of it.
|
||||
//!
|
||||
//! The cost of mirroring is drift — an Android client quietly missing a field the
|
||||
//! desktop gained. Every conversion below therefore DESTRUCTURES the core struct
|
||||
//! exhaustively instead of reading fields it cares about. Add a field to
|
||||
//! `core::local::models::Note` and this file stops compiling until Android is told
|
||||
//! what to do with it. That is the entire reason for the `let Core { .. } = value`
|
||||
//! style here; please keep it.
|
||||
|
||||
use thoughtsync_core::local::models as core_models;
|
||||
use thoughtsync_core::sync::client as core_client;
|
||||
use thoughtsync_core::sync::compat as core_compat;
|
||||
use thoughtsync_core::sync::engine as core_engine;
|
||||
use thoughtsync_core::sync::pull as core_pull;
|
||||
use thoughtsync_core::sync::push as core_push;
|
||||
use thoughtsync_core::sync::state as core_state;
|
||||
|
||||
/// A note, with everything needed to render a card or open the editor.
|
||||
///
|
||||
/// Timestamps are RFC3339 strings, not a date type: that is what SQLite holds and
|
||||
/// what the server speaks, and converting here would mean this layer picking a
|
||||
/// calendar/timezone policy that belongs to the UI.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
/// Title if set, else the first body line — always present, so a body-only note
|
||||
/// is still nameable. Derived by the core, never stored.
|
||||
pub display_title: String,
|
||||
pub body: String,
|
||||
pub color: String,
|
||||
pub kind: String,
|
||||
pub position: i64,
|
||||
pub pinned: bool,
|
||||
pub archived: bool,
|
||||
pub trashed: bool,
|
||||
pub deleted_at: Option<String>,
|
||||
pub remind_at: Option<String>,
|
||||
pub recurrence: Option<String>,
|
||||
pub labels: Vec<NoteLabel>,
|
||||
pub items: Vec<ChecklistItem>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub previews: Vec<LinkPreview>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteLabel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
/// True when attached because of a `#tag` in the body, so the UI can show it is
|
||||
/// owned by the text and not independently removable.
|
||||
pub via_tag: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct ChecklistItem {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub checked: bool,
|
||||
pub position: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub filename: Option<String>,
|
||||
pub mime: String,
|
||||
pub size: Option<i64>,
|
||||
pub sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct LinkPreview {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub site_name: Option<String>,
|
||||
}
|
||||
|
||||
impl From<core_models::Note> for Note {
|
||||
fn from(value: core_models::Note) -> Self {
|
||||
// Exhaustive on purpose — see the module header.
|
||||
let core_models::Note {
|
||||
id,
|
||||
title,
|
||||
display_title,
|
||||
body,
|
||||
color,
|
||||
kind,
|
||||
position,
|
||||
pinned,
|
||||
archived,
|
||||
trashed,
|
||||
deleted_at,
|
||||
remind_at,
|
||||
recurrence,
|
||||
labels,
|
||||
items,
|
||||
attachments,
|
||||
previews,
|
||||
created_at,
|
||||
updated_at,
|
||||
} = value;
|
||||
Note {
|
||||
id,
|
||||
title,
|
||||
display_title,
|
||||
body,
|
||||
color,
|
||||
kind,
|
||||
position,
|
||||
pinned,
|
||||
archived,
|
||||
trashed,
|
||||
deleted_at,
|
||||
remind_at,
|
||||
recurrence,
|
||||
labels: labels.into_iter().map(NoteLabel::from).collect(),
|
||||
items: items.into_iter().map(ChecklistItem::from).collect(),
|
||||
attachments: attachments.into_iter().map(Attachment::from).collect(),
|
||||
previews: previews.into_iter().map(LinkPreview::from).collect(),
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_models::NoteLabel> for NoteLabel {
|
||||
fn from(value: core_models::NoteLabel) -> Self {
|
||||
let core_models::NoteLabel {
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
via_tag,
|
||||
} = value;
|
||||
NoteLabel {
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
via_tag,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_models::ChecklistItem> for ChecklistItem {
|
||||
fn from(value: core_models::ChecklistItem) -> Self {
|
||||
let core_models::ChecklistItem {
|
||||
id,
|
||||
text,
|
||||
checked,
|
||||
position,
|
||||
} = value;
|
||||
ChecklistItem {
|
||||
id,
|
||||
text,
|
||||
checked,
|
||||
position,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_models::Attachment> for Attachment {
|
||||
fn from(value: core_models::Attachment) -> Self {
|
||||
let core_models::Attachment {
|
||||
id,
|
||||
url,
|
||||
filename,
|
||||
mime,
|
||||
size,
|
||||
sha256,
|
||||
} = value;
|
||||
Attachment {
|
||||
id,
|
||||
url,
|
||||
filename,
|
||||
mime,
|
||||
size,
|
||||
sha256,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_models::LinkPreview> for LinkPreview {
|
||||
fn from(value: core_models::LinkPreview) -> Self {
|
||||
let core_models::LinkPreview {
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image_url,
|
||||
site_name,
|
||||
} = value;
|
||||
LinkPreview {
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image_url,
|
||||
site_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── queries and edits ─────────────────────────────
|
||||
|
||||
/// What the board is asking for. Mirrors the core's `ListQuery`.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteQuery {
|
||||
/// "notes" | "archive" | "trash" | "reminders" | "labels" — the core validates.
|
||||
pub view: String,
|
||||
pub label_id: Option<String>,
|
||||
pub sort: Option<String>,
|
||||
pub facets: Option<NoteFacets>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteFacets {
|
||||
pub q: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub label: Option<Vec<String>>,
|
||||
pub has_reminder: Option<bool>,
|
||||
pub has_attachment: Option<bool>,
|
||||
pub created_after: Option<String>,
|
||||
pub created_before: Option<String>,
|
||||
}
|
||||
|
||||
impl From<NoteQuery> for core_models::ListQuery {
|
||||
fn from(value: NoteQuery) -> Self {
|
||||
let NoteQuery {
|
||||
view,
|
||||
label_id,
|
||||
sort,
|
||||
facets,
|
||||
} = value;
|
||||
core_models::ListQuery {
|
||||
view,
|
||||
label_id,
|
||||
sort,
|
||||
facets: facets.map(core_models::Facets::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NoteFacets> for core_models::Facets {
|
||||
fn from(value: NoteFacets) -> Self {
|
||||
let NoteFacets {
|
||||
q,
|
||||
color,
|
||||
kind,
|
||||
label,
|
||||
has_reminder,
|
||||
has_attachment,
|
||||
created_after,
|
||||
created_before,
|
||||
} = value;
|
||||
core_models::Facets {
|
||||
q,
|
||||
color,
|
||||
kind,
|
||||
label,
|
||||
has_reminder,
|
||||
has_attachment,
|
||||
created_after,
|
||||
created_before,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A new note.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteDraft {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
/// "default" unless the user picked a colour.
|
||||
pub color: String,
|
||||
pub kind: Option<String>,
|
||||
/// Checklist lines, for `kind = "checklist"`.
|
||||
pub items: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl From<NoteDraft> for core_models::NoteCreateInput {
|
||||
fn from(value: NoteDraft) -> Self {
|
||||
let NoteDraft {
|
||||
title,
|
||||
body,
|
||||
color,
|
||||
kind,
|
||||
items,
|
||||
} = value;
|
||||
core_models::NoteCreateInput {
|
||||
title,
|
||||
body,
|
||||
color,
|
||||
kind,
|
||||
items,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One field-level change to a note.
|
||||
///
|
||||
/// A LIST of these rather than a struct of optional fields, because the core's patch
|
||||
/// semantics distinguish three states — leave alone, set to a value, and clear to
|
||||
/// null — and Kotlin has no way to express the third with a nullable field. `title:
|
||||
/// null` in a data class is indistinguishable from `title` unset, so the editor
|
||||
/// could never clear a title. Explicit `Clear*` variants say it out loud, and Kotlin
|
||||
/// gets a sealed class it can `when` over exhaustively.
|
||||
#[derive(Debug, Clone, uniffi::Enum)]
|
||||
pub enum NoteEdit {
|
||||
Title { value: String },
|
||||
ClearTitle,
|
||||
Body { value: String },
|
||||
Color { value: String },
|
||||
Kind { value: String },
|
||||
Pinned { value: bool },
|
||||
Archived { value: bool },
|
||||
RemindAt { value: String },
|
||||
ClearRemindAt,
|
||||
Recurrence { value: String },
|
||||
ClearRecurrence,
|
||||
}
|
||||
|
||||
impl NoteEdit {
|
||||
/// The (key, value) pair this edit contributes to the core's JSON patch.
|
||||
///
|
||||
/// The core reads a patch object where a present key means "change this" and a
|
||||
/// null value means "clear it" — the shape the REST API and the Tauri commands
|
||||
/// both already speak. Translating here keeps that one patch format in one
|
||||
/// place instead of teaching a second dialect to the store.
|
||||
fn entry(self) -> (&'static str, serde_json::Value) {
|
||||
use serde_json::Value;
|
||||
match self {
|
||||
NoteEdit::Title { value } => ("title", Value::String(value)),
|
||||
NoteEdit::ClearTitle => ("title", Value::Null),
|
||||
NoteEdit::Body { value } => ("body", Value::String(value)),
|
||||
NoteEdit::Color { value } => ("color", Value::String(value)),
|
||||
NoteEdit::Kind { value } => ("kind", Value::String(value)),
|
||||
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
|
||||
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
|
||||
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
|
||||
NoteEdit::ClearRemindAt => ("remind_at", Value::Null),
|
||||
NoteEdit::Recurrence { value } => ("recurrence", Value::String(value)),
|
||||
NoteEdit::ClearRecurrence => ("recurrence", Value::Null),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a list of edits into the single patch object the store applies.
|
||||
///
|
||||
/// Later edits win on a repeated key, which is what a caller batching "set title,
|
||||
/// then clear title" would expect.
|
||||
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
|
||||
let mut map = serde_json::Map::new();
|
||||
for edit in edits {
|
||||
let (key, value) = edit.entry();
|
||||
map.insert(key.to_string(), value);
|
||||
}
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
|
||||
// ───────────────────────────────── sync ─────────────────────────────────
|
||||
|
||||
/// What the UI may know about the link. Carries no device token, deliberately —
|
||||
/// the core withholds it from `Status` for the same reason, and a bearer token has
|
||||
/// no business in UI state.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct SyncStatus {
|
||||
pub linked: bool,
|
||||
pub server_url: Option<String>,
|
||||
pub last_cursor: i64,
|
||||
pub last_sync_at: Option<String>,
|
||||
}
|
||||
|
||||
impl From<core_state::Status> for SyncStatus {
|
||||
fn from(value: core_state::Status) -> Self {
|
||||
let core_state::Status {
|
||||
linked,
|
||||
server_url,
|
||||
last_cursor,
|
||||
last_sync_at,
|
||||
} = value;
|
||||
SyncStatus {
|
||||
linked,
|
||||
server_url,
|
||||
last_cursor,
|
||||
last_sync_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a server said about itself, before committing to anything.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct ProbeResult {
|
||||
/// Normalised by the core — this, not what the user typed, is what gets stored.
|
||||
pub base_url: String,
|
||||
pub site_name: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub trash_retention_days: Option<u32>,
|
||||
pub compatibility: Compatibility,
|
||||
}
|
||||
|
||||
/// Whether this client and that server can sync at all.
|
||||
#[derive(Debug, Clone, uniffi::Enum)]
|
||||
pub enum Compatibility {
|
||||
Ok,
|
||||
/// Safe to sync, but these named capabilities are missing. The UI should say so
|
||||
/// rather than let a feature silently do nothing.
|
||||
Degraded {
|
||||
unavailable: Vec<String>,
|
||||
},
|
||||
/// Do not sync. `client_must_update` says which side can fix it, so the message
|
||||
/// can be actionable.
|
||||
Incompatible {
|
||||
reason: String,
|
||||
client_must_update: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<core_compat::Compatibility> for Compatibility {
|
||||
fn from(value: core_compat::Compatibility) -> Self {
|
||||
match value {
|
||||
core_compat::Compatibility::Ok => Compatibility::Ok,
|
||||
core_compat::Compatibility::Degraded { unavailable } => {
|
||||
Compatibility::Degraded { unavailable }
|
||||
}
|
||||
core_compat::Compatibility::Incompatible {
|
||||
reason,
|
||||
client_must_update,
|
||||
} => Compatibility::Incompatible {
|
||||
reason,
|
||||
client_must_update,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_client::ProbeResult> for ProbeResult {
|
||||
fn from(value: core_client::ProbeResult) -> Self {
|
||||
let core_client::ProbeResult {
|
||||
base_url,
|
||||
server,
|
||||
compatibility,
|
||||
} = value;
|
||||
let core_compat::ServerInfo {
|
||||
site_name,
|
||||
version,
|
||||
// Protocol numbers are the raw material of the compatibility verdict,
|
||||
// which is already carried above in a form the UI can act on. Sending
|
||||
// them too would invite a second, worse judgement being made in Kotlin.
|
||||
sync_protocol_version: _,
|
||||
min_client_protocol_version: _,
|
||||
sync_features: _,
|
||||
trash_retention_days,
|
||||
} = server;
|
||||
ProbeResult {
|
||||
base_url,
|
||||
site_name,
|
||||
version,
|
||||
trash_retention_days,
|
||||
compatibility: compatibility.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who the server thinks this device belongs to.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct Identity {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
impl From<core_client::Identity> for Identity {
|
||||
fn from(value: core_client::Identity) -> Self {
|
||||
let core_client::Identity {
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
} = value;
|
||||
Identity {
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of this device's token on the server during an unlink.
|
||||
///
|
||||
/// Separate from the local result because the local half always succeeds and the
|
||||
/// remote half may not — someone unlinking a machine they are selling deserves to be
|
||||
/// told plainly that the token is still live.
|
||||
#[derive(Debug, Clone, uniffi::Enum)]
|
||||
pub enum RevokeOutcome {
|
||||
Revoked,
|
||||
/// This server predates the self-revoke route. Only the web app can retire it.
|
||||
Unsupported,
|
||||
Failed {
|
||||
reason: String,
|
||||
},
|
||||
/// Nothing to revoke; the app wasn't linked.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl From<core_client::RevokeOutcome> for RevokeOutcome {
|
||||
fn from(value: core_client::RevokeOutcome) -> Self {
|
||||
match value {
|
||||
core_client::RevokeOutcome::Revoked => RevokeOutcome::Revoked,
|
||||
core_client::RevokeOutcome::Unsupported => RevokeOutcome::Unsupported,
|
||||
core_client::RevokeOutcome::Failed { reason } => RevokeOutcome::Failed { reason },
|
||||
core_client::RevokeOutcome::Skipped => RevokeOutcome::Skipped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of one full push-then-pull cycle.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct SyncOutcome {
|
||||
pub push: PushSummary,
|
||||
pub pull: PullSummary,
|
||||
/// The state after the cycle, so the UI refreshes from one call rather than
|
||||
/// following every sync with a status query.
|
||||
pub status: SyncStatus,
|
||||
}
|
||||
|
||||
/// Counts are `u64` because the core uses `usize`, which has no uniffi
|
||||
/// representation. Widening is lossless on every target we build for; narrowing to
|
||||
/// u32 would be a silent truncation waiting for a very large sync.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct PushSummary {
|
||||
pub batches: u64,
|
||||
pub sent: u64,
|
||||
pub created: u64,
|
||||
pub applied: u64,
|
||||
/// The server had a newer edit and kept it. Not a failure — the local row stops
|
||||
/// being dirty and the following pull adopts the server's version.
|
||||
pub kept: u64,
|
||||
pub noop: u64,
|
||||
/// Still dirty, and surfaced: these need a human (a duplicate label name is the
|
||||
/// realistic case). Silently retrying forever would be the wrong shape.
|
||||
pub rejected: u64,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct PullSummary {
|
||||
pub pages: u64,
|
||||
pub notes_applied: u64,
|
||||
pub notes_deleted: u64,
|
||||
pub labels_applied: u64,
|
||||
pub labels_deleted: u64,
|
||||
pub cursor: i64,
|
||||
/// Rows that still held unpushed local edits when the server's version landed on
|
||||
/// top. Should be 0 in a normal cycle, because push runs first; anything higher
|
||||
/// means local work was overwritten, which is worth saying out loud.
|
||||
pub clobbered_dirty: u64,
|
||||
pub blobs_downloaded: u64,
|
||||
/// Attachments whose bytes couldn't be fetched or failed verification. Counted
|
||||
/// rather than fatal.
|
||||
pub blobs_failed: u64,
|
||||
}
|
||||
|
||||
impl From<core_push::PushSummary> for PushSummary {
|
||||
fn from(value: core_push::PushSummary) -> Self {
|
||||
let core_push::PushSummary {
|
||||
batches,
|
||||
sent,
|
||||
created,
|
||||
applied,
|
||||
kept,
|
||||
noop,
|
||||
rejected,
|
||||
errors,
|
||||
} = value;
|
||||
PushSummary {
|
||||
batches: batches as u64,
|
||||
sent: sent as u64,
|
||||
created: created as u64,
|
||||
applied: applied as u64,
|
||||
kept: kept as u64,
|
||||
noop: noop as u64,
|
||||
rejected: rejected as u64,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_pull::PullSummary> for PullSummary {
|
||||
fn from(value: core_pull::PullSummary) -> Self {
|
||||
let core_pull::PullSummary {
|
||||
pages,
|
||||
notes_applied,
|
||||
notes_deleted,
|
||||
labels_applied,
|
||||
labels_deleted,
|
||||
cursor,
|
||||
clobbered_dirty,
|
||||
blobs_downloaded,
|
||||
blobs_failed,
|
||||
} = value;
|
||||
PullSummary {
|
||||
pages: pages as u64,
|
||||
notes_applied: notes_applied as u64,
|
||||
notes_deleted: notes_deleted as u64,
|
||||
labels_applied: labels_applied as u64,
|
||||
labels_deleted: labels_deleted as u64,
|
||||
cursor,
|
||||
clobbered_dirty: clobbered_dirty as u64,
|
||||
blobs_downloaded: blobs_downloaded as u64,
|
||||
blobs_failed: blobs_failed as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_engine::SyncOutcome> for SyncOutcome {
|
||||
fn from(value: core_engine::SyncOutcome) -> Self {
|
||||
let core_engine::SyncOutcome { push, pull, status } = value;
|
||||
SyncOutcome {
|
||||
push: push.into(),
|
||||
pull: pull.into(),
|
||||
status: status.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_set_and_a_clear_are_different_patch_entries() {
|
||||
let set = patch_from(vec![NoteEdit::Title {
|
||||
value: "x".to_string(),
|
||||
}]);
|
||||
assert_eq!(set["title"], serde_json::json!("x"));
|
||||
|
||||
let cleared = patch_from(vec![NoteEdit::ClearTitle]);
|
||||
assert!(
|
||||
cleared["title"].is_null(),
|
||||
"a clear must reach the store as JSON null — an absent key means \
|
||||
'leave alone', which is a different instruction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_edit_list_is_an_empty_patch() {
|
||||
// Not merely tidy: the core rejects a non-object patch, and a UI that
|
||||
// batches edits may well end up sending none.
|
||||
assert_eq!(patch_from(vec![]), serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_edits_win_on_a_repeated_field() {
|
||||
let patch = patch_from(vec![
|
||||
NoteEdit::Title {
|
||||
value: "first".to_string(),
|
||||
},
|
||||
NoteEdit::ClearTitle,
|
||||
]);
|
||||
assert!(patch["title"].is_null());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Where the generated Kotlin lands. Matches the app's package so the bindings are
|
||||
# `com.fabledsword.thoughtsync.core.*` rather than something the app has to alias.
|
||||
[bindings.kotlin]
|
||||
package_name = "com.fabledsword.thoughtsync.core"
|
||||
cdylib_name = "thoughtsync_ffi"
|
||||
@@ -21,6 +21,26 @@ use rusqlite::Connection;
|
||||
/// it in the uniffi object.
|
||||
pub struct Db(pub Mutex<Connection>);
|
||||
|
||||
impl Db {
|
||||
/// Lock the store, reporting a poisoned lock as a message rather than a panic.
|
||||
///
|
||||
/// Every consumer was writing `db.0.lock().map_err(|e| e.to_string())?` at each
|
||||
/// call site. Beyond the repetition, that spelling forces the caller to NAME
|
||||
/// `rusqlite::Connection` in any helper that returns the guard — which would make
|
||||
/// rusqlite a dependency of a layer whose whole point is not to know what the
|
||||
/// store is made of. Returning it from here means callers can bind the guard by
|
||||
/// inference and never name the type.
|
||||
///
|
||||
/// A poisoned lock means some earlier call panicked while holding it. The store
|
||||
/// is not necessarily corrupt, but this connection can't be trusted blind, so it
|
||||
/// surfaces as an error the UI can show instead of a second panic.
|
||||
pub fn conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>, String> {
|
||||
self.0
|
||||
.lock()
|
||||
.map_err(|_| "the local store lock was poisoned by an earlier panic".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||
pub fn open(path: &Path) -> rusqlite::Result<Db> {
|
||||
let conn = Connection::open(path)?;
|
||||
|
||||
Reference in New Issue
Block a user