diff --git a/Cargo.lock b/Cargo.lock index 938ce6e..e3f6632 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,12 +8,14 @@ version = "0.1.0" dependencies = [ "aardvark-doc", "aardvark-node", - "ashpd", + "ashpd 0.9.2", "futures-util", "gettext-rs", "gtk4", "libadwaita", + "oo7", "sourceview5", + "thiserror 2.0.12", "tracing", "tracing-subscriber", ] @@ -30,7 +32,6 @@ dependencies = [ "loro", "p2panda-core", "thiserror 2.0.12", - "tokio", "tracing", ] @@ -40,6 +41,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "chrono", "ciborium", "p2panda-core", "p2panda-discovery", @@ -48,6 +50,7 @@ dependencies = [ "p2panda-stream", "p2panda-sync", "serde", + "sqlx", "tokio", "tokio-stream", "tracing", @@ -177,7 +180,26 @@ dependencies = [ "serde_repr", "tracing", "url", - "zbus", + "zbus 4.4.0", +] + +[[package]] +name = "ashpd" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.0", + "serde", + "serde_repr", + "tracing", + "url", + "zbus 5.6.0", ] [[package]] @@ -373,6 +395,15 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -406,16 +437,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] -name = "backoff" -version = "0.4.0" +name = "backon" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +checksum = "fd0b50b1b78dbadd44ab18b3c794e496f3a139abb9fbc27d9c94c4eebbb96496" dependencies = [ - "futures-core", - "getrandom 0.2.15", - "instant", - "pin-project-lite", - "rand 0.8.5", + "fastrand", + "gloo-timers", + "tokio", ] [[package]] @@ -468,6 +497,9 @@ name = "bitflags" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +dependencies = [ + "serde", +] [[package]] name = "bitmaps" @@ -480,9 +512,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.6.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "675f87afced0413c9bb02843499dbbd3882a237645883f71a2b59644a6d2f753" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" dependencies = [ "arrayref", "arrayvec", @@ -625,8 +657,10 @@ checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -789,6 +823,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -922,6 +965,7 @@ checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" dependencies = [ "const-oid", "der_derive", + "pem-rfc7468", "zeroize", ] @@ -1010,6 +1054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1045,6 +1090,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "ed25519" version = "2.2.3" @@ -1076,6 +1127,9 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] [[package]] name = "embedded-io" @@ -1189,6 +1243,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "event-listener" version = "5.4.0" @@ -1268,6 +1333,21 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1347,6 +1427,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -1655,6 +1746,18 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gobject-sys" version = "0.20.9" @@ -1836,6 +1939,15 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.2", +] + [[package]] name = "heapless" version = "0.7.17" @@ -1889,35 +2001,10 @@ dependencies = [ [[package]] name = "hickory-proto" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner 0.6.1", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.8.5", - "thiserror 1.0.69", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "hickory-proto" -version = "0.25.0-alpha.5" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d00147af6310f4392a31680db52a3ed45a2e0f68eb18e8c3fe5537ecc96d9e2" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ - "async-recursion", "async-trait", "cfg-if", "data-encoding", @@ -1929,6 +2016,7 @@ dependencies = [ "ipnet", "once_cell", "rand 0.9.0", + "ring", "thiserror 2.0.12", "tinyvec", "tokio", @@ -1938,13 +2026,13 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.25.0-alpha.5" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5762f69ebdbd4ddb2e975cd24690bf21fe6b2604039189c26acddbc427f12887" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" dependencies = [ "cfg-if", "futures-util", - "hickory-proto 0.25.0-alpha.5", + "hickory-proto", "ipconfig", "moka", "once_cell", @@ -1957,6 +2045,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1982,6 +2079,15 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a8575493d277c9092b988c780c94737fb9fd8651a1001e16bee3eccfc1baedb" +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "hostname" version = "0.4.0" @@ -2369,14 +2475,14 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iroh" -version = "0.33.0" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ffd6af2e000f04972068c0318e0d8fa90ee9cfcb2bc6124db38591500e0278" +checksum = "37432887a6836e7a832fccb121b5f0ee6cd953c506f99b0278bdbedf8dee0e88" dependencies = [ "aead", "anyhow", "atomic-waker", - "backoff", + "backon", "bytes", "cfg_aliases", "concurrent-queue", @@ -2392,14 +2498,13 @@ dependencies = [ "instant", "iroh-base", "iroh-metrics", - "iroh-net-report", "iroh-quinn", "iroh-quinn-proto", "iroh-quinn-udp", "iroh-relay", "n0-future", "netdev", - "netwatch 0.3.0", + "netwatch", "pin-project", "pkarr", "portmapper", @@ -2413,6 +2518,7 @@ dependencies = [ "smallvec", "strum", "stun-rs", + "surge-ping", "thiserror 2.0.12", "time", "tokio", @@ -2428,15 +2534,14 @@ dependencies = [ [[package]] name = "iroh-base" -version = "0.33.0" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "011d271a95b41218d22bdaf3352f29ef1dd7d6be644ca8543941655bec5f3d35" +checksum = "3cd952d9e25e521d6aeb5b79f2fe32a0245da36aae3569e50f6010b38a5f0923" dependencies = [ "curve25519-dalek", "data-encoding", "derive_more", "ed25519-dalek", - "getrandom 0.2.15", "postcard", "rand_core 0.6.4", "serde", @@ -2459,9 +2564,9 @@ dependencies = [ [[package]] name = "iroh-gossip" -version = "0.33.0" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d4c7e330bf3d29576d443003e31a2d30d97b29ee13521af2634926d831c01d" +checksum = "71a9d638618fb6a4dac68d59cb694774478ea039bc9afca4709e242726591be1" dependencies = [ "anyhow", "async-channel", @@ -2490,9 +2595,9 @@ dependencies = [ [[package]] name = "iroh-metrics" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "571d177e20f0848a643a2c0f662be0e08968f8743b0776941f83a2152b87a180" +checksum = "c0f7cd1ffe3b152a5f4f4c1880e01e07d96001f20e02cc143cb7842987c616b3" dependencies = [ "erased_set", "serde", @@ -2501,35 +2606,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "iroh-net-report" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d2652f42eadc63458e36c0a422569f338639dc0b5bb469db0eb4a382b4e295c" -dependencies = [ - "anyhow", - "bytes", - "cfg_aliases", - "derive_more", - "hickory-resolver", - "iroh-base", - "iroh-metrics", - "iroh-quinn", - "iroh-relay", - "n0-future", - "netwatch 0.3.0", - "portmapper", - "rand 0.8.5", - "reqwest", - "rustls", - "surge-ping", - "thiserror 2.0.12", - "tokio", - "tokio-util", - "tracing", - "url", -] - [[package]] name = "iroh-quinn" version = "0.13.0" @@ -2588,9 +2664,9 @@ dependencies = [ [[package]] name = "iroh-relay" -version = "0.33.0" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c930ccc4dfd0196b531344e3d0f83a0f82c45b170406e04a2491cba571faec5b" +checksum = "40d2d7b50d999922791c6c14c25e13f55711e182618cb387bafa0896ffe0b930" dependencies = [ "anyhow", "bytes", @@ -2691,6 +2767,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128" @@ -2735,6 +2814,23 @@ version = "0.2.171" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +[[package]] +name = "libm" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a25169bd5913a4b437588a7e3d127cd6e90127b60e0ffbd834a38f1599e016b8" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2810,15 +2906,17 @@ dependencies = [ "cfg-if", "generator 0.8.4", "scoped-tls", + "serde", + "serde_json", "tracing", "tracing-subscriber", ] [[package]] name = "loro" -version = "1.4.6" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc3e0312d28cdec90cbf31fb5f4aa1ff15d4d30218845fe771f3cba5545e1056" +checksum = "c2d4331ca4f9279ef08e9367b1e0f2be184ae411c195bb8ed2058b347fcf2351" dependencies = [ "enum-as-inner 0.6.1", "fxhash", @@ -2832,9 +2930,9 @@ dependencies = [ [[package]] name = "loro-common" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8a7a44070a295743eea2182f2ea82bc504b182a2b0d791cbada83d9472a1baf" +checksum = "3068d052d47fccaa42f794a8f088670bcf72225470bec613778407a56e9f57a9" dependencies = [ "arbitrary", "enum-as-inner 0.6.1", @@ -2850,9 +2948,9 @@ dependencies = [ [[package]] name = "loro-delta" -version = "1.3.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3a4afa7e170424f830726868613b4510b37f985a2287b8fdeb7ef6be7e2ad36" +checksum = "26b837794343ce8cf7992f23b5d75495d0cff583203e3b3ca6fdfbaa2132c5dc" dependencies = [ "arrayvec", "enum-as-inner 0.5.1", @@ -2862,9 +2960,9 @@ dependencies = [ [[package]] name = "loro-internal" -version = "1.4.6" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82e6dc34f0581659062db756afc1c7c98a3e66214cf6460aa45df447e9b95860" +checksum = "6dff6d085f2d2b679c8a45e94a3879229b48321fd9262fc5012c907623f8a13f" dependencies = [ "append-only-bytes", "arref", @@ -2879,6 +2977,7 @@ dependencies = [ "im", "itertools 0.12.1", "leb128", + "loom 0.7.2", "loro-common", "loro-delta", "loro-kv-store", @@ -2897,15 +2996,17 @@ dependencies = [ "serde_json", "smallvec", "thiserror 1.0.69", + "thread_local", "tracing", + "wasm-bindgen", "xxhash-rust", ] [[package]] name = "loro-kv-store" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a8095ac01b61d14093a0a09bd8e4862d782e81162457cb718308e42704385f7" +checksum = "e062c6054447e21d4503636f0de7eef0d59af8595d14d3d74013888fe03bbd86" dependencies = [ "bytes", "ensure-cov", @@ -2982,6 +3083,16 @@ dependencies = [ "regex-automata 0.1.10", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "md5" version = "0.7.0" @@ -3181,57 +3292,22 @@ dependencies = [ [[package]] name = "netwatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "304c0c1b348830b016039f2cb1c5ac8217084a78875262c5594925dd08aa77fc" -dependencies = [ - "anyhow", - "atomic-waker", - "bytes", - "derive_more", - "futures-lite", - "futures-sink", - "futures-util", - "iroh-quinn-udp", - "libc", - "netdev", - "netlink-packet-core", - "netlink-packet-route 0.19.0", - "netlink-sys", - "once_cell", - "rtnetlink 0.13.1", - "rtnetlink 0.14.1", - "serde", - "socket2", - "thiserror 2.0.12", - "time", - "tokio", - "tokio-util", - "tracing", - "windows 0.58.0", - "wmi", -] - -[[package]] -name = "netwatch" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64da82edf903649e6cb6a77b5a6f7fe01387d8865065d411d139018510880302" +checksum = "0b7879c2cfdf30d92f2be89efa3169b3d78107e3ab7f7b9a37157782569314e1" dependencies = [ - "anyhow", "atomic-waker", "bytes", + "cfg_aliases", "derive_more", - "futures-lite", - "futures-sink", - "futures-util", "iroh-quinn-udp", + "js-sys", "libc", + "n0-future", "netdev", "netlink-packet-core", "netlink-packet-route 0.19.0", "netlink-sys", - "once_cell", "rtnetlink 0.13.1", "rtnetlink 0.14.1", "serde", @@ -3241,7 +3317,9 @@ dependencies = [ "tokio", "tokio-util", "tracing", - "windows 0.58.0", + "web-sys", + "windows 0.59.0", + "windows-result 0.3.1", "wmi", ] @@ -3337,10 +3415,28 @@ dependencies = [ ] [[package]] -name = "num-complex" -version = "0.4.6" +name = "num-bigint-dig" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "num-traits", ] @@ -3389,6 +3485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3464,6 +3561,37 @@ name = "once_cell" version = "1.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "oo7" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb23d3ec3527d65a83be1c1795cb883c52cfa57147d42acc797127df56fc489" +dependencies = [ + "ashpd 0.11.0", + "async-fs", + "async-io", + "async-lock", + "blocking", + "endi", + "futures-lite", + "futures-util", + "getrandom 0.3.1", + "num", + "num-bigint-dig", + "openssl", + "rand 0.9.0", + "serde", + "tracing", + "zbus 5.6.0", + "zbus_macros 5.6.0", + "zeroize", + "zvariant 5.5.1", +] [[package]] name = "opaque-debug" @@ -3471,12 +3599,50 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-sys" +version = "0.9.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3495,8 +3661,8 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "p2panda-core" -version = "0.3.0" -source = "git+https://github.com/p2panda/p2panda?rev=f3a016324b69beac45cf20a792fe6890cb1a21e3#f3a016324b69beac45cf20a792fe6890cb1a21e3" +version = "0.3.1" +source = "git+https://github.com/p2panda/p2panda?rev=085a57206aeae70142176c0777ed2febc7b98664#085a57206aeae70142176c0777ed2febc7b98664" dependencies = [ "blake3", "ciborium", @@ -3505,23 +3671,23 @@ dependencies = [ "rand 0.8.5", "serde", "serde_bytes", - "thiserror 1.0.69", + "thiserror 2.0.12", ] [[package]] name = "p2panda-discovery" -version = "0.3.0" -source = "git+https://github.com/p2panda/p2panda?rev=f3a016324b69beac45cf20a792fe6890cb1a21e3#f3a016324b69beac45cf20a792fe6890cb1a21e3" +version = "0.3.1" +source = "git+https://github.com/p2panda/p2panda?rev=085a57206aeae70142176c0777ed2febc7b98664#085a57206aeae70142176c0777ed2febc7b98664" dependencies = [ "anyhow", "base32", "flume", "futures-buffered", "futures-lite", - "hickory-proto 0.24.4", + "hickory-proto", "iroh", "iroh-base", - "netwatch 0.2.0", + "netwatch", "socket2", "tokio", "tokio-util", @@ -3530,8 +3696,8 @@ dependencies = [ [[package]] name = "p2panda-net" -version = "0.3.0" -source = "git+https://github.com/p2panda/p2panda?rev=f3a016324b69beac45cf20a792fe6890cb1a21e3#f3a016324b69beac45cf20a792fe6890cb1a21e3" +version = "0.3.1" +source = "git+https://github.com/p2panda/p2panda?rev=085a57206aeae70142176c0777ed2febc7b98664#085a57206aeae70142176c0777ed2febc7b98664" dependencies = [ "anyhow", "async-trait", @@ -3542,13 +3708,13 @@ dependencies = [ "iroh-base", "iroh-gossip", "iroh-quinn", - "netwatch 0.2.0", + "netwatch", "p2panda-core", "p2panda-discovery", "p2panda-sync", "rand 0.8.5", "serde", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tokio-stream", "tokio-util", @@ -3557,18 +3723,21 @@ dependencies = [ [[package]] name = "p2panda-store" -version = "0.3.0" -source = "git+https://github.com/p2panda/p2panda?rev=f3a016324b69beac45cf20a792fe6890cb1a21e3#f3a016324b69beac45cf20a792fe6890cb1a21e3" +version = "0.3.1" +source = "git+https://github.com/p2panda/p2panda?rev=085a57206aeae70142176c0777ed2febc7b98664#085a57206aeae70142176c0777ed2febc7b98664" dependencies = [ + "ciborium", + "hex", "p2panda-core", + "sqlx", "thiserror 2.0.12", "trait-variant", ] [[package]] name = "p2panda-stream" -version = "0.3.0" -source = "git+https://github.com/p2panda/p2panda?rev=f3a016324b69beac45cf20a792fe6890cb1a21e3#f3a016324b69beac45cf20a792fe6890cb1a21e3" +version = "0.3.1" +source = "git+https://github.com/p2panda/p2panda?rev=085a57206aeae70142176c0777ed2febc7b98664#085a57206aeae70142176c0777ed2febc7b98664" dependencies = [ "ciborium", "futures-channel", @@ -3577,20 +3746,20 @@ dependencies = [ "p2panda-store", "pin-project", "pin-utils", - "thiserror 1.0.69", + "thiserror 2.0.12", ] [[package]] name = "p2panda-sync" -version = "0.3.0" -source = "git+https://github.com/p2panda/p2panda?rev=f3a016324b69beac45cf20a792fe6890cb1a21e3#f3a016324b69beac45cf20a792fe6890cb1a21e3" +version = "0.3.1" +source = "git+https://github.com/p2panda/p2panda?rev=085a57206aeae70142176c0777ed2febc7b98664#085a57206aeae70142176c0777ed2febc7b98664" dependencies = [ "async-trait", "futures", "p2panda-core", "p2panda-store", "serde", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tokio-util", ] @@ -3664,6 +3833,15 @@ dependencies = [ "serde", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -3782,6 +3960,17 @@ dependencies = [ "z32", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -3874,11 +4063,10 @@ checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" [[package]] name = "portmapper" -version = "0.3.1" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5469b29e6ce2a27bfc9382720b5f0768993afec9e53b133d8248c8b09406156a" +checksum = "247dcb75747c53cc433d6d8963a064187eec4a676ba13ea33143f1c9100e754f" dependencies = [ - "anyhow", "base64", "bytes", "derive_more", @@ -3887,7 +4075,7 @@ dependencies = [ "igd-next", "iroh-metrics", "libc", - "netwatch 0.3.0", + "netwatch", "num_enum", "rand 0.8.5", "serde", @@ -4295,6 +4483,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rtnetlink" version = "0.13.1" @@ -4591,9 +4799,9 @@ dependencies = [ [[package]] name = "serde_columnar" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d4e3c0e46450edf7da174b610b9143eb8ca22059ace5016741fc9e20b88d1e7" +checksum = "5910a00acc21b3f106b9e3977cabf8d4c15b62ea585664f08ec6fedb118d88e0" dependencies = [ "itertools 0.11.0", "postcard", @@ -4604,9 +4812,9 @@ dependencies = [ [[package]] name = "serde_columnar_derive" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42c5d47942b2a7e76118b697fc0f94516a5d8366a3c0fee8d0e2b713e952e306" +checksum = "44cea1995b758f1b344f484e77a02d9d85c8a62c9ce0e5f1850e27e2f7eebbc9" dependencies = [ "darling", "proc-macro2", @@ -4731,6 +4939,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest", "rand_core 0.6.4", ] @@ -4773,9 +4982,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" dependencies = [ "libc", "windows-sys 0.52.0", @@ -4835,6 +5044,199 @@ dependencies = [ "der", ] +[[package]] +name = "sqlx" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3a85280daca669cfd3bcb68a337882a8bc57ec882f72c5d13a430613a738e" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.2", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4200e0fde19834956d4252347c12a083bdcb237d7a1a1446bffd8768417dce" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.100", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ceaa29cade31beca7129b6beeb05737f44f82dbe2a9806ecea5a7093d00b7" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.100", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0afdd3aa7a629683c2d750c2df343025545087081ab5942593a5288855b1b7a7" +dependencies = [ + "atoi", + "base64", + "bitflags 2.9.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.12", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0bedbe1bbb5e2615ef347a5e9d8cd7680fb63e77d9dafc0f29be15e53f1ebe6" +dependencies = [ + "atoi", + "base64", + "bitflags 2.9.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.12", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.12", + "tracing", + "url", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -4847,6 +5249,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5166,9 +5579,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.44.1" +version = "1.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" dependencies = [ "backtrace", "bytes", @@ -5460,6 +5873,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -5475,6 +5894,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -5557,6 +5982,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.0" @@ -5603,6 +6034,12 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -5725,6 +6162,16 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +dependencies = [ + "redox_syscall", + "wasite", +] + [[package]] name = "widestring" version = "1.1.0" @@ -6393,9 +6840,42 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2522b82023923eecb0b366da727ec883ace092e7887b61d3da5139f26b44da58" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix 0.29.0", + "ordered-stream", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "windows-sys 0.59.0", + "winnow", + "zbus_macros 5.6.0", + "zbus_names 4.2.0", + "zvariant 5.5.1", ] [[package]] @@ -6408,7 +6888,22 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.100", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d2e12843c75108c00c618c2e8ef9675b50b6ec095b36dc965f2e5aed463c15" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.100", + "zbus_names 4.2.0", + "zvariant 5.5.1", + "zvariant_utils 3.2.0", ] [[package]] @@ -6419,7 +6914,19 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow", + "zvariant 5.5.1", ] [[package]] @@ -6488,6 +6995,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] [[package]] name = "zerovec" @@ -6522,7 +7043,22 @@ dependencies = [ "serde", "static_assertions", "url", - "zvariant_derive", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "557e89d54880377a507c94cd5452f20e35d14325faf9d2958ebeadce0966c1b2" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow", + "zvariant_derive 5.5.1", + "zvariant_utils 3.2.0", ] [[package]] @@ -6535,7 +7071,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.100", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757779842a0d242061d24c28be589ce392e45350dfb9186dfd7a042a2e19870c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.100", + "zvariant_utils 3.2.0", ] [[package]] @@ -6548,3 +7097,17 @@ dependencies = [ "quote", "syn 2.0.100", ] + +[[package]] +name = "zvariant_utils" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "static_assertions", + "syn 2.0.100", + "winnow", +] diff --git a/aardvark-app/Cargo.toml b/aardvark-app/Cargo.toml index 9cdfe10..9d78782 100644 --- a/aardvark-app/Cargo.toml +++ b/aardvark-app/Cargo.toml @@ -17,9 +17,15 @@ sourceview = { package = "sourceview5", version = "0.9" } tracing = "0.1" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } ashpd = { version = "0.9", default-features = false, features = ["tracing", "async-std"] } +thiserror = { version = "2.0" } futures-util = "0.3" +oo7 = { version = "0.4", default-features = false, features = [ + "openssl_crypto", + "async-std", + "tracing", +] } [dependencies.adw] package = "libadwaita" version = "0.7" -features = ["v1_6"] +features = ["v1_6"] \ No newline at end of file diff --git a/aardvark-app/data/resources/icons/scalable/actions/down-smaller-symbolic.svg b/aardvark-app/data/resources/icons/scalable/actions/down-smaller-symbolic.svg new file mode 100644 index 0000000..0d073be --- /dev/null +++ b/aardvark-app/data/resources/icons/scalable/actions/down-smaller-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/aardvark-app/data/resources/icons/scalable/actions/join-document-symbolic.svg b/aardvark-app/data/resources/icons/scalable/actions/join-document-symbolic.svg new file mode 100644 index 0000000..975067b --- /dev/null +++ b/aardvark-app/data/resources/icons/scalable/actions/join-document-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/aardvark-app/data/resources/resources.gresource.xml b/aardvark-app/data/resources/resources.gresource.xml index 2b7e3e0..2ffd3ac 100644 --- a/aardvark-app/data/resources/resources.gresource.xml +++ b/aardvark-app/data/resources/resources.gresource.xml @@ -1,5 +1,7 @@ + icons/scalable/actions/down-smaller-symbolic.svg + icons/scalable/actions/join-document-symbolic.svg diff --git a/aardvark-app/src/application.rs b/aardvark-app/src/application.rs index 59f243a..890cb52 100644 --- a/aardvark-app/src/application.rs +++ b/aardvark-app/src/application.rs @@ -23,9 +23,12 @@ use adw::prelude::*; use adw::subclass::prelude::*; use gettextrs::gettext; use gtk::{gio, glib, glib::Properties}; +use std::{cell::OnceCell, fs}; +use tracing::error; use crate::AardvarkWindow; use crate::config; +use crate::secret; use crate::system_settings::SystemSettings; mod imp { @@ -35,7 +38,7 @@ mod imp { #[properties(wrapper_type = super::AardvarkApplication)] pub struct AardvarkApplication { #[property(get)] - pub service: Service, + pub service: OnceCell, #[property(get)] pub system_settings: SystemSettings, } @@ -55,17 +58,36 @@ mod imp { obj.setup_gactions(); obj.set_accels_for_action("app.quit", &["q"]); obj.set_accels_for_action("app.new-window", &["n"]); + + // FIXME: Don't block on loading the identity + glib::MainContext::new().block_on(async move { + let private_key = secret::get_or_create_identity() + .await + .expect("Unable to get or create identity"); + + let mut data_path = glib::user_data_dir(); + data_path.push("Aardvark"); + data_path.push(private_key.public_key().to_string()); + if let Err(error) = fs::create_dir_all(&data_path) { + error!("Failed to create data directory: {error}"); + } + let data_dir = gio::File::for_path(data_path); + + self.service + .set(Service::new(&private_key, &data_dir)) + .unwrap(); + }); } } impl ApplicationImpl for AardvarkApplication { fn startup(&self) { - self.service.startup(); + self.obj().service().startup(); self.parent_startup(); } fn shutdown(&self) { - self.service.shutdown(); + self.obj().service().shutdown(); self.parent_shutdown(); } @@ -116,7 +138,7 @@ impl AardvarkApplication { } fn new_window(&self) { - let window = AardvarkWindow::new(self, &self.imp().service); + let window = AardvarkWindow::new(self, &self.service()); window.present(); } diff --git a/aardvark-app/src/connection_popover/mod.rs b/aardvark-app/src/connection_popover/mod.rs index b291bfb..80fd71e 100644 --- a/aardvark-app/src/connection_popover/mod.rs +++ b/aardvark-app/src/connection_popover/mod.rs @@ -118,9 +118,10 @@ mod imp { let author: Author = binding.source().unwrap().downcast().unwrap(); if is_online { Some("Online".to_string()) - //Some(format_last_seen(&glib::DateTime::now_local().unwrap())) + } else if let Some(last_seen) = author.last_seen() { + Some(format_last_seen(&last_seen)) } else { - Some(format_last_seen(&author.last_seen().unwrap())) + Some("Never seen".to_string()) } }) .build(); diff --git a/aardvark-app/src/main.rs b/aardvark-app/src/main.rs index 1d82a03..7b56ba1 100644 --- a/aardvark-app/src/main.rs +++ b/aardvark-app/src/main.rs @@ -23,6 +23,8 @@ mod components; mod config; mod connection_popover; mod open_dialog; +mod open_popover; +mod secret; mod system_settings; mod textbuffer; mod window; @@ -37,9 +39,12 @@ use tracing_subscriber::prelude::*; use self::application::AardvarkApplication; use self::config::*; use self::connection_popover::ConnectionPopover; +use self::open_popover::OpenPopover; use self::textbuffer::AardvarkTextBuffer; use self::window::AardvarkWindow; +pub use self::config::APP_ID; + fn main() -> glib::ExitCode { setup_logging(); diff --git a/aardvark-app/src/open_popover/mod.rs b/aardvark-app/src/open_popover/mod.rs new file mode 100644 index 0000000..6060026 --- /dev/null +++ b/aardvark-app/src/open_popover/mod.rs @@ -0,0 +1,362 @@ +/* Copyright 2025 The Aardvark Developers + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +use adw::subclass::prelude::*; +use gettextrs::gettext; +use gtk::prelude::*; +use gtk::{glib, glib::clone, glib::closure_local}; + +use crate::system_settings::ClockFormat; +use crate::{AardvarkApplication, AardvarkWindow, open_dialog::OpenDialog}; +use aardvark_doc::{document::Document, documents::Documents}; + +mod imp { + use super::*; + use adw::prelude::AdwDialogExt; + use glib::subclass::Signal; + use std::sync::LazyLock; + + #[derive(Debug, Default, glib::Properties, gtk::CompositeTemplate)] + #[properties(wrapper_type = super::OpenPopover)] + #[template(resource = "/org/p2panda/aardvark/open_popover/open_popover.ui")] + pub struct OpenPopover { + #[template_child] + search_entry: TemplateChild, + #[template_child] + listbox: TemplateChild, + #[template_child] + stack: TemplateChild, + #[template_child] + no_results_page: TemplateChild, + #[template_child] + document_list_page: TemplateChild, + #[template_child] + open_document_button: TemplateChild, + #[property(get = Self::model, set = Self::set_model, type = Option)] + model: gtk::FilterListModel, + } + + #[glib::object_subclass] + impl ObjectSubclass for OpenPopover { + const NAME: &'static str = "AardvarkOpenPopover"; + type Type = super::OpenPopover; + type ParentType = gtk::Popover; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for OpenPopover { + fn signals() -> &'static [Signal] { + static SIGNALS: LazyLock> = LazyLock::new(|| { + vec![ + // The user has activated a document in the document list. + Signal::builder("document-activated") + .param_types([Document::static_type()]) + .build(), + ] + }); + SIGNALS.as_ref() + } + + fn constructed(&self) { + self.parent_constructed(); + + // TODO: We should also match the document id with a more complex filter + let filter = gtk::StringFilter::builder() + .expression(gtk::PropertyExpression::new( + Document::static_type(), + gtk::Expression::NONE, + "name", + )) + .ignore_case(true) + .match_mode(gtk::StringFilterMatchMode::Substring) + .build(); + self.model.set_filter(Some(&filter)); + + self.search_entry + .connect_search_changed(move |search_entry| { + filter.set_search(Some(&search_entry.text())); + }); + + self.model.connect_items_changed(clone!( + #[weak(rename_to = this)] + self, + move |model, _, _, _| { + if model.n_items() > 0 { + this.stack.set_visible_child(&*this.document_list_page); + } else { + this.stack.set_visible_child(&*this.no_results_page); + } + } + )); + + self.listbox.connect_row_activated(clone!( + #[weak(rename_to = this)] + self, + move |_, row| { + let document: Document = this + .model + .item(row.index() as u32) + .unwrap() + .downcast() + .unwrap(); + this.obj() + .emit_by_name::<()>("document-activated", &[&document]); + this.search_entry.set_text(""); + this.obj().popdown(); + } + )); + + self.listbox.bind_model(Some(&self.model), |document| { + let document = document.downcast_ref::().unwrap(); + let row = adw::ActionRow::builder() + .selectable(false) + .activatable(true) + .build(); + + document + .bind_property("name", &row, "title") + .sync_create() + .transform_to(|_, title: Option| { + if let Some(title) = title { + Some(title) + } else { + Some(gettext("Empty document")) + } + }) + .build(); + + document + .bind_property("last-accessed", &row, "subtitle") + .sync_create() + .transform_to(|binding, last_accessed: Option| { + let document: Document = binding.source().unwrap().downcast().unwrap(); + if let Some(last_accessed) = last_accessed { + Some(format_last_accessed(&last_accessed)) + } else if document.subscribed() { + Some(gettext("Currently open")) + } else { + Some(gettext("Never accessed")) + } + }) + .build(); + + row.upcast() + }); + + self.open_document_button.connect_clicked(clone!( + #[weak(rename_to = this)] + self, + move |_| { + let dialog = OpenDialog::new(); + let window = this + .obj() + .root() + .and_then(|w| w.downcast::().ok()) + .expect("Toplevel window needs to be a AardvarkWindow"); + + this.obj().popdown(); + dialog.present(Some(&window)); + + let service = window.service(); + dialog.connect_open(clone!( + #[weak] + this, + #[weak] + service, + move |_, document_id| { + let document = service + .documents() + .by_id(document_id) + .unwrap_or_else(|| Document::new(&service, Some(document_id))); + + this.obj() + .emit_by_name::<()>("document-activated", &[&document]); + } + )); + } + )); + } + } + + impl OpenPopover { + fn model(&self) -> Option { + if let Some(model) = self.model.model() { + model.downcast().ok() + } else { + None + } + } + + fn set_model(&self, model: Option<&Documents>) { + self.model.set_model(model); + } + } + + impl WidgetImpl for OpenPopover {} + impl PopoverImpl for OpenPopover {} +} + +glib::wrapper! { + pub struct OpenPopover(ObjectSubclass) + @extends gtk::Widget, gtk::Popover; +} + +impl OpenPopover { + pub fn new>(model: &P) -> Self { + glib::Object::builder().property("model", model).build() + } + + /// Connect to the signal emitted when a user clicks a document in the document list. + pub fn connect_document_activated( + &self, + f: F, + ) -> glib::SignalHandlerId { + self.connect_closure( + "document-activated", + true, + closure_local!(move |obj: Self, document: Document| { + f(&obj, &document); + }), + ) + } +} + +// This was copied from Fractal +// See: https://gitlab.gnome.org/World/fractal/-/blob/main/src/session/model/user_sessions_list/user_session.rs#L258 +fn format_last_accessed(datetime: &glib::DateTime) -> String { + let datetime = datetime.to_local().unwrap(); + let clock_format = AardvarkApplication::default() + .system_settings() + .clock_format(); + let use_24 = clock_format == ClockFormat::TwentyFourHours; + + // This was ported from Nautilus and simplified for our use case. + // See: https://gitlab.gnome.org/GNOME/nautilus/-/blob/1c5bd3614a35cfbb49de087bc10381cdef5a218f/src/nautilus-file.c#L5001 + let now = glib::DateTime::now_local().unwrap(); + let format; + let days_ago = { + let today_midnight = + glib::DateTime::from_local(now.year(), now.month(), now.day_of_month(), 0, 0, 0f64) + .expect("constructing GDateTime works"); + + let date = glib::DateTime::from_local( + datetime.year(), + datetime.month(), + datetime.day_of_month(), + 0, + 0, + 0f64, + ) + .expect("constructing GDateTime works"); + + today_midnight.difference(&date).as_days() + }; + + // Show only the time if date is on today + if days_ago == 0 { + if use_24 { + // Translators: Time in 24h format, i.e. "23:04". + // Do not change the time format as it will follow the system settings. + // See `man strftime` or the documentation of g_date_time_format for the available specifiers: + format = gettext("Last accessed at %H:%M"); + } else { + // Translators: Time in 12h format, i.e. "11:04 PM". + // Do not change the time format as it will follow the system settings. + // See `man strftime` or the documentation of g_date_time_format for the available specifiers: + format = gettext("Last accessed at %I:%M %p"); + } + } + // Show the word "Yesterday" and time if date is on yesterday + else if days_ago == 1 { + if use_24 { + // Translators: this a time in 24h format, i.e. "Last seen yesterday at 23:04". + // Do not change the time format as it will follow the system settings. + // See `man strftime` or the documentation of g_date_time_format for the available specifiers: + // xgettext:no-c-format + format = gettext("Last accessed yesterday at %H:%M"); + } else { + // Translators: this is a time in 12h format, i.e. "Last seen Yesterday at 11:04 + // PM". + // Do not change the time format as it will follow the system settings. + // See `man strftime` or the documentation of g_date_time_format for the available specifiers: + // xgettext:no-c-format + format = gettext("Last accessed yesterday at %I:%M %p"); + } + } + // Show a week day and time if date is in the last week + else if days_ago > 1 && days_ago < 7 { + if use_24 { + // Translators: this is the name of the week day followed by a time in 24h + // format, i.e. "Last seen Monday at 23:04". + // Do not change the time format as it will follow the system settings. + // See `man strftime` or the documentation of g_date_time_format for the available specifiers: + // xgettext:no-c-format + format = gettext("Last accessed %A at %H:%M"); + } else { + // Translators: this is the week day name followed by a time in 12h format, i.e. + // "Last seen Monday at 11:04 PM". + // Do not change the time format as it will follow the system settings. + // See `man strftime` or the documentation of g_date_time_format for the available specifiers: + // xgettext:no-c-format + format = gettext("Last accessed %A at %I:%M %p"); + } + } else if datetime.year() == now.year() { + if use_24 { + // Translators: this is the month and day and the time in 24h format, i.e. "Last + // seen February 3 at 23:04". + // Do not change the time format as it will follow the system settings. + // See `man strftime` or the documentation of g_date_time_format for the available specifiers: + // xgettext:no-c-format + format = gettext("Last accessed %B %-e at %H:%M"); + } else { + // Translators: this is the month and day and the time in 12h format, i.e. "Last + // seen February 3 at 11:04 PM". + // Do not change the time format as it will follow the system settings. + // See `man strftime` or the documentation of g_date_time_format for the available specifiers: + // xgettext:no-c-format + format = gettext("Last accessed %B %-e at %I:%M %p"); + } + } else if use_24 { + // Translators: this is the full date and the time in 24h format, i.e. "Last + // seen February 3 2015 at 23:04". + // Do not change the time format as it will follow the system settings. + // See `man strftime` or the documentation of g_date_time_format for the available specifiers: + // xgettext:no-c-format + format = gettext("Last accessed %B %-e %Y at %H:%M"); + } else { + // Translators: this is the full date and the time in 12h format, i.e. "Last + // seen February 3 2015 at 11:04 PM". + // Do not change the time format as it will follow the system settings. + // See `man strftime` or the documentation of g_date_time_format for the available specifiers: + // xgettext:no-c-format + format = gettext("Last accessed %B %-e %Y at %I:%M %p"); + } + + datetime + .format(&format) + .expect("formatting GDateTime works") + .into() +} diff --git a/aardvark-app/src/open_popover/open_popover.ui b/aardvark-app/src/open_popover/open_popover.ui new file mode 100644 index 0000000..7af5845 --- /dev/null +++ b/aardvark-app/src/open_popover/open_popover.ui @@ -0,0 +1,69 @@ + + + + + + + diff --git a/aardvark-app/src/secret.rs b/aardvark-app/src/secret.rs new file mode 100644 index 0000000..f81e03d --- /dev/null +++ b/aardvark-app/src/secret.rs @@ -0,0 +1,78 @@ +/* Copyright 2025 The Aardvark Developers + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +use std::collections::HashMap; +use thiserror::Error; +use tracing::info; + +use crate::APP_ID; +use aardvark_doc::identity::{IdentityError, PrivateKey}; + +const XDG_SCHEMA: &'static str = "xdg:schema"; + +fn attributes() -> HashMap<&'static str, String> { + HashMap::from([(XDG_SCHEMA, APP_ID.to_owned())]) +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("Secret Service error: {0}")] + Service(oo7::Error), + #[error("Format error: {0}")] + Format(IdentityError), +} + +impl From for Error { + fn from(value: IdentityError) -> Self { + Error::Format(value) + } +} + +impl From for Error { + fn from(value: oo7::Error) -> Self { + Error::Service(value) + } +} + +pub async fn get_or_create_identity() -> Result { + let keyring = oo7::Keyring::new().await?; + + keyring.unlock().await?; + + let private_key: PrivateKey = + if let Some(item) = keyring.search_items(&attributes()).await?.get(0) { + item.unlock().await?; + let private_key = PrivateKey::try_from(item.secret().await?.as_bytes())?; + info!("Found existing identity: {}", private_key.public_key()); + + private_key + } else { + let private_key = PrivateKey::new(); + keyring + .create_item("Aardvark", &attributes(), private_key.as_bytes(), true) + .await?; + + info!( + "No existing identity found. Create new identity: {}", + private_key.public_key() + ); + private_key + }; + + Ok(private_key) +} diff --git a/aardvark-app/src/style.css b/aardvark-app/src/style.css index 9a9d3ed..e891fba 100644 --- a/aardvark-app/src/style.css +++ b/aardvark-app/src/style.css @@ -42,3 +42,20 @@ font-weight: 700; font-size: 9px; } +.open-popover contents { + padding: 0px; + min-width: 300px; +} + +.open-popover .search { + margin: 6px; +} + +.open-popover row { + border-radius: 6px; + margin: 6px; +} + +.open-popover .open-document { + margin: 12px; +} diff --git a/aardvark-app/src/textbuffer.rs b/aardvark-app/src/textbuffer.rs index 33782d3..b7bdadc 100644 --- a/aardvark-app/src/textbuffer.rs +++ b/aardvark-app/src/textbuffer.rs @@ -97,6 +97,9 @@ mod imp { move |values| { let pos: i32 = values.get(1).unwrap().get().unwrap(); let text: &str = values.get(2).unwrap().get().unwrap(); + if buffer.inhibit_text_change() { + return None; + } let mut pos_iter = buffer.iter_at_offset(pos); buffer.set_inhibit_text_change(true); @@ -119,6 +122,10 @@ mod imp { move |values| { let start: i32 = values.get(1).unwrap().get().unwrap(); let end: i32 = values.get(2).unwrap().get().unwrap(); + if buffer.inhibit_text_change() { + return None; + } + let mut start = buffer.iter_at_offset(start); let mut end = buffer.iter_at_offset(end); buffer.set_inhibit_text_change(true); @@ -136,34 +143,49 @@ mod imp { impl TextBufferImpl for AardvarkTextBuffer { fn insert_text(&self, iter: &mut gtk::TextIter, new_text: &str) { + if self.obj().inhibit_text_change() { + self.parent_insert_text(iter, new_text); + return; + } + let Some(document) = self.obj().document() else { + self.parent_insert_text(iter, new_text); + return; + }; + let offset = iter.offset(); + self.obj().set_inhibit_text_change(true); + let result = document.insert_text(offset, new_text); + self.obj().set_inhibit_text_change(false); - if !self.inhibit_text_change.get() { - if let Some(document) = self.document.borrow().as_ref() { - if let Err(error) = document.insert_text(offset, new_text) { - error!("Failed to submit changes to the document: {error}"); - } - } + // Only insert text into the buffer when the document was successfully updated + if let Err(error) = result { + error!("Failed to submit changes to the document: {error}"); } else { - // Only insert text received from the CRDT document info!("inserting new text {} at pos {}", new_text, offset); - self.parent_insert_text(iter, new_text); } } fn delete_range(&self, start: &mut gtk::TextIter, end: &mut gtk::TextIter) { + if self.obj().inhibit_text_change() { + self.parent_delete_range(start, end); + return; + } + let Some(document) = self.obj().document() else { + self.parent_delete_range(start, end); + return; + }; + let offset_start = start.offset(); let offset_end = end.offset(); + self.obj().set_inhibit_text_change(true); + let result = document.delete_range(offset_start, offset_end); + self.obj().set_inhibit_text_change(false); - if !self.inhibit_text_change.get() { - if let Some(document) = self.document.borrow().as_ref() { - if let Err(error) = document.delete_range(offset_start, offset_end) { - error!("Failed to submit changes to the document: {error}") - } - } + // Only delete text from the buffer when the document was successfully updated + if let Err(error) = result { + error!("Failed to submit changes to the document: {error}"); } else { - // Only delete text received from the CRDT document info!( "deleting range at start {} end {}", offset_start, offset_end @@ -187,6 +209,10 @@ impl AardvarkTextBuffer { glib::Object::builder().build() } + fn inhibit_text_change(&self) -> bool { + self.imp().inhibit_text_change.get() + } + fn set_inhibit_text_change(&self, inhibit_text_change: bool) { self.imp().inhibit_text_change.set(inhibit_text_change); } diff --git a/aardvark-app/src/ui-resources.gresource.xml b/aardvark-app/src/ui-resources.gresource.xml index aa0365b..8a9d39d 100644 --- a/aardvark-app/src/ui-resources.gresource.xml +++ b/aardvark-app/src/ui-resources.gresource.xml @@ -2,6 +2,7 @@ open_dialog/open_dialog.ui + open_popover/open_popover.ui window.ui components/zoom_level_selector.ui gtk/help-overlay.ui diff --git a/aardvark-app/src/window.rs b/aardvark-app/src/window.rs index f91d833..c9e86d6 100644 --- a/aardvark-app/src/window.rs +++ b/aardvark-app/src/window.rs @@ -24,16 +24,13 @@ use aardvark_doc::{ document::{Document, DocumentId}, service::Service, }; -use adw::prelude::AdwDialogExt; -use adw::subclass::prelude::*; -use gtk::prelude::*; + +use adw::{prelude::*, subclass::prelude::*}; use gtk::{gdk, gio, glib, glib::clone}; -use sourceview::*; use crate::{ - AardvarkApplication, AardvarkTextBuffer, ConnectionPopover, + AardvarkApplication, AardvarkTextBuffer, ConnectionPopover, OpenPopover, components::{MultilineEntry, ZoomLevelSelector}, - open_dialog::OpenDialog, }; const BASE_TEXT_FONT_SIZE: f64 = 24.0; @@ -49,7 +46,9 @@ mod imp { #[template_child] pub text_view: TemplateChild, #[template_child] - pub open_dialog_document_button: TemplateChild, + pub open_popover_button: TemplateChild, + #[template_child] + pub open_popover: TemplateChild, #[template_child] pub toast_overlay: TemplateChild, #[template_child] @@ -83,6 +82,7 @@ mod imp { fn class_init(klass: &mut Self::Class) { ZoomLevelSelector::static_type(); MultilineEntry::static_type(); + OpenPopover::static_type(); klass.bind_template(); @@ -151,7 +151,7 @@ mod imp { self.font_size.set(BASE_TEXT_FONT_SIZE); self.obj().set_font_scale(0.0); gtk::style_context_add_provider_for_display( - &self.obj().display(), + >k::Widget::display(self.obj().upcast_ref()), &self.css_provider, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, ); @@ -159,64 +159,63 @@ mod imp { let scroll_controller = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL); scroll_controller.set_propagation_phase(gtk::PropagationPhase::Capture); - let window = self.obj().clone(); - scroll_controller.connect_scroll(move |scroll, _dx, dy| { - if scroll - .current_event_state() - .contains(gdk::ModifierType::CONTROL_MASK) - { - if dy < 0.0 { - window.set_font_scale(window.font_scale() + 1.0); + let window = self.obj(); + scroll_controller.connect_scroll(clone!( + #[weak] + window, + #[upgrade_or] + glib::Propagation::Stop, + move |scroll, _dx, dy| { + if scroll + .current_event_state() + .contains(gdk::ModifierType::CONTROL_MASK) + { + if dy < 0.0 { + window.set_font_scale(window.font_scale() + 1.0); + } else { + window.set_font_scale(window.font_scale() - 1.0); + } + glib::Propagation::Stop } else { - window.set_font_scale(window.font_scale() - 1.0); + glib::Propagation::Proceed } - glib::Propagation::Stop - } else { - glib::Propagation::Proceed } - }); + )); self.obj().add_controller(scroll_controller); let zoom_gesture = gtk::GestureZoom::new(); - let window = self.obj().clone(); let prev_delta = Cell::new(0.0); - zoom_gesture.connect_scale_changed(move |_, delta| { - if prev_delta.get() == delta { - return; - } + zoom_gesture.connect_scale_changed(clone!( + #[weak] + window, + move |_, delta| { + if prev_delta.get() == delta { + return; + } - if prev_delta.get() < delta { - window.set_font_scale(window.font_scale() + delta); - } else { - window.set_font_scale(window.font_scale() - delta); + if prev_delta.get() < delta { + window.set_font_scale(window.font_scale() + delta); + } else { + window.set_font_scale(window.font_scale() - delta); + } + prev_delta.set(delta); } - prev_delta.set(delta); - }); + )); self.obj().add_controller(zoom_gesture); - let window = self.obj(); - self.open_dialog_document_button.connect_clicked(clone!( - #[weak] - window, - move |_| { - let dialog = OpenDialog::new(); - - dialog.present(Some(&window)); - - dialog.connect_open(clone!( - #[weak] - window, - move |_, document_id| { - let app = AardvarkApplication::default(); - - if let Some(window) = app.window_for_document_id(document_id) { - window.present(); - } else { - let document = Document::new(&window.service(), Some(document_id)); - window.imp().set_document(document); - } - } - )); + self.open_popover + .set_model(self.obj().service().documents()); + + self.open_popover.connect_document_activated(clone!( + #[weak(rename_to = this)] + self, + move |_, document| { + let app = AardvarkApplication::default(); + if let Some(window) = app.window_for_document_id(&document.id()) { + window.present(); + } else { + this.set_document(document.to_owned()); + } } )); @@ -233,6 +232,11 @@ mod imp { let document = Document::new(self.service.get().unwrap(), None); self.set_document(document); + + self.obj().connect_close_request(|window| { + window.document().set_subscribed(false); + glib::Propagation::Proceed + }); } } @@ -273,7 +277,13 @@ mod imp { )); self.connection_button_label .set_label(&format!("{}", authors.n_items())); - self.document.replace(Some(document)); + + document.set_subscribed(true); + let old_document = self.document.replace(Some(document)); + + if let Some(old_document) = old_document { + old_document.set_subscribed(false); + } self.obj().notify("document"); } @@ -305,7 +315,7 @@ mod imp { glib::wrapper! { pub struct AardvarkWindow(ObjectSubclass) @extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow, - @implements gio::ActionGroup, gio::ActionMap; + @implements gtk::Native, gtk::Root, gio::ActionGroup, gio::ActionMap; } impl AardvarkWindow { diff --git a/aardvark-app/src/window.ui b/aardvark-app/src/window.ui index 7828cf1..b777ad2 100644 --- a/aardvark-app/src/window.ui +++ b/aardvark-app/src/window.ui @@ -39,14 +39,28 @@ - + - - document-open-symbolic - _Open - True + + + + _Open + True + + + + + down-smaller-symbolic + + + + + + @@ -199,3 +213,4 @@ + diff --git a/aardvark-doc/Cargo.toml b/aardvark-doc/Cargo.toml index 98ea423..b229f1f 100644 --- a/aardvark-doc/Cargo.toml +++ b/aardvark-doc/Cargo.toml @@ -13,8 +13,7 @@ anyhow = "1.0.94" async-channel = "2.3.1" glib = "0.20" gio = "0.20" -loro = "1.3.1" -p2panda-core = { git = "https://github.com/p2panda/p2panda", rev = "f3a016324b69beac45cf20a792fe6890cb1a21e3", default-features = false } +loro = "1.5" +p2panda-core = { git = "https://github.com/p2panda/p2panda", rev = "085a57206aeae70142176c0777ed2febc7b98664", default-features = false } thiserror = "2.0.11" -tokio = { version = "1.42.0", features = ["macros", "test-util"] } tracing = "0.1" \ No newline at end of file diff --git a/aardvark-doc/src/author.rs b/aardvark-doc/src/author.rs index 0a19a10..ee2d91c 100644 --- a/aardvark-doc/src/author.rs +++ b/aardvark-doc/src/author.rs @@ -1,10 +1,11 @@ -use std::cell::{Cell, OnceCell}; use std::sync::Mutex; +use std::{cell::Cell, sync::OnceLock}; use glib::Properties; use glib::prelude::*; use glib::subclass::prelude::*; -use p2panda_core::PublicKey; + +use crate::identity::PublicKey; pub const COLORS: [(&str, &str); 15] = [ ("Yellow", "#faf387"), @@ -77,8 +78,9 @@ mod imp { #[property(name = "name", get = Self::name, type = String)] #[property(name = "emoji", get = Self::emoji, type = String)] #[property(name = "color", get = Self::color, type = String)] - pub public_key: OnceCell, - #[property(get)] + #[property(get, set, construct_only, type = PublicKey)] + public_key: OnceLock, + #[property(get, set, construct_only)] pub last_seen: Mutex>, #[property(get, default = true)] pub is_online: Cell, @@ -133,15 +135,23 @@ glib::wrapper! { pub struct Author(ObjectSubclass); } impl Author { - pub fn new(public_key: PublicKey) -> Self { - let obj: Self = glib::Object::new(); + pub fn new(public_key: &PublicKey) -> Self { + let obj: Self = glib::Object::builder() + .property("public-key", public_key) + .build(); - obj.imp().public_key.set(public_key).unwrap(); obj.imp().is_online.set(true); obj } - pub fn for_this_device(public_key: PublicKey) -> Self { + pub(crate) fn with_state(public_key: &PublicKey, last_seen: Option<&glib::DateTime>) -> Self { + glib::Object::builder() + .property("public-key", public_key) + .property("last-seen", last_seen) + .build() + } + + pub fn for_this_device(public_key: &PublicKey) -> Self { let obj = Self::new(public_key); obj.imp().is_this_device.set(true); @@ -149,10 +159,6 @@ impl Author { obj } - pub(crate) fn public_key(&self) -> &PublicKey { - self.imp().public_key.get().unwrap() - } - pub(crate) fn set_is_online(&self, is_online: bool) { let was_online = self.imp().is_online.get(); self.imp().is_online.set(is_online); diff --git a/aardvark-doc/src/authors.rs b/aardvark-doc/src/authors.rs index 9f224e3..934e6c5 100644 --- a/aardvark-doc/src/authors.rs +++ b/aardvark-doc/src/authors.rs @@ -1,11 +1,11 @@ -use p2panda_core::PublicKey; use std::sync::Mutex; use gio::prelude::*; use gio::subclass::prelude::ListModelImpl; -use glib::{clone, subclass::prelude::*}; +use glib::subclass::prelude::*; use crate::author::Author; +use crate::identity::PublicKey; mod imp { use super::*; @@ -63,57 +63,51 @@ impl Authors { glib::Object::new() } + pub(crate) fn from_vec(authors: Vec) -> Self { + let obj: Self = glib::Object::new(); + *obj.imp().list.lock().unwrap() = authors; + obj + } + pub(crate) fn add_this_device(&self, author_key: PublicKey) { - glib::source::idle_add_full( - glib::source::Priority::DEFAULT, - clone!( - #[weak(rename_to = obj)] - self, - #[upgrade_or] - glib::ControlFlow::Break, - move || { - let mut list = obj.imp().list.lock().unwrap(); - let pos = list.len() as u32; - - let author = Author::for_this_device(author_key); - list.push(author); - drop(list); - obj.items_changed(pos, 0, 1); - glib::ControlFlow::Break - } - ), - ); + let mut list = self.imp().list.lock().unwrap(); + let pos = list.len() as u32; + + let author = Author::for_this_device(&author_key); + list.push(author); + drop(list); + self.items_changed(pos, 0, 1); + } + + pub(crate) fn ensure_author(&self, author_key: PublicKey) { + let mut list = self.imp().list.lock().unwrap(); + + if !list.iter().any(|author| author.public_key() == author_key) { + let pos = list.len() as u32; + + let author = Author::new(&author_key); + + list.push(author); + drop(list); + + self.items_changed(pos, 0, 1); + } } pub(crate) fn add_or_update(&self, author_key: PublicKey, is_online: bool) { - glib::source::idle_add_full( - glib::source::Priority::DEFAULT, - clone!( - #[weak(rename_to = obj)] - self, - #[upgrade_or] - glib::ControlFlow::Break, - move || { - let mut list = obj.imp().list.lock().unwrap(); - - if let Some(author) = list - .iter() - .find(|author| author.public_key() == &author_key) - { - author.set_is_online(is_online); - } else { - let pos = list.len() as u32; - - let author = Author::new(author_key); - - list.push(author); - drop(list); - - obj.items_changed(pos, 0, 1); - } - glib::ControlFlow::Break - } - ), - ); + let mut list = self.imp().list.lock().unwrap(); + + if let Some(author) = list.iter().find(|author| author.public_key() == author_key) { + author.set_is_online(is_online); + } else { + let pos = list.len() as u32; + + let author = Author::new(&author_key); + + list.push(author); + drop(list); + + self.items_changed(pos, 0, 1); + } } } diff --git a/aardvark-doc/src/document.rs b/aardvark-doc/src/document.rs index 9bca38c..33d9fd3 100644 --- a/aardvark-doc/src/document.rs +++ b/aardvark-doc/src/document.rs @@ -1,24 +1,23 @@ -use std::cell::{Cell, OnceCell}; use std::fmt; use std::str::FromStr; -use std::sync::Arc; -use std::sync::OnceLock; use aardvark_node::document::{DocumentId as DocumentIdNode, SubscribableDocument}; use anyhow::Result; +use gio::prelude::ApplicationExtManual; use glib::prelude::*; use glib::subclass::{Signal, prelude::*}; use glib::{Properties, clone}; -use loro::{ExportMode, LoroDoc, event::Diff}; -use p2panda_core::{HashError, PublicKey}; +use loro::{ExportMode, LoroDoc, LoroText, event::Diff}; +use p2panda_core::HashError; use tracing::error; use crate::authors::Authors; +use crate::identity::PublicKey; use crate::service::Service; #[derive(Clone, Debug, PartialEq, Eq, glib::Boxed)] #[boxed_type(name = "AardvarkDocumentId", nullable)] -pub struct DocumentId(DocumentIdNode); +pub struct DocumentId(pub(crate) DocumentIdNode); impl FromStr for DocumentId { type Err = HashError; @@ -35,26 +34,36 @@ impl fmt::Display for DocumentId { } mod imp { + use super::*; + use std::cell::{Cell, OnceCell}; + use std::sync::{Arc, Mutex, OnceLock}; + use std::time::Duration; + /// Identifier of container where we handle the text CRDT in a Loro document. /// /// Loro documents can contain multiple different CRDT types in one document. const TEXT_CONTAINER_ID: &str = "document"; - - use super::*; + const DOCUMENT_NAME_LENGTH: usize = 32; + const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Properties, Default)] #[properties(wrapper_type = super::Document)] pub struct Document { + #[property(get, construct_only)] + name: Mutex>, + #[property(get, construct_only, set)] + last_accessed: Mutex>, #[property(name = "text", get = Self::text, type = String)] - crdt_doc: OnceCell, + pub(super) crdt_doc: OnceCell, #[property(get, construct_only, set = Self::set_id)] id: OnceCell, - #[property(get, set)] - ready: Cell, + #[property(get, set = Self::set_subscribed)] + subscribed: Cell, #[property(get, construct_only)] service: OnceCell, - #[property(get)] - authors: Authors, + #[property(get, set = Self::set_authors, construct_only)] + authors: OnceCell, + snapshot_task: Mutex>, } #[glib::object_subclass] @@ -63,7 +72,39 @@ mod imp { type Type = super::Document; } + fn extract_name(crdt_text: LoroText) -> Option { + if crdt_text.is_empty() { + return None; + } + + let mut name = String::with_capacity(DOCUMENT_NAME_LENGTH); + crdt_text.iter(|slice| { + for char in slice.chars() { + if char == '\n' { + // Only use the first line as name for the document + return false; + } else if char.is_whitespace() || char.is_alphanumeric() { + name.push(char); + } + } + + name.len() < DOCUMENT_NAME_LENGTH + }); + + if name.trim().len() > 0 { + Some(name) + } else { + None + } + } + impl Document { + fn set_authors(&self, authors: Option) { + if let Some(authors) = authors { + self.authors.set(authors).unwrap(); + } + } + pub fn text(&self) -> String { self.crdt_doc .get() @@ -72,76 +113,172 @@ mod imp { .to_string() } + fn update_name(&self) { + let crdt_text = self + .crdt_doc + .get() + .expect("crdt_doc to be set") + .get_text(TEXT_CONTAINER_ID); + + let name = extract_name(crdt_text); + + if name == self.obj().name() { + return; + } + + *self.name.lock().unwrap() = name.clone(); + self.obj().notify_name(); + + let obj = self.obj(); + glib::spawn_future(clone!( + #[weak] + obj, + async move { + let document_id = obj.id().0; + if let Err(error) = obj + .service() + .node() + .set_name_for_document(&document_id, name) + .await + { + error!( + "Failed to update name for document {}: {}", + document_id, error + ); + } + } + )); + } + fn set_id(&self, id: Option) { if let Some(id) = id { self.id.set(id).expect("Document id can only be set once"); } } - pub fn splice_text(&self, index: i32, delete_len: i32, chunk: &str) -> Result<()> { + pub fn insert_text(&self, index: usize, chunk: &str) -> Result<()> { let doc = self.crdt_doc.get().expect("crdt_doc to be set"); let text = doc.get_text(TEXT_CONTAINER_ID); - if delete_len == 0 { - text.insert(index as usize, chunk) - .expect("update document after text insertion"); - } else { - text.delete(index as usize, delete_len as usize) - .expect("update document after text removal"); - } + text.insert(index, chunk)?; + doc.commit(); + + Ok(()) + } + + pub fn delete_text(&self, index: usize, len: usize) -> Result<()> { + let doc = self.crdt_doc.get().expect("crdt_doc to be set"); + let text = doc.get_text(TEXT_CONTAINER_ID); + text.delete(index, len)?; doc.commit(); Ok(()) } /// Apply changes to the CRDT from a message received from another peer - pub fn on_remote_message(&self, bytes: &[u8]) { + pub fn on_remote_message(&self, bytes: Vec) { let doc = self.crdt_doc.get().expect("crdt_doc to be set"); - if let Err(err) = doc.import_with(bytes, "delta") { + if let Err(err) = doc.import_with(&bytes, "delta") { eprintln!("received invalid message: {}", err); } } - fn emit_text_inserted(&self, pos: i32, text: String) { - // Emit the signal on the main thread - let obj = self.obj(); - glib::source::idle_add_full( - glib::source::Priority::DEFAULT, - clone!( + pub fn set_subscribed(&self, subscribed: bool) { + if self.obj().subscribed() == subscribed { + return; + } + + self.subscribed.set(subscribed); + + if subscribed { + *self.last_accessed.lock().unwrap() = None; + + let obj = self.obj(); + glib::spawn_future(clone!( #[weak] obj, - #[upgrade_or] - glib::ControlFlow::Break, - move || { - obj.emit_by_name::<()>("text-inserted", &[&pos, &text]); - glib::ControlFlow::Break + async move { + let document_id = obj.id().0; + let handle = DocumentHandle(obj.downgrade()); + if let Err(error) = + obj.service().node().subscribe(document_id, handle).await + { + error!("Failed to subscribe to document: {}", error); + obj.imp().set_subscribed(false); + } } - ), - ); + )); + } else { + *self.last_accessed.lock().unwrap() = glib::DateTime::now_utc().ok(); + + let obj = self.obj(); + // Keep the application alive till we completed the unsubscription task + let guard = gio::Application::default().and_then(|app| Some(app.hold())); + // Keep a strong reference to the document to ensure the document lives long enough + glib::spawn_future_local(clone!( + #[strong] + obj, + async move { + let document_id = obj.id().0; + if let Err(error) = obj.service().node().unsubscribe(&document_id).await { + error!("Failed to unsubscribe document {}: {}", document_id, error); + } + drop(guard); + } + )); + } + self.obj().notify_last_accessed(); + self.obj().notify_subscribed(); + } + + fn emit_text_inserted(&self, pos: i32, text: String) { + if pos <= DOCUMENT_NAME_LENGTH as i32 { + self.update_name(); + } + + self.obj() + .emit_by_name::<()>("text-inserted", &[&pos, &text]); } fn emit_range_deleted(&self, start: i32, end: i32) { - // Emit the signal on the main thread - let obj = self.obj(); - glib::source::idle_add_full( - glib::source::Priority::DEFAULT, - clone!( - #[weak] - obj, - #[upgrade_or] - glib::ControlFlow::Break, - move || { - obj.emit_by_name::<()>("range-deleted", &[&start, &end]); - glib::ControlFlow::Break - } - ), - ); + if start <= DOCUMENT_NAME_LENGTH as i32 || end <= DOCUMENT_NAME_LENGTH as i32 { + self.update_name(); + } + + self.obj() + .emit_by_name::<()>("range-deleted", &[&start, &end]); + } + + fn mark_for_snapshot(&self) { + let mut snapshot_task = self.snapshot_task.lock().unwrap(); + if snapshot_task.is_none() { + let obj = self.obj(); + let ctx = glib::MainContext::ref_thread_default(); + let handle = ctx.spawn_with_priority( + glib::source::Priority::LOW, + clone!( + #[weak] + obj, + async move { + glib::timeout_future_with_priority( + glib::source::Priority::LOW, + SNAPSHOT_TIMEOUT, + ) + .await; + obj.store_snapshot().await; + obj.imp().snapshot_task.lock().unwrap().take(); + } + ), + ); + + *snapshot_task = handle.into_source_id().ok(); + } } fn setup_loro_document(&self) { - let public_key = self.obj().service().public_key(); + let public_key = self.obj().service().private_key().public_key(); let obj = self.obj(); let doc = LoroDoc::new(); // The peer id represents the identity of the author applying local changes (that's @@ -155,7 +292,7 @@ mod imp { // this should not really be a problem, but it would be nice if the Loro API would // change some day. let mut buf = [0u8; 8]; - buf[..8].copy_from_slice(&public_key.as_bytes()[..8]); + buf[..8].copy_from_slice(&public_key.0.as_bytes()[..8]); u64::from_be_bytes(buf) }) .expect("set peer id for new document"); @@ -217,32 +354,15 @@ mod imp { false, move |delta_bytes| { let delta_bytes = delta_bytes.to_vec(); + obj.imp().mark_for_snapshot(); // Move a strong reference to the Document into the spawn, // to ensure changes are always propagated to the network glib::spawn_future(async move { - // Broadcast a "text delta" to all peers and persist the snapshot. - // - // TODO(adz): We should consider persisting the snapshot every x - // times or x seconds, not sure yet what logic makes the most - // sense. - let snapshot_bytes = obj - .imp() - .crdt_doc - .get() - .expect("crdt_doc to be set") - .export(ExportMode::Snapshot) - .expect("encoded crdt snapshot"); - - if let Err(error) = obj - .service() - .node() - .delta_with_snapshot(obj.id().0, delta_bytes, snapshot_bytes) - .await + // Broadcast a "text delta" to all peers + if let Err(error) = + obj.service().node().delta(obj.id().0, delta_bytes).await { - error!( - "Failed to send snapshot of document to the network: {}", - error - ); + error!("Failed to send delta of document to the network: {}", error); } }); @@ -270,38 +390,36 @@ mod imp { ] }) } + fn dispose(&self) { + self.set_subscribed(false); + } fn constructed(&self) { self.parent_constructed(); if self.id.get().is_none() { - let document_id = self - .obj() - .service() - .node() - .create_document() - .expect("Create document"); + let document_id = glib::MainContext::new().block_on(async move { + self.obj() + .service() + .node() + .create_document() + .await + .expect("Create document") + }); self.set_id(Some(DocumentId(document_id))); } self.setup_loro_document(); - let obj = self.obj(); - glib::spawn_future(clone!( - #[weak] - obj, - async move { - let document_id = obj.id().0; - let handle = DocumentHandle(obj.downgrade()); - if let Err(error) = obj.service().node().subscribe(document_id, handle).await { - error!("Failed to subscribe to document: {}", error); - } - } - )); + self.authors.get_or_init(|| { + let authors = Authors::new(); + + // Add ourself to the list of authors + authors.add_this_device(self.obj().service().private_key().public_key()); + authors + }); - // Add ourself to the list of authors - self.authors - .add_this_device(self.obj().service().public_key()); + self.obj().service().documents().add(self.obj().clone()); } } } @@ -317,12 +435,52 @@ impl Document { .build() } - pub fn insert_text(&self, index: i32, chunk: &str) -> Result<()> { - self.imp().splice_text(index, 0, chunk) + pub(crate) fn with_state( + service: &Service, + id: Option<&DocumentId>, + name: Option<&str>, + last_accessed: Option<&glib::DateTime>, + authors: &Authors, + ) -> Self { + glib::Object::builder() + .property("service", service) + .property("id", id) + .property("authors", authors) + .property("name", name) + .property("last-accessed", last_accessed) + .build() + } + + pub fn insert_text(&self, pos: i32, text: &str) -> Result<()> { + self.imp().insert_text(pos as usize, text) } - pub fn delete_range(&self, index: i32, end: i32) -> Result<()> { - self.imp().splice_text(index, end - index, "") + pub fn delete_range(&self, start_pos: i32, end_pos: i32) -> Result<()> { + self.imp() + .delete_text(start_pos as usize, (end_pos - start_pos) as usize) + } + + /// Persist the snapshot. + pub(crate) async fn store_snapshot(&self) { + // FIXME: only store a new snapshot if it changed since the previous snapshot + let snapshot_bytes = self + .imp() + .crdt_doc + .get() + .expect("crdt_doc to be set") + .export(ExportMode::Snapshot) + .expect("encoded crdt snapshot"); + if let Err(error) = self + .service() + .node() + .snapshot(self.id().0, snapshot_bytes) + .await + { + error!( + "Failed to send snapshot of document to the network: {}", + error + ); + } } } @@ -332,23 +490,35 @@ unsafe impl Sync for Document {} struct DocumentHandle(glib::WeakRef); impl SubscribableDocument for DocumentHandle { - fn bytes_received(&self, _author: PublicKey, data: &[u8]) { + fn bytes_received(&self, author: p2panda_core::PublicKey, data: Vec) { if let Some(document) = self.0.upgrade() { - document.imp().on_remote_message(data); + let context = glib::MainContext::ref_thread_default(); + context.invoke(move || { + document.imp().on_remote_message(data); + document.authors().ensure_author(PublicKey(author)); + }); } } - fn authors_joined(&self, authors: Vec) { + fn authors_joined(&self, authors: Vec) { if let Some(document) = self.0.upgrade() { - for author in authors.into_iter() { - document.authors().add_or_update(author, true); - } + let context = glib::MainContext::ref_thread_default(); + context.invoke(move || { + for author in authors.into_iter() { + document.authors().add_or_update(PublicKey(author), true); + } + }); } } - fn author_set_online(&self, author: PublicKey, is_online: bool) { + fn author_set_online(&self, author: p2panda_core::PublicKey, is_online: bool) { if let Some(document) = self.0.upgrade() { - document.authors().add_or_update(author, is_online); + let context = glib::MainContext::ref_thread_default(); + context.invoke(move || { + document + .authors() + .add_or_update(PublicKey(author), is_online); + }); } } } diff --git a/aardvark-doc/src/documents.rs b/aardvark-doc/src/documents.rs new file mode 100644 index 0000000..3619bfc --- /dev/null +++ b/aardvark-doc/src/documents.rs @@ -0,0 +1,81 @@ +use std::sync::Mutex; + +use gio::prelude::*; +use gio::subclass::prelude::ListModelImpl; +use glib::subclass::prelude::*; + +use crate::document::{Document, DocumentId}; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct Documents { + pub(super) list: Mutex>, + } + + #[glib::object_subclass] + impl ObjectSubclass for Documents { + const NAME: &'static str = "Documents"; + type Type = super::Documents; + type Interfaces = (gio::ListModel,); + } + + impl ObjectImpl for Documents {} + + impl ListModelImpl for Documents { + fn item_type(&self) -> glib::Type { + Document::static_type() + } + + fn n_items(&self) -> u32 { + self.list.lock().unwrap().len() as u32 + } + + fn item(&self, index: u32) -> Option { + self.list + .lock() + .unwrap() + .get(index as usize) + .cloned() + .map(Cast::upcast) + } + } +} + +glib::wrapper! { + pub struct Documents(ObjectSubclass) + @implements gio::ListModel; +} + +unsafe impl Send for Documents {} +unsafe impl Sync for Documents {} + +impl Default for Documents { + fn default() -> Self { + Self::new() + } +} + +impl Documents { + pub fn new() -> Self { + glib::Object::new() + } + + pub(crate) fn add(&self, document: Document) { + let mut list = self.imp().list.lock().unwrap(); + + // FIXME: Inserting a new document at the top of the list is quite inefficient + list.insert(0, document.clone()); + drop(list); + self.items_changed(0, 0, 1); + } + + pub fn by_id(&self, document_id: &DocumentId) -> Option { + let list = self.imp().list.lock().unwrap(); + + list.iter() + .find(|document| &document.id() == document_id) + .cloned() + } +} diff --git a/aardvark-doc/src/lib.rs b/aardvark-doc/src/lib.rs index f2d9978..1341c8e 100644 --- a/aardvark-doc/src/lib.rs +++ b/aardvark-doc/src/lib.rs @@ -1,21 +1,124 @@ pub mod author; pub mod authors; pub mod document; +pub mod documents; pub mod service; +pub mod identity { + pub use p2panda_core::identity::IdentityError; + use std::fmt; + + #[derive(Clone, Debug, glib::Boxed)] + #[boxed_type(name = "AardvarkPrivateKey", nullable)] + pub struct PrivateKey(pub(crate) p2panda_core::PrivateKey); + + impl PrivateKey { + pub fn new() -> PrivateKey { + PrivateKey(p2panda_core::PrivateKey::new()) + } + + pub fn public_key(&self) -> PublicKey { + PublicKey(self.0.public_key()) + } + + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes().as_slice() + } + } + + impl TryFrom<&[u8]> for PrivateKey { + type Error = p2panda_core::IdentityError; + + fn try_from(value: &[u8]) -> Result { + Ok(PrivateKey(p2panda_core::PrivateKey::try_from(value)?)) + } + } + + impl<'a> From<&'a PrivateKey> for &'a [u8] { + fn from(value: &PrivateKey) -> &[u8] { + value.0.as_bytes().as_slice() + } + } + + impl fmt::Display for PrivateKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } + } + + #[derive(Clone, Debug, PartialEq, glib::Boxed)] + #[boxed_type(name = "AardvarkPublicKey", nullable)] + pub struct PublicKey(pub(crate) p2panda_core::PublicKey); + + impl fmt::Display for PublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } + } + + impl<'a> From<&'a PublicKey> for &'a [u8] { + fn from(value: &PublicKey) -> &[u8] { + value.0.as_bytes().as_slice() + } + } + + impl PublicKey { + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes().as_slice() + } + } +} + #[cfg(test)] mod tests { use crate::document::Document; + use crate::identity::PrivateKey; use crate::service::Service; + use gio::prelude::FileExt; use glib::object::ObjectExt; + use std::fs; + + struct TestResource { + service: Service, + } + + impl Drop for TestResource { + fn drop(&mut self) { + fs::remove_dir_all(self.service.data_dir().path().unwrap()) + .expect("Able to remove data dir"); + } + } + + impl TestResource { + /// Creates a new `TestResource` that includes the `Service` + fn new() -> TestResource { + let private_key = PrivateKey::new(); + let mut data_path = glib::tmp_dir(); + data_path.push("Aardvark"); + data_path.push(private_key.public_key().to_string()); + fs::create_dir_all(&data_path).expect("Able to create data dir"); + let data_dir = gio::File::for_path(data_path); + + TestResource { + service: Service::new(&private_key, &data_dir), + } + } + + fn service(&self) -> Service { + self.service.clone() + } + } #[test] fn create_document() { let context = glib::MainContext::default(); - let service = Service::new(); + + let resource = TestResource::new(); + let service = resource.service(); let test_string = "Hello World"; service.startup(); let document = Document::new(&service, None); + document.set_subscribed(true); context.iteration(false); assert!(document.insert_text(0, test_string).is_ok()); assert_eq!(document.text(), test_string); @@ -25,15 +128,19 @@ mod tests { fn basic_sync() { let main_loop = glib::MainLoop::new(None, false); let test_string = "Hello World"; - let service = Service::new(); + let resource = TestResource::new(); + let service = resource.service(); service.startup(); let document = Document::new(&service, None); + document.set_subscribed(true); let id = document.id(); - let service2 = Service::new(); + let resource2 = TestResource::new(); + let service2 = resource2.service(); service2.startup(); let document2 = Document::new(&service2, Some(&id)); + document2.set_subscribed(true); assert_eq!(document.id(), document2.id()); main_loop.context().spawn(async move { @@ -57,15 +164,19 @@ mod tests { fn sync_multiple_changes() { let main_loop = glib::MainLoop::new(None, false); let expected_string = "Hello, World!"; - let service = Service::new(); + let resource = TestResource::new(); + let service = resource.service(); service.startup(); let document = Document::new(&service, None); + document.set_subscribed(true); let id = document.id(); - let service2 = Service::new(); + let resource2 = TestResource::new(); + let service2 = resource2.service(); service2.startup(); let document2 = Document::new(&service2, Some(&id)); + document2.set_subscribed(true); assert_eq!(document.id(), document2.id()); main_loop.context().spawn(async move { @@ -99,15 +210,19 @@ mod tests { "{}{}{}{}", test_string, test_string, test_string, test_string ); - let service = Service::new(); + let resource = TestResource::new(); + let service = resource.service(); service.startup(); let document = Document::new(&service, None); + document.set_subscribed(true); let id = document.id(); - let service2 = Service::new(); + let resource2 = TestResource::new(); + let service2 = resource2.service(); service2.startup(); let document2 = Document::new(&service2, Some(&id)); + document2.set_subscribed(true); assert_eq!(document.id(), document2.id()); main_loop.context().spawn(async move { diff --git a/aardvark-doc/src/service.rs b/aardvark-doc/src/service.rs index 300a853..30164e9 100644 --- a/aardvark-doc/src/service.rs +++ b/aardvark-doc/src/service.rs @@ -1,18 +1,36 @@ +use gio::prelude::FileExt; +use glib::Properties; +use glib::object::ObjectExt; use glib::subclass::prelude::*; -use p2panda_core::{Hash, PrivateKey, PublicKey}; -use tracing::info; +use p2panda_core::Hash; +use std::sync::OnceLock; +use tracing::error; +use crate::identity::{PrivateKey, PublicKey}; +use crate::{ + author::Author, + authors::Authors, + document::{Document, DocumentId}, + documents::Documents, +}; use aardvark_node::Node; mod imp { use super::*; - #[derive(Default)] + #[derive(Default, Properties)] + #[properties(wrapper_type = super::Service)] pub struct Service { pub node: Node, - pub private_key: PrivateKey, + #[property(get, set, construct_only, type = PrivateKey)] + pub private_key: OnceLock, + #[property(get, set, construct_only, type = gio::File)] + pub data_dir: OnceLock, + #[property(get)] + documents: Documents, } + #[glib::derived_properties] impl ObjectImpl for Service {} #[glib::object_subclass] @@ -27,33 +45,75 @@ glib::wrapper! { } impl Service { - pub fn new() -> Self { - glib::Object::new() + pub fn new(private_key: &PrivateKey, data_dir: &gio::File) -> Self { + glib::Object::builder() + .property("private-key", private_key) + .property("data-dir", data_dir) + .build() } pub fn startup(&self) { - let private_key = self.imp().private_key.clone(); - let network_id = b"aardvark <3"; - info!("my public key: {}", private_key.public_key()); + glib::MainContext::new().block_on(async move { + let private_key = self.private_key().0.clone(); + let public_key = private_key.public_key(); + let network_id = Hash::new(b"aardvark <3"); + let path = self.data_dir().path().expect("Valid file path"); + if let Err(error) = self + .imp() + .node + .run(private_key.clone(), network_id, Some(path.as_ref())) + .await + { + error!("Running node failed: {error}"); + } - self.imp().node.run(private_key, Hash::new(network_id)); + if let Ok(documents) = self.imp().node.documents().await { + for document in documents { + let last_accessed = document.last_accessed.and_then(|last_accessed| { + glib::DateTime::from_unix_utc(last_accessed.timestamp()).ok() + }); + + let authors: Vec = document + .authors + .iter() + .map(|author| { + if author.public_key == public_key { + Author::for_this_device(&PublicKey(author.public_key)) + } else { + let last_seen = author.last_seen.and_then(|last_seen| { + glib::DateTime::from_unix_utc(last_seen.timestamp()).ok() + }); + Author::with_state( + &PublicKey(author.public_key), + last_seen.as_ref(), + ) + } + }) + .collect(); + + let authors = Authors::from_vec(authors); + // The document is inserted automatically in the document list + let _document = Document::with_state( + self, + Some(&DocumentId(document.id)), + document.name.as_deref(), + last_accessed.as_ref(), + &authors, + ); + } + } + }); } pub fn shutdown(&self) { - self.imp().node.shutdown(); + glib::MainContext::new().block_on(async move { + if let Err(error) = self.imp().node.shutdown().await { + error!("Failed to shutdown service: {}", error); + } + }); } pub(crate) fn node(&self) -> &Node { &self.imp().node } - - pub(crate) fn public_key(&self) -> PublicKey { - self.imp().private_key.public_key() - } -} - -impl Default for Service { - fn default() -> Self { - Service::new() - } } diff --git a/aardvark-node/Cargo.toml b/aardvark-node/Cargo.toml index 63f3e5e..aaf625f 100644 --- a/aardvark-node/Cargo.toml +++ b/aardvark-node/Cargo.toml @@ -11,14 +11,16 @@ authors = [ [dependencies] anyhow = "1.0.94" async-trait = "0.1.83" +chrono = "0.4.40" ciborium = "0.2.2" -p2panda-core = { git = "https://github.com/p2panda/p2panda", rev = "f3a016324b69beac45cf20a792fe6890cb1a21e3" } -p2panda-discovery = { git = "https://github.com/p2panda/p2panda", rev = "f3a016324b69beac45cf20a792fe6890cb1a21e3", features = ["mdns"] } -p2panda-net = { git = "https://github.com/p2panda/p2panda", rev = "f3a016324b69beac45cf20a792fe6890cb1a21e3" } -p2panda-store = { git = "https://github.com/p2panda/p2panda", rev = "f3a016324b69beac45cf20a792fe6890cb1a21e3" } -p2panda-stream = { git = "https://github.com/p2panda/p2panda", rev = "f3a016324b69beac45cf20a792fe6890cb1a21e3" } -p2panda-sync = { git = "https://github.com/p2panda/p2panda", rev = "f3a016324b69beac45cf20a792fe6890cb1a21e3", features = ["log-sync"] } +p2panda-core = { git = "https://github.com/p2panda/p2panda", rev = "085a57206aeae70142176c0777ed2febc7b98664" } +p2panda-discovery = { git = "https://github.com/p2panda/p2panda", rev = "085a57206aeae70142176c0777ed2febc7b98664", features = ["mdns"] } +p2panda-net = { git = "https://github.com/p2panda/p2panda", rev = "085a57206aeae70142176c0777ed2febc7b98664" } +p2panda-store = { git = "https://github.com/p2panda/p2panda", rev = "085a57206aeae70142176c0777ed2febc7b98664", features = ["sqlite"], default-features = false} +p2panda-stream = { git = "https://github.com/p2panda/p2panda", rev = "085a57206aeae70142176c0777ed2febc7b98664" } +p2panda-sync = { git = "https://github.com/p2panda/p2panda", rev = "085a57206aeae70142176c0777ed2febc7b98664", features = ["log-sync"] } serde = { version = "1.0.215", features = ["derive"] } -tokio = { version = "1.42.0", features = ["rt", "sync"] } +sqlx = { version = "0.8.5", features = ["runtime-tokio", "sqlite", "chrono"], default-features = false} +tokio = { version = "1.44.2", features = ["rt", "sync"] } tokio-stream = "0.1.17" tracing = "0.1" diff --git a/aardvark-node/migrations/20250418140035_create_tables.sql b/aardvark-node/migrations/20250418140035_create_tables.sql new file mode 100644 index 0000000..39ad104 --- /dev/null +++ b/aardvark-node/migrations/20250418140035_create_tables.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS authors ( + public_key TEXT NOT NULL, + document_id TEXT NOT NULL, + last_seen INTEGER, + UNIQUE(public_key, document_id), + FOREIGN KEY(document_id) REFERENCES documents(document_id) +); + +CREATE TABLE IF NOT EXISTS documents ( + document_id TEXT NOT NULL PRIMARY KEY, + name TEXT, + last_accessed INTEGER +); \ No newline at end of file diff --git a/aardvark-node/src/document.rs b/aardvark-node/src/document.rs index 689422d..0262b5a 100644 --- a/aardvark-node/src/document.rs +++ b/aardvark-node/src/document.rs @@ -2,14 +2,27 @@ use std::fmt; use std::hash::Hash as StdHash; use std::str::FromStr; +use chrono::{DateTime, Utc}; use p2panda_core::{Hash, HashError, PublicKey}; use p2panda_net::TopicId; use p2panda_sync::TopicQuery; use serde::{Deserialize, Serialize}; +use sqlx::{ + Decode, Encode, FromRow, Sqlite, Type, + encode::IsNull, + error::BoxDynError, + sqlite::{SqliteArgumentValue, SqliteTypeInfo, SqliteValueRef}, +}; #[derive(Copy, Clone, Debug, PartialEq, Eq, StdHash, Serialize, Deserialize)] pub struct DocumentId(Hash); +impl DocumentId { + pub fn as_bytes(&self) -> &[u8] { + self.0.as_ref() + } +} + impl TopicQuery for DocumentId {} impl TopicId for DocumentId { @@ -18,6 +31,12 @@ impl TopicId for DocumentId { } } +impl From<[u8; 32]> for DocumentId { + fn from(bytes: [u8; 32]) -> Self { + Self(Hash::from_bytes(bytes)) + } +} + impl From for DocumentId { fn from(document_id: Hash) -> Self { Self(document_id) @@ -50,8 +69,60 @@ impl FromStr for DocumentId { } } +impl TryFrom<&[u8]> for DocumentId { + type Error = HashError; + + fn try_from(value: &[u8]) -> Result { + Ok(Hash::try_from(value)?.into()) + } +} + +impl Type for DocumentId { + fn type_info() -> SqliteTypeInfo { + <&[u8] as Type>::type_info() + } + + fn compatible(ty: &SqliteTypeInfo) -> bool { + <&[u8] as Type>::compatible(ty) + } +} + +impl<'q> Encode<'q, Sqlite> for &'q DocumentId { + fn encode_by_ref( + &self, + args: &mut Vec>, + ) -> Result { + <&[u8] as Encode>::encode_by_ref(&self.as_bytes(), args) + } +} + +impl Decode<'_, Sqlite> for DocumentId { + fn decode(value: SqliteValueRef<'_>) -> Result { + Ok(DocumentId::try_from(<&[u8] as Decode>::decode( + value, + )?)?) + } +} + +#[derive(Debug, FromRow)] +pub struct Document { + #[sqlx(rename = "document_id")] + pub id: DocumentId, + #[sqlx(default)] + pub name: Option, + pub last_accessed: Option>, + #[sqlx(skip)] + pub authors: Vec, +} + +#[derive(Debug)] +pub struct Author { + pub public_key: PublicKey, + pub last_seen: Option>, +} + pub trait SubscribableDocument: Sync + Send { - fn bytes_received(&self, author: PublicKey, data: &[u8]); + fn bytes_received(&self, author: PublicKey, data: Vec); fn authors_joined(&self, authors: Vec); fn author_set_online(&self, author: PublicKey, is_online: bool); } diff --git a/aardvark-node/src/lib.rs b/aardvark-node/src/lib.rs index aa2d7df..1cfd1d6 100644 --- a/aardvark-node/src/lib.rs +++ b/aardvark-node/src/lib.rs @@ -3,6 +3,7 @@ mod network; mod node; mod operation; mod store; +mod utils; pub use document::SubscribableDocument; pub use node::Node; diff --git a/aardvark-node/src/network.rs b/aardvark-node/src/network.rs index 9779f2b..3d5f876 100644 --- a/aardvark-node/src/network.rs +++ b/aardvark-node/src/network.rs @@ -7,16 +7,18 @@ use p2panda_discovery::mdns::LocalDiscovery; use p2panda_net::config::GossipConfig; use p2panda_net::{FromNetwork, NetworkBuilder, SyncConfiguration, SystemEvent, ToNetwork}; use p2panda_stream::{DecodeExt, IngestExt}; -use tokio::sync::{broadcast, mpsc}; -use tokio::task::JoinHandle; +use std::collections::HashMap; +use tokio::sync::RwLock; +use tokio::sync::mpsc; use tokio_stream::StreamExt; use tokio_stream::wrappers::ReceiverStream; use tracing::error; -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct Network { operation_store: OperationStore, network: p2panda_net::Network, + document_tx: RwLock>>, } impl Network { @@ -43,29 +45,32 @@ impl Network { Ok(Self { operation_store, network, + document_tx: RwLock::new(HashMap::new()), }) } - pub async fn shutdown(self) -> Result<()> { - self.network.shutdown().await?; + pub async fn shutdown(&self) -> Result<()> { + self.network.clone().shutdown().await?; Ok(()) } - pub async fn subscribe( + pub async fn subscribe( &self, document: DocumentId, - ) -> Result<( - mpsc::Sender>, - mpsc::Receiver>, - broadcast::Receiver>, - )> { - let (to_network, mut from_app) = mpsc::channel::>(128); - let (to_app, from_network) = mpsc::channel(128); - + f: impl Fn(Operation) -> Fut + Send + 'static, + ) -> Result<()> + where + Fut: Future + Send, + { // Join a gossip overlay with peers who are interested in the same document and start sync // with them. let (document_tx, document_rx, _gossip_ready) = self.network.subscribe(document).await?; + { + let mut store = self.document_tx.write().await; + store.insert(document.clone(), document_tx); + } + let stream = ReceiverStream::new(document_rx); // Incoming gossip payloads have a slightly different shape than sync. We convert them @@ -113,30 +118,63 @@ impl Network { }); // Send checked and ingested operations for this document to application layer. - let _result: JoinHandle> = tokio::task::spawn(async move { + tokio::task::spawn(async move { while let Some(operation) = stream.next().await { - to_app.send(operation).await?; + f(operation).await; } - Ok(()) }); - // Receive operations from application layer and forward them into gossip overlay for this - // document. - let _result: JoinHandle> = tokio::task::spawn(async move { - while let Some(operation) = from_app.recv().await { - let encoded_gossip_operation = - encode_gossip_operation(operation.header, operation.body)?; - document_tx - .send(ToNetwork::Message { - bytes: encoded_gossip_operation, - }) - .await?; + Ok(()) + } + + pub async fn unsubscribe(&self, document_id: &DocumentId) -> Result<()> { + self.document_tx.write().await.remove(document_id); + + Ok(()) + } + + pub async fn subscribe_events( + &self, + f: impl Fn(SystemEvent) -> Fut + Send + 'static, + ) -> Result<()> + where + Fut: Future + Send, + { + let mut events = self.network.events().await?; + + tokio::task::spawn(async move { + while let Ok(event) = events.recv().await { + f(event).await; } - Ok(()) }); - let events = self.network.events().await?; + Ok(()) + } + + /// Send operations to the gossip overlay for `document`. + /// + /// This will panic if the `document` wasn't subscribed to. + pub async fn send_operation( + &self, + document: &DocumentId, + operation: Operation, + ) -> Result<()> { + let document_tx = { + self.document_tx + .read() + .await + .get(document) + .cloned() + .expect("Not subscribed to document with id {document_id}") + }; - Ok((to_network, from_network, events)) + let encoded_gossip_operation = encode_gossip_operation(operation.header, operation.body)?; + document_tx + .send(ToNetwork::Message { + bytes: encoded_gossip_operation, + }) + .await?; + + Ok(()) } } diff --git a/aardvark-node/src/node.rs b/aardvark-node/src/node.rs index 4afa624..89346bb 100644 --- a/aardvark-node/src/node.rs +++ b/aardvark-node/src/node.rs @@ -1,21 +1,29 @@ -use std::sync::Arc; +use std::collections::HashMap; +use std::path::Path; +use std::sync::{Arc, OnceLock}; use anyhow::Result; +use chrono::Utc; use p2panda_core::{Hash, PrivateKey}; -use p2panda_net::{SyncConfiguration, SystemEvent, TopicId}; +use p2panda_net::{SyncConfiguration, SystemEvent}; +use p2panda_store::sqlite::store::migrations as operation_store_migrations; use p2panda_sync::log_sync::LogSyncProtocol; +use sqlx::{migrate::Migrator, sqlite}; use tokio::runtime::{Builder, Runtime}; -use tokio::sync::OnceCell; -use tracing::warn; +use tokio::sync::{Notify, RwLock, Semaphore}; +use tracing::{error, info, warn}; -use crate::document::{DocumentId, SubscribableDocument}; +use crate::document::{Document, DocumentId, SubscribableDocument}; use crate::network::Network; use crate::operation::{LogType, create_operation, validate_operation}; use crate::store::{DocumentStore, OperationStore}; +use crate::utils::CombinedMigrationSource; -#[derive(Clone)] pub struct Node { - inner: Arc, + inner: OnceLock>, + ready_notify: Arc, + documents: Arc>>>, + semaphore_operation_store: Semaphore, } impl Default for Node { @@ -24,82 +32,175 @@ impl Default for Node { } } +#[derive(Debug)] struct NodeInner { runtime: Runtime, operation_store: OperationStore, document_store: DocumentStore, - network: OnceCell, - private_key: OnceCell, + network: Network, + private_key: PrivateKey, } impl Node { pub fn new() -> Self { - // FIXME: Stores are currently in-memory and do not persist data on the file-system. - // Related issue: https://github.com/p2panda/aardvark/issues/31 - let operation_store = OperationStore::new(); - let document_store = DocumentStore::new(); + Self { + inner: OnceLock::new(), + ready_notify: Arc::new(Notify::new()), + documents: Arc::new(RwLock::new(HashMap::new())), + // FIXME: This makes sure we only create one operation at the time and not in parallel + // Since we would mess up the sequence of operations + semaphore_operation_store: Semaphore::new(1), + } + } + + async fn inner(&self) -> &Arc { + if let Some(inner) = self.inner.get() { + inner + } else { + self.ready_notify.notified().await; + self.inner + .get() + .expect("Inner should always be set at this point") + } + } + pub async fn run( + &self, + private_key: PrivateKey, + network_id: Hash, + db_location: Option<&Path>, + ) -> Result<()> { let runtime = Builder::new_multi_thread() .worker_threads(1) .enable_all() - .build() - .expect("single-threaded tokio runtime"); + .build()?; + + let _guard = runtime.enter(); + + let connection_options = sqlx::sqlite::SqliteConnectOptions::new() + .shared_cache(true) + .create_if_missing(true); + let connection_options = if let Some(db_location) = db_location { + let db_file = db_location.join("database.sqlite"); + info!("Database file location: {db_file:?}"); + connection_options.filename(db_file) + } else { + connection_options.in_memory(true) + }; - Self { - inner: Arc::new(NodeInner { - runtime, - operation_store, - document_store, - network: OnceCell::new(), - private_key: OnceCell::new(), - }), - } - } + let pool = if db_location.is_some() { + sqlx::sqlite::SqlitePool::connect_with(connection_options).await? + } else { + // FIXME: we need to set max connection to 1 for in memory sqlite DB. + // Probably has to do something with this issue: https://github.com/launchbadge/sqlx/issues/2510 + let pool_options = sqlite::SqlitePoolOptions::new().max_connections(1); + pool_options.connect_with(connection_options).await? + }; + + // Run migration for p2panda OperationStore and for the our DocumentStore + Migrator::new(CombinedMigrationSource::new(vec![ + operation_store_migrations(), + sqlx::migrate!(), + ])) + .await? + .run(&pool) + .await?; + + let operation_store = OperationStore::new(pool.clone()); + let document_store = DocumentStore::new(pool); - pub fn run(&self, private_key: PrivateKey, network_id: Hash) { let sync_config = { - let sync = LogSyncProtocol::new( - self.inner.document_store.clone(), - self.inner.operation_store.clone(), - ); + let sync = LogSyncProtocol::new(document_store.clone(), operation_store.clone()); SyncConfiguration::::new(sync) }; - let operation_store = self.inner.operation_store.clone(); - let inner = self.inner.clone(); + let network = Network::spawn( + network_id, + private_key.clone(), + sync_config, + operation_store.clone(), + ) + .await?; + let inner = Arc::new(NodeInner { + runtime, + operation_store, + document_store, + network, + private_key, + }); - self.inner.runtime.block_on(async move { - inner - .private_key - .set(private_key.clone()) - .expect("network can be run only once"); + let documents = self.documents.clone(); + + let inner_clone = inner.clone(); + inner + .network + .subscribe_events(move |system_event| { + let documents = documents.clone(); + let inner_clone = inner_clone.clone(); + async move { + match system_event { + SystemEvent::GossipJoined { topic_id, peers } => { + if let Some(document) = documents.read().await.get(&topic_id.into()) { + document.authors_joined(peers); + } + } + SystemEvent::GossipNeighborUp { topic_id, peer } => { + if let Some(document) = documents.read().await.get(&topic_id.into()) { + document.author_set_online(peer, true); + } + } + SystemEvent::GossipNeighborDown { topic_id, peer } => { + if let Err(error) = inner_clone + .document_store + .set_last_seen_for_author(peer, Some(Utc::now())) + .await + { + error!("Failed to set last seen for author {peer}: {error}"); + } + if let Some(document) = documents.read().await.get(&topic_id.into()) { + document.author_set_online(peer, false); + } + } + _ => {} + }; + } + }) + .await?; - inner - .network - .get_or_init(|| async { - Network::spawn(network_id, private_key, sync_config, operation_store) - .await - .expect("networking backend") - }) - .await; - }); + self.inner.set(inner).expect("Node can be run only once"); + self.ready_notify.notify_waiters(); + + Ok(()) } - pub fn shutdown(&self) { - let network = self.inner.network.get().expect("network running").clone(); - self.inner.runtime.block_on(async move { - network.shutdown().await.expect("network to shutdown"); - }); + pub async fn shutdown(&self) -> Result<()> { + let inner = self.inner().await; + let _guard = inner.runtime.enter(); + + inner.network.shutdown().await?; + + Ok(()) } - pub fn create_document(&self) -> Result { - let private_key = self.inner.private_key.get().expect("private key"); + pub async fn documents(&self) -> Result> { + let inner = self.inner().await; - let mut operation_store = self.inner.operation_store.clone(); - let operation = self.inner.runtime.block_on(async { + let inner_clone = inner.clone(); + Ok(inner + .runtime + .spawn(async move { inner_clone.document_store.documents().await }) + .await??) + } + + pub async fn create_document(&self) -> Result { + let inner = self.inner().await; + let _permit = self.semaphore_operation_store.acquire().await.unwrap(); + + let inner_clone = inner.clone(); + let operation = inner.runtime.block_on(async { create_operation( - &mut operation_store, - private_key, + &mut inner_clone.operation_store.clone(), + &inner_clone.private_key, LogType::Snapshot, None, None, @@ -116,94 +217,139 @@ impl Node { Ok(document_id) } + /// Set the name for a given document + /// + /// This information will be written to the database + pub async fn set_name_for_document( + &self, + document_id: &DocumentId, + name: Option, + ) -> Result<()> { + let inner = self.inner().await; + + let inner_clone = inner.clone(); + let document_id = *document_id; + inner + .runtime + .spawn(async move { + inner_clone + .document_store + .set_name_for_document(&document_id, name) + .await + }) + .await??; + + Ok(()) + } + + // TODO: check if peers are online and call SubscribableDocument::author_set_online(). + // This requires system events tracking pub async fn subscribe( &self, document_id: DocumentId, document: T, ) -> Result<()> { - let private_key = self.inner.private_key.get().expect("private key").clone(); let document = Arc::new(document); + let inner = self.inner().await; + let _permit = self.semaphore_operation_store.acquire().await.unwrap(); - // Add ourselves as an author to the document store. - self.inner - .document_store - .add_author(document_id, private_key.public_key()) - .await?; + let inner_clone = inner.clone(); + let stored_operations = inner + .runtime + .spawn(async move { + inner_clone + .document_store + .add_document(&document_id) + .await?; + // Add ourselves as an author to the document store. + inner_clone + .document_store + .add_author(&document_id, &inner_clone.private_key.public_key()) + .await?; + inner_clone + .document_store + .operations_for_document(&inner_clone.operation_store, &document_id) + .await + }) + .await??; - let inner_clone = self.inner.clone(); - let (document_tx, mut document_rx, mut system_event) = self - .inner + for operation in stored_operations { + // Send all stored operation bytes to the app, + // it doesn't matter if the app already knows some or all of them + if let Some(body) = operation.body { + document.bytes_received(operation.header.public_key, body.to_bytes()); + } + } + + let inner_clone = inner.clone(); + let document_clone = document.clone(); + inner .runtime .spawn(async move { - let network = inner_clone + let inner_clone2 = inner_clone.clone(); + inner_clone2 .network - // Allow concurrent calls by awaiting network instance as it might be still - // in process of initialisation. - .get_or_init(|| async { - unreachable!("network was initialised in `run` method"); + .subscribe(document_id, move |operation| { + let inner_clone = inner_clone.clone(); + let document_clone = document_clone.clone(); + async move { + // Process the operations and forward application messages to app layer. This is where + // we "materialize" our application state from incoming "application events". + // Validation for our custom "document" extension. + if let Err(err) = validate_operation(&operation, &document_id) { + warn!( + public_key = %operation.header.public_key, + seq_num = %operation.header.seq_num, + "{err}" + ); + return; + } + + // When we discover a new author we need to add them to our document store. + if let Err(error) = inner_clone + .document_store + .add_author(&document_id, &operation.header.public_key) + .await + { + error!("Can't store author to database: {error}"); + } + + // Forward the payload up to the app. + if let Some(body) = operation.body { + document_clone + .bytes_received(operation.header.public_key, body.to_bytes()); + } + } }) - .await; - network.subscribe(document_id).await + .await }) - .await - .unwrap()?; - self.inner - .document_store - .set_subscription_for_document(document_id, document_tx) - .await; + .await??; - let inner = self.inner.clone(); - let document_clone = document.clone(); - self.inner.runtime.spawn(async move { - // Process the operations and forward application messages to app layer. This is where - // we "materialize" our application state from incoming "application events". - while let Some(operation) = document_rx.recv().await { - // Validation for our custom "document" extension. - if let Err(err) = validate_operation(&operation, &document_id) { - warn!( - public_key = %operation.header.public_key, - seq_num = %operation.header.seq_num, - "{err}" - ); - continue; - } + self.documents.write().await.insert(document_id, document); - // When we discover a new author we need to add them to our document store. - inner - .document_store - .add_author(document_id, operation.header.public_key) - .await - .expect("Unable to add author to DocumentStore"); + Ok(()) + } - // Forward the payload up to the app. - if let Some(body) = operation.body { - document_clone.bytes_received(operation.header.public_key, &body.to_bytes()); - } - } - }); + pub async fn unsubscribe(&self, document_id: &DocumentId) -> Result<()> { + let inner = self.inner().await; + let _permit = self.semaphore_operation_store.acquire().await.unwrap(); - self.inner.runtime.spawn(async move { - while let Ok(system_event) = system_event.recv().await { - match system_event { - SystemEvent::GossipJoined { topic_id, peers } - if topic_id == document_id.id() => - { - document.authors_joined(peers); - } - SystemEvent::GossipNeighborUp { topic_id, peer } - if topic_id == document_id.id() => - { - document.author_set_online(peer, true); - } - SystemEvent::GossipNeighborDown { topic_id, peer } - if topic_id == document_id.id() => - { - document.author_set_online(peer, false); - } - _ => {} - }; - } - }); + let inner_clone = inner.clone(); + let document_id = *document_id; + + inner + .runtime + .spawn(async move { + inner_clone + .document_store + .set_last_accessed_for_document(&document_id, Some(Utc::now())) + .await?; + + let result = inner_clone.network.unsubscribe(&document_id).await; + result + }) + .await??; + self.documents.write().await.remove(&document_id); Ok(()) } @@ -213,28 +359,34 @@ impl Node { /// This should be used to inform all subscribed peers about small changes to the text /// document (Delta-Based CRDT). pub async fn delta(&self, document_id: DocumentId, bytes: Vec) -> Result<()> { - let private_key = self.inner.private_key.get().expect("private key"); - - // Append one operation to our "ephemeral" delta log. - let operation = create_operation( - &mut self.inner.operation_store.clone(), - &private_key, - LogType::Delta, - Some(document_id), - Some(&bytes), - false, - ) - .await?; + let inner = self.inner().await; + let _permit = self.semaphore_operation_store.acquire().await.unwrap(); - let document_tx = self - .inner - .document_store - .subscription_for_document(document_id) - .await - .expect("Not subscribed to document"); + let inner_clone = inner.clone(); + inner + .runtime + .spawn(async move { + let mut operation_store = inner_clone.operation_store.clone(); + // Append one operation to our "ephemeral" delta log. + let operation = create_operation( + &mut operation_store, + &inner_clone.private_key, + LogType::Delta, + Some(document_id), + Some(&bytes), + false, + ) + .await?; + + // Broadcast operation on gossip overlay. + inner_clone + .network + .send_operation(&document_id, operation) + .await + }) + .await??; - // Broadcast operation on gossip overlay. - document_tx.send(operation).await?; + info!("Delta operation sent for document with id {}", document_id); Ok(()) } @@ -247,55 +399,57 @@ impl Node { /// Since a snapshot contains all data we need to reliably reconcile documents (it is a /// State-Based CRDT) this command prunes all our logs and removes past snapshot- and delta /// operations. - pub async fn delta_with_snapshot( - &self, - document_id: DocumentId, - delta_bytes: Vec, - snapshot_bytes: Vec, - ) -> Result<()> { - let private_key = self.inner.private_key.get().expect("private key"); - - // Append an operation to our "snapshot" log and set the prune flag to - // true. This will remove previous snapshots. - // - // Snapshots are not broadcasted on the gossip overlay as they would be - // too large. Peers will sync them up when they join the document. - create_operation( - &mut self.inner.operation_store.clone(), - &private_key, - LogType::Snapshot, - Some(document_id), - Some(&snapshot_bytes), - true, - ) - .await?; - - // Append an operation to our "ephemeral" delta log and set the prune - // flag to true. - // - // This signals removing all previous "delta" operations now. This is - // some sort of garbage collection whenever we snapshot. Snapshots - // already contain all history, there is no need to keep duplicate - // "delta" data around. - let operation = create_operation( - &mut self.inner.operation_store.clone(), - &private_key, - LogType::Delta, - Some(document_id.into()), - Some(&delta_bytes), - true, - ) - .await?; + pub async fn snapshot(&self, document_id: DocumentId, snapshot_bytes: Vec) -> Result<()> { + let inner = self.inner().await; + let _permit = self.semaphore_operation_store.acquire().await.unwrap(); - let document_tx = self - .inner - .document_store - .subscription_for_document(document_id) - .await - .expect("Not subscribed to document"); + let inner_clone = inner.clone(); + inner + .runtime + .spawn(async move { + let mut operation_store = inner_clone.operation_store.clone(); + + // Append an operation to our "snapshot" log and set the prune flag to + // true. This will remove previous snapshots. + // + // Snapshots are not broadcasted on the gossip overlay as they would be + // too large. Peers will sync them up when they join the document. + create_operation( + &mut operation_store, + &inner_clone.private_key, + LogType::Snapshot, + Some(document_id), + Some(&snapshot_bytes), + true, + ) + .await?; + + // Append an operation to our "ephemeral" delta log and set the prune + // flag to true. + // + // This signals removing all previous "delta" operations now. This is + // some sort of garbage collection whenever we snapshot. Snapshots + // already contain all history, there is no need to keep duplicate + // "delta" data around. + let operation = create_operation( + &mut operation_store, + &inner_clone.private_key, + LogType::Delta, + Some(document_id), + None, + true, + ) + .await?; + + // Broadcast operation on gossip overlay. + inner_clone + .network + .send_operation(&document_id, operation) + .await + }) + .await??; - // Broadcast operation on gossip overlay. - document_tx.send(operation).await?; + info!("Snapshot saved for document with id {}", document_id); Ok(()) } diff --git a/aardvark-node/src/operation.rs b/aardvark-node/src/operation.rs index aac29a9..5e5e598 100644 --- a/aardvark-node/src/operation.rs +++ b/aardvark-node/src/operation.rs @@ -5,7 +5,7 @@ use anyhow::{Result, bail}; use p2panda_core::cbor::{decode_cbor, encode_cbor}; use p2panda_core::{Body, Extension, Extensions, Header, Operation, PrivateKey, PruneFlag}; use p2panda_store::LogStore; -use p2panda_stream::operation::{IngestResult, ingest_operation}; +use p2panda_store::OperationStore as TraitOperationStore; use serde::{Deserialize, Serialize}; use crate::document::DocumentId; @@ -125,7 +125,6 @@ pub async fn create_operation( prune_flag: bool, ) -> Result> { let body = body.map(Body::new); - let public_key = private_key.public_key(); let latest_operation = match document { @@ -168,20 +167,32 @@ pub async fn create_operation( let document: DocumentId = header.extension().expect("document id from our own logs"); let log_id = LogId::new(log_type, &document); - let result = ingest_operation( - store, - header.clone(), - body.clone(), - header.to_bytes(), - &log_id, - prune_flag, - ) - .await?; - - let IngestResult::Complete(operation) = result else { - panic!("we should never need to re-order our own operations") + let operation = Operation { + hash: header.hash(), + header, + body, }; + store + .insert_operation( + operation.hash, + &operation.header, + operation.body.as_ref(), + operation.header.to_bytes().as_slice(), + &log_id, + ) + .await?; + + if prune_flag { + store + .delete_operations( + &operation.header.public_key, + &log_id, + operation.header.seq_num, + ) + .await?; + } + Ok(operation) } diff --git a/aardvark-node/src/store.rs b/aardvark-node/src/store.rs index df867ee..8447b5c 100644 --- a/aardvark-node/src/store.rs +++ b/aardvark-node/src/store.rs @@ -1,68 +1,220 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::hash::Hash as StdHash; -use std::sync::Arc; -use tokio::sync::mpsc; -use anyhow::Result; use async_trait::async_trait; -use p2panda_core::{Operation, PublicKey}; -use p2panda_store::MemoryStore; +use chrono::{DateTime, Utc}; +use p2panda_core::PublicKey; +use p2panda_store::{LogStore, SqliteStore}; use p2panda_sync::log_sync::TopicLogMap; use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; +use sqlx; +use sqlx::Row; +use tracing::error; -use crate::document::DocumentId; -use crate::operation::{AardvarkExtensions, LogType}; +use crate::document::{Author, Document, DocumentId}; +use crate::operation::{AardvarkExtensions, LogType, validate_operation}; #[derive(Clone, Debug)] pub struct DocumentStore { - inner: Arc>, -} - -#[derive(Debug)] -struct DocumentStoreInner { - authors: HashMap>, - document_tx: HashMap>>, + pool: sqlx::SqlitePool, } impl DocumentStore { - pub fn new() -> Self { - Self { - inner: Arc::new(RwLock::new(DocumentStoreInner { - authors: HashMap::new(), - document_tx: HashMap::new(), - })), + pub fn new(pool: sqlx::SqlitePool) -> Self { + Self { pool } + } + + async fn authors(&self, document_id: &DocumentId) -> sqlx::Result> { + let list = sqlx::query("SELECT public_key FROM authors WHERE document_id = ?") + .bind(document_id) + .fetch_all(&self.pool) + .await?; + + Ok(list + .iter() + .filter_map(|row| PublicKey::try_from(row.get::<&[u8], _>("public_key")).ok()) + .collect()) + } + + pub async fn documents(&self) -> sqlx::Result> { + let mut documents: Vec = + sqlx::query_as("SELECT document_id, name, last_accessed FROM documents") + .fetch_all(&self.pool) + .await?; + let authors = sqlx::query("SELECT public_key, document_id, last_seen FROM authors") + .fetch_all(&self.pool) + .await?; + + let mut authors_per_document = authors.iter().fold(HashMap::new(), |mut acc, row| { + let Ok(document_id) = row.try_get::("document_id") else { + return acc; + }; + let Ok(public_key) = PublicKey::try_from(row.get::<&[u8], _>("public_key")) else { + return acc; + }; + let Ok(last_seen) = row.try_get::>, _>("last_seen") else { + return acc; + }; + acc.entry(document_id) + .or_insert_with(|| Vec::new()) + .push(Author { + public_key, + last_seen, + }); + acc + }); + + for document in &mut documents { + document.authors = authors_per_document + .remove(&document.id) + .expect("Document does not exist"); } + + Ok(documents) } - pub async fn set_subscription_for_document( + pub async fn add_document(&self, document_id: &DocumentId) -> sqlx::Result<()> { + // The document_id is the primary key in the table therefore ignore insertion when the document exists already + sqlx::query( + " + INSERT OR IGNORE INTO documents ( document_id ) + VALUES ( ? ) + ", + ) + .bind(document_id) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn add_author( &self, - document_id: DocumentId, - tx: mpsc::Sender>, - ) { - let mut store = self.inner.write().await; - store.document_tx.insert(document_id, tx); + document_id: &DocumentId, + public_key: &PublicKey, + ) -> sqlx::Result<()> { + // The author/document_id pair is required to be unique therefore ignore if the insertion fails + sqlx::query( + " + INSERT OR IGNORE INTO authors ( public_key, document_id ) + VALUES ( ?, ? ) + ", + ) + .bind(public_key.as_bytes().as_slice()) + .bind(document_id) + .execute(&self.pool) + .await?; + + Ok(()) } - pub async fn subscription_for_document( + pub async fn set_last_seen_for_author( &self, - document_id: DocumentId, - ) -> Option>> { - let store = self.inner.read().await; - store.document_tx.get(&document_id).cloned() + public_key: PublicKey, + last_seen: Option>, + ) -> sqlx::Result<()> { + sqlx::query( + " + UPDATE authors + SET last_seen = ? + WHERE public_key = ? + ", + ) + .bind(last_seen) + .bind(public_key.as_bytes().as_slice()) + .execute(&self.pool) + .await?; + + Ok(()) } - pub async fn add_author(&self, document: DocumentId, public_key: PublicKey) -> Result<()> { - let mut store = self.inner.write().await; - store - .authors - .entry(public_key) - .and_modify(|documents| { - documents.insert(document); - }) - .or_insert(HashSet::from([document])); + pub async fn set_name_for_document( + &self, + document_id: &DocumentId, + name: Option, + ) -> sqlx::Result<()> { + sqlx::query( + " + UPDATE documents + SET name = ? + WHERE document_id = ? + ", + ) + .bind(name) + .bind(document_id) + .execute(&self.pool) + .await?; + Ok(()) } + + pub async fn set_last_accessed_for_document( + &self, + document_id: &DocumentId, + last_accessed: Option>, + ) -> sqlx::Result<()> { + sqlx::query( + " + UPDATE documents + SET last_accessed = ? + WHERE document_id = ? + ", + ) + .bind(last_accessed) + .bind(document_id) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn operations_for_document( + &self, + operation_store: &OperationStore, + document_id: &DocumentId, + ) -> sqlx::Result>> { + let authors = self.authors(document_id).await?; + + let log_ids = [ + LogId::new(LogType::Delta, document_id), + LogId::new(LogType::Snapshot, document_id), + ]; + + let mut result = Vec::new(); + + for author in authors.iter() { + for log_id in &log_ids { + let operations = match operation_store.get_log(author, log_id, None).await { + Ok(Some(operations)) => { + operations.into_iter().map(|(header, body)| { + let operation = p2panda_core::Operation { + hash: header.hash(), + header, + body, + }; + + // Stored operations are always valid + assert!(validate_operation(&operation, &document_id).is_ok()); + operation + }) + } + Ok(None) => { + continue; + } + Err(error) => { + error!( + "Failed to load operation for {author} with log type {log_id:?}: {error}" + ); + continue; + } + }; + + result.extend(operations); + } + } + + Ok(result) + } } #[derive(Clone, Debug, PartialEq, Eq, StdHash, Serialize, Deserialize)] @@ -77,28 +229,20 @@ impl LogId { #[async_trait] impl TopicLogMap for DocumentStore { async fn get(&self, topic: &DocumentId) -> Option>> { - let store = &self.inner.read().await; - let mut result = HashMap::>::new(); - - for (public_key, documents) in &store.authors { - if documents.contains(topic) { - // We maintain two logs per author per document. - let log_ids = [ - LogId::new(LogType::Delta, topic), - LogId::new(LogType::Snapshot, topic), - ]; - - result - .entry(*public_key) - .and_modify(|logs| { - logs.extend_from_slice(&log_ids); - }) - .or_insert(log_ids.into()); - } - } - - Some(result) + let Ok(authors) = self.authors(topic).await else { + return None; + }; + let log_ids = [ + LogId::new(LogType::Delta, topic), + LogId::new(LogType::Snapshot, topic), + ]; + Some( + authors + .into_iter() + .map(|author| (author, log_ids.to_vec())) + .collect(), + ) } } -pub type OperationStore = MemoryStore; +pub type OperationStore = SqliteStore; diff --git a/aardvark-node/src/utils.rs b/aardvark-node/src/utils.rs new file mode 100644 index 0000000..195dd21 --- /dev/null +++ b/aardvark-node/src/utils.rs @@ -0,0 +1,34 @@ +use std::pin::Pin; + +use sqlx::error::BoxDynError; +use sqlx::migrate::{Migration, MigrationSource, Migrator}; + +type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// Combine multiple `sqlx::migrate::Migrator` into a single `sqlx::migrate::MigrationSource` +/// +/// See for more details: https://github.com/launchbadge/sqlx/discussions/3407 +#[derive(Debug)] +pub struct CombinedMigrationSource { + migrators: Vec, +} + +impl CombinedMigrationSource { + pub fn new(migrators: Vec) -> CombinedMigrationSource { + Self { migrators } + } +} + +impl<'s> MigrationSource<'s> for CombinedMigrationSource { + fn resolve(self) -> BoxFuture<'s, Result, BoxDynError>> { + Box::pin(async move { + Ok(self + .migrators + .iter() + .map(|migrator| migrator.iter()) + .flatten() + .cloned() + .collect()) + }) + } +}