From 862740c4ee25e3dfb37e217352b46341d3b099e2 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 30 Jun 2026 03:27:13 +0200 Subject: [PATCH] init --- Cargo.lock | 556 +++++++- Cargo.toml | 3 +- LICENSE | 21 + crates/pdfs-cli/Cargo.toml | 1 + crates/pdfs-cli/src/main.rs | 221 ++- crates/pdfs-core/Cargo.toml | 1 + crates/pdfs-core/src/auth.rs | 22 +- crates/pdfs-core/src/cache.rs | 549 ++++++++ crates/pdfs-core/src/config.rs | 19 + crates/pdfs-core/src/control.rs | 125 ++ crates/pdfs-core/src/lib.rs | 2 + crates/pdfs-fuse/Cargo.toml | 1 + crates/pdfs-fuse/src/lib.rs | 829 +++++++++-- crates/pdfs-gui/Cargo.toml | 26 + crates/pdfs-gui/src/app.rs | 1307 ++++++++++++++++++ crates/pdfs-gui/src/main.rs | 205 +++ packaging/.gitignore | 4 + packaging/PKGBUILD | 63 + packaging/io.narl.proton-drive-linux.desktop | 12 + packaging/io.narl.proton-drive-linux.svg | 14 + 20 files changed, 3831 insertions(+), 150 deletions(-) create mode 100644 LICENSE create mode 100644 crates/pdfs-core/src/cache.rs create mode 100644 crates/pdfs-core/src/control.rs create mode 100644 crates/pdfs-gui/Cargo.toml create mode 100644 crates/pdfs-gui/src/app.rs create mode 100644 crates/pdfs-gui/src/main.rs create mode 100644 packaging/.gitignore create mode 100644 packaging/PKGBUILD create mode 100644 packaging/io.narl.proton-drive-linux.desktop create mode 100644 packaging/io.narl.proton-drive-linux.svg diff --git a/Cargo.lock b/Cargo.lock index 8dd0944..33c77bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "1.0.0" @@ -263,6 +272,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -343,6 +363,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.0" @@ -466,6 +492,29 @@ dependencies = [ "libbz2-rs-sys", ] +[[package]] +name = "cairo-rs" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "camellia" version = "0.1.0" @@ -521,6 +570,16 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -566,6 +625,21 @@ dependencies = [ "inout 0.2.2", ] +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + [[package]] name = "clap" version = "4.6.1" @@ -585,7 +659,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -838,7 +912,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn", ] @@ -873,6 +947,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-codegen" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a49da9fdfbe872d4841d56605dc42efa5e6ca3291299b87f44e1cde91a28617c" +dependencies = [ + "clap 2.34.0", + "dbus", + "xml-rs", +] + [[package]] name = "dbus-secret-service" version = "4.1.0" @@ -891,6 +976,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dbus-tree" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f456e698ae8e54575e19ddb1f9b7bce2298568524f215496b248eb9498b4f508" +dependencies = [ + "dbus", +] + [[package]] name = "der" version = "0.7.10" @@ -1207,6 +1301,16 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1256,7 +1360,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80a5eca878900c2e39e9e52fd797954b7fc39eeefc8558257114bfea6a698fcf" dependencies = [ - "bitflags", + "bitflags 2.13.0", "libc", "log", "memchr", @@ -1371,6 +1475,63 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk-pixbuf" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1430,6 +1591,114 @@ dependencies = [ "polyval", ] +[[package]] +name = "gio" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", +] + +[[package]] +name = "glib" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + [[package]] name = "group" version = "0.13.0" @@ -1441,6 +1710,89 @@ dependencies = [ "subtle", ] +[[package]] +name = "gsk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1453,6 +1805,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -1896,6 +2257,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ksni" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4934310bdd016e55725482b8d35ac0c16fd058c1b955d8959aa2d953b918c85b" +dependencies = [ + "dbus", + "dbus-codegen", + "dbus-tree", + "thiserror 1.0.69", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1905,6 +2278,37 @@ dependencies = [ "spin", ] +[[package]] +name = "libadwaita" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500135d29c16aabf67baafd3e7741d48e8b8978ca98bac39e589165c8dc78191" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6680988058c2558baf3f548a370e4e78da3bf7f08469daa822ac414842c912db" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -2051,7 +2455,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -2064,7 +2468,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -2302,6 +2706,30 @@ dependencies = [ "winapi", ] +[[package]] +name = "pango" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking" version = "2.2.1" @@ -2347,10 +2775,11 @@ name = "pdfs-cli" version = "0.1.0" dependencies = [ "anyhow", - "clap", + "clap 4.6.1", "pdfs-core", "pdfs-fuse", "rpassword", + "serde_json", "tokio", "tracing", "tracing-subscriber", @@ -2366,6 +2795,7 @@ dependencies = [ "proton-sdk", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.18", "tokio", "tracing", @@ -2379,10 +2809,26 @@ dependencies = [ "libc", "pdfs-core", "proton-drive-rs", + "serde_json", "tokio", "tracing", ] +[[package]] +name = "pdfs-gui" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-channel", + "gtk4", + "ksni", + "libadwaita", + "pdfs-core", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2520,7 +2966,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.5.2", "pin-project-lite", "rustix", "windows-sys 0.61.2", @@ -2783,7 +3229,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -2977,7 +3423,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -3129,7 +3575,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation", "core-foundation-sys", "libc", @@ -3206,6 +3652,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serdect" version = "0.2.0" @@ -3403,6 +3858,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.11.1" @@ -3446,12 +3907,31 @@ dependencies = [ "syn", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + [[package]] name = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -3465,6 +3945,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3589,6 +4078,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3619,6 +4123,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -3640,7 +4150,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -3775,6 +4285,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3827,6 +4343,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -4412,6 +4940,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 0825bc3..6922252 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli"] +members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] [workspace.package] version = "0.1.0" @@ -26,6 +26,7 @@ anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } directories = "5" +sha2 = "0.10" keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] } clap = { version = "4", features = ["derive"] } rpassword = "7" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e51bd8d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Nils Pukropp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/pdfs-cli/Cargo.toml b/crates/pdfs-cli/Cargo.toml index 281f438..f562761 100644 --- a/crates/pdfs-cli/Cargo.toml +++ b/crates/pdfs-cli/Cargo.toml @@ -20,3 +20,4 @@ tracing.workspace = true tracing-subscriber.workspace = true clap.workspace = true rpassword.workspace = true +serde_json.workspace = true diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 06be1e0..c787036 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -1,12 +1,14 @@ //! `pdfs` — command-line front-end for the Proton Drive Linux client. use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use clap::{Parser, Subcommand}; use pdfs_core::auth; +use pdfs_core::cache::ContentCache; use pdfs_core::config::AppDirs; +use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse}; #[derive(Parser)] #[command(name = "pdfs", version, about = "Proton Drive for Linux (Files On-Demand)")] @@ -25,20 +27,54 @@ enum Command { }, /// Forget the stored session. Logout, - /// Show the currently logged-in account. + /// Show account login state, and mount status if a daemon is running. Status, /// Mount Proton Drive at the given (or default) path. Blocks until unmounted. Mount { /// Mountpoint; defaults to ~/ProtonDrive. mountpoint: Option, }, + /// Keep a file on this device (download + cache it). Needs a running mount. + Pin { + /// File path, inside the mountpoint or relative to it. + path: PathBuf, + }, + /// Stop keeping a file on this device, evicting its cached content. + Unpin { + /// File path, inside the mountpoint or relative to it. + path: PathBuf, + }, + /// List files pinned to this device. + Pins, + /// List a directory via the running daemon (in-app browser backend). + Ls { + /// Directory path, inside the mountpoint or relative to it. Root if omitted. + path: Option, + }, + /// List the photos timeline (newest first) via the running daemon. + Photos { + /// Max photos to list. + #[arg(long, default_value_t = 50)] + limit: usize, + /// Skip this many photos from the start of the timeline. + #[arg(long, default_value_t = 0)] + offset: usize, + }, + /// Download a photo by uid (`volume~link`) and print its cached path. + OpenPhoto { + /// Photo node uid in `volume~link` form (from `pdfs photos`). + uid: String, + }, } fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "info".into()), + // `pgp` logs a benign WARN when it re-serializes a Proton secret-key + // packet with a longer (but equivalent) length encoding than the + // original; drop it to error so normal runs stay quiet. + .unwrap_or_else(|_| "info,pgp=error".into()), ) .init(); @@ -48,6 +84,12 @@ fn main() -> Result<()> { Command::Logout => cmd_logout(), Command::Status => cmd_status(), Command::Mount { mountpoint } => cmd_mount(mountpoint), + Command::Pin { path } => cmd_pin(path), + Command::Unpin { path } => cmd_unpin(path), + Command::Pins => cmd_pins(), + Command::Ls { path } => cmd_ls(path), + Command::Photos { limit, offset } => cmd_photos(limit, offset), + Command::OpenPhoto { uid } => cmd_open_photo(uid), } } @@ -93,9 +135,20 @@ fn cmd_status() -> Result<()> { println!("Logged in as {} (user {})", s.username, s.user_id); println!("Scopes: {}", s.scopes.join(", ")); } - Err(pdfs_core::Error::NotLoggedIn) => println!("Not logged in. Run `pdfs login`."), + Err(pdfs_core::Error::NotLoggedIn) => { + println!("Not logged in. Run `pdfs login`."); + return Ok(()); + } Err(e) => return Err(e.into()), } + // If a mount daemon is running, report live status from its control socket. + match control_request(CtlRequest::Status) { + Ok(CtlResponse::Status { mountpoint, pinned, .. }) => { + println!("Mounted at {mountpoint} ({pinned} pinned)"); + } + Ok(other) => println!("Mount: unexpected response {other:?}"), + Err(_) => println!("Mount: not running."), + } Ok(()) } @@ -106,14 +159,168 @@ fn cmd_mount(mountpoint: Option) -> Result<()> { std::fs::create_dir_all(&mountpoint) .with_context(|| format!("create mountpoint {}", mountpoint.display()))?; + let username = auth::load().map(|s| s.username).unwrap_or_default(); + let cache = ContentCache::open( + dirs.content_cache_dir(), + dirs.pins_path(), + pdfs_core::config::DEFAULT_CACHE_BUDGET_BYTES, + ) + .context("open content cache")?; + let control_socket = dirs.control_socket(); + // Multi-threaded runtime: its worker threads keep servicing async SDK calls // while the main thread is parked inside the blocking FUSE session loop. let rt = tokio::runtime::Runtime::new()?; - let (client, _session) = rt + let (client, session) = rt .block_on(auth::resume_client()) .context("resume session (run `pdfs login` first)")?; + // Persist rotated tokens for the daemon's lifetime. Proton refresh tokens are + // single-use: the 401-refresh path swaps in a new access+refresh pair, and if + // that pair is never written back the keyring keeps a now-dead refresh token, + // so the next `pdfs mount` fails with `InvalidRefreshToken`. The session + // shares its token store with the Drive client (one `Arc`-backed token store), + // so both the poll below and the shutdown flush see every rotation. + // + // `ProtonApiSession` is `Clone` over that shared store, so the polling task + // gets its own handle and we keep `session` here for a final flush. The poll + // is a backstop for long-lived mounts; the flush after `mount` returns is what + // makes the common case correct — a refresh shortly before a clean unmount + // would otherwise be lost in the poll gap, leaving a dead token behind. + let poll_session = session.clone(); + rt.spawn(async move { + let mut last = poll_session.current_tokens().await; + loop { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + let current = poll_session.current_tokens().await; + if current.access_token == last.access_token + && current.refresh_token == last.refresh_token + { + continue; + } + match auth::persist(&poll_session).await { + Ok(()) => { + tracing::info!("persisted refreshed session tokens"); + last = current; + } + Err(e) => tracing::warn!(error = %e, "failed to persist refreshed tokens"), + } + } + }); + let handle = rt.handle().clone(); - pdfs_fuse::mount(client, handle, &mountpoint).context("mount failed")?; + let result = + pdfs_fuse::mount(client, handle, &mountpoint, cache, &control_socket, username); + + // Clean unmount: flush whatever tokens the store holds now so the keyring + // always ends a session with the live refresh token, not a stale one. Runs + // even if `mount` errored — a rotation may still have happened first. + if let Err(e) = rt.block_on(auth::persist(&session)) { + tracing::warn!(error = %e, "failed to persist tokens on shutdown"); + } + + result.context("mount failed")?; Ok(()) } + +fn cmd_pin(path: PathBuf) -> Result<()> { + match control_request(CtlRequest::Pin { path: path_arg(&path)? })? { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_unpin(path: PathBuf) -> Result<()> { + match control_request(CtlRequest::Unpin { path: path_arg(&path)? })? { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_pins() -> Result<()> { + match control_request(CtlRequest::ListPins)? { + CtlResponse::Pins { pins } if pins.is_empty() => println!("No pinned files."), + CtlResponse::Pins { pins } => { + for p in pins { + println!("{}", p.path); + } + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_ls(path: Option) -> Result<()> { + let path = match path { + Some(p) => path_arg(&p)?, + None => String::new(), + }; + match control_request(CtlRequest::ListDir { path })? { + CtlResponse::Entries { entries } if entries.is_empty() => println!("(empty)"), + CtlResponse::Entries { entries } => { + for e in entries { + let kind = if e.is_dir { "d" } else { "-" }; + let pin = if e.pinned { "*" } else { " " }; + println!("{kind}{pin} {:>12} {} [{}]", e.size, e.name, e.uid); + } + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_photos(limit: usize, offset: usize) -> Result<()> { + match control_request(CtlRequest::PhotosTimeline { offset, limit })? { + CtlResponse::Photos { available: false, .. } => { + println!("This account has no photos volume.") + } + CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."), + CtlResponse::Photos { items, .. } => { + for p in items { + let thumb = p.thumb_path.as_deref().unwrap_or("(no thumbnail)"); + println!("{} {} {thumb}", p.capture_time, p.uid); + } + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_open_photo(uid: String) -> Result<()> { + match control_request(CtlRequest::OpenPhoto { uid })? { + CtlResponse::FilePath { path } => println!("{path}"), + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +/// Normalize a user-supplied path argument for sending to the daemon. Absolute +/// paths are canonicalized (so the daemon can strip its mountpoint prefix); +/// relative paths are passed through and resolved against the mount root. +fn path_arg(path: &Path) -> Result { + let p = if path.is_absolute() { + std::fs::canonicalize(path) + .with_context(|| format!("resolve {}", path.display()))? + } else { + path.to_path_buf() + }; + p.to_str() + .map(str::to_owned) + .ok_or_else(|| anyhow!("path is not valid UTF-8")) +} + +/// Send one request to the running mount daemon's control socket and read its +/// reply. Errors if no daemon is listening. +fn control_request(req: CtlRequest) -> Result { + let socket = AppDirs::new()?.control_socket(); + pdfs_core::control::send(&socket, &req) + .with_context(|| format!("talk to mount daemon at {}", socket.display())) +} diff --git a/crates/pdfs-core/Cargo.toml b/crates/pdfs-core/Cargo.toml index 7f24ba4..a921109 100644 --- a/crates/pdfs-core/Cargo.toml +++ b/crates/pdfs-core/Cargo.toml @@ -16,4 +16,5 @@ serde_json.workspace = true thiserror.workspace = true tracing.workspace = true directories.workspace = true +sha2.workspace = true keyring.workspace = true diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index a591c70..a7a558d 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -35,7 +35,7 @@ pub struct StoredSession { } impl StoredSession { - fn into_params(&self) -> ResumeParameters { + fn to_params(&self) -> ResumeParameters { ResumeParameters { session_id: self.session_id.clone().into(), username: self.username.clone(), @@ -122,11 +122,25 @@ pub fn logout() -> Result<()> { /// Resume a persisted session and build an authenticated Drive client. /// -/// Re-persists tokens afterward in case the refresh path rotated them. Returns -/// `Error::NotLoggedIn` when no session has been saved. +/// Returns `Error::NotLoggedIn` when no session has been saved. The caller is +/// responsible for persisting rotated tokens over the session's lifetime via +/// [`persist`] — Proton refresh tokens are single-use, so a refresh that is not +/// written back leaves the keyring holding a stale (now-invalid) refresh token, +/// and the next resume fails with `InvalidRefreshToken`. pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> { let stored = load()?; - let session = ProtonApiSession::resume(client_config(), stored.into_params())?; + let session = ProtonApiSession::resume(client_config(), stored.to_params())?; let client = ProtonDriveClient::new(&session, stored.mailbox_password.clone().into_bytes()); Ok((client, session)) } + +/// Write the session's current tokens back to the keyring. +/// +/// The session does not carry the mailbox password (it is only needed to rebuild +/// the key chain on resume), so it is re-read from the existing stored blob and +/// preserved. Call whenever the session may have rotated its tokens through the +/// 401-refresh path so a later [`resume_client`] presents a live refresh token. +pub async fn persist(session: &ProtonApiSession) -> Result<()> { + let mailbox_password = load()?.mailbox_password; + save(session, &mailbox_password).await +} diff --git a/crates/pdfs-core/src/cache.rs b/crates/pdfs-core/src/cache.rs new file mode 100644 index 0000000..b829c31 --- /dev/null +++ b/crates/pdfs-core/src/cache.rs @@ -0,0 +1,549 @@ +//! On-disk content cache and pin registry. +//! +//! Files-On-Demand keeps file *content* off local disk by default: a `read` +//! hydrates the requested byte range straight from the remote. Two things make +//! that cache-worthy: +//! +//! * **Pinning** — the user marks a file "always keep on this device". Its full +//! plaintext is downloaded once and stored under [`AppDirs::cache_dir`]; every +//! later `read` is served from disk with no network. +//! * **Validation** — Proton does not hand us a stable revision id on the public +//! [`Node`], so a cached blob is validated against the node's +//! `(modification_time, plaintext_size)` pair. A new revision changes at least +//! the mtime, so a stale blob is detected and ignored (and the event poller +//! evicts it eagerly anyway). +//! +//! [`AppDirs::cache_dir`]: crate::config::AppDirs::cache_dir +//! [`Node`]: proton_drive_rs::Node + +use std::collections::{BTreeMap, HashSet}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use proton_drive_rs::proton_sdk::ids::NodeUid; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::error::Result; + +/// Validity tag stored alongside a cached blob. A blob is fresh only if both +/// fields still match the node's current metadata. +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +struct Meta { + /// Node modification time, epoch seconds. + mtime: i64, + /// Plaintext size in bytes. + size: u64, +} + +/// One pinned file, as persisted to the pin registry. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Pin { + /// Node uid in `volume~link` display form. + pub uid: String, + /// Last path the file was pinned under, for display in `status`. Advisory + /// only — the uid is the identity. + pub path: String, +} + +/// The pin registry, persisted as JSON. Keyed by uid display string so a pin +/// survives renames/moves of the node. +#[derive(Serialize, Deserialize, Default)] +struct PinFile { + pins: BTreeMap, +} + +/// Content cache rooted at a directory, with a sibling pin-registry file. +pub struct ContentCache { + /// Directory holding `` blobs and `.meta` tags. + content_dir: PathBuf, + /// Subdirectory holding cached thumbnails (`.t` + `.meta`). Kept out + /// of `content_dir` so the budget scan never sees thumbnail files. + thumb_dir: PathBuf, + /// JSON pin registry path. + pins_path: PathBuf, + /// Soft cap on total blob bytes. Exceeded only transiently: a `store` + /// evicts least-recently-used *unpinned* blobs back under the cap. `0` + /// disables the cap. Pinned blobs are never evicted, so pins alone may push + /// the cache over budget. + max_bytes: u64, +} + +impl ContentCache { + /// Open (and create) a cache under `content_dir`, with the pin registry at + /// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent + /// directories are created if missing. + pub fn open(content_dir: PathBuf, pins_path: PathBuf, max_bytes: u64) -> Result { + std::fs::create_dir_all(&content_dir)?; + let thumb_dir = content_dir.join("thumbs"); + std::fs::create_dir_all(&thumb_dir)?; + if let Some(parent) = pins_path.parent() { + std::fs::create_dir_all(parent)?; + } + Ok(Self { content_dir, thumb_dir, pins_path, max_bytes }) + } + + /// Filesystem-safe, fixed-length cache key for a uid display string: the + /// SHA-256 of `s`, hex-encoded (64 chars). A plain hex of the uid would + /// *double* its length, and Proton's `volume~link` ids are long enough + /// (each half can be 60+ chars) that the doubled key overflows `NAME_MAX` + /// (255) — `store` then fails with ENAMETOOLONG. Hashing bounds the key at + /// 64 chars; reversibility is not needed and collisions are negligible. + fn key_str(s: &str) -> String { + let digest = Sha256::digest(s.as_bytes()); + let mut out = String::with_capacity(digest.len() * 2); + for b in digest { + out.push(char::from_digit((b >> 4) as u32, 16).unwrap()); + out.push(char::from_digit((b & 0xf) as u32, 16).unwrap()); + } + out + } + + /// Filesystem-safe cache key for `uid`. + fn key(uid: &NodeUid) -> String { + Self::key_str(&uid.to_string()) + } + + fn blob_path(&self, uid: &NodeUid) -> PathBuf { + self.content_dir.join(Self::key(uid)) + } + + fn meta_path(&self, uid: &NodeUid) -> PathBuf { + self.content_dir.join(format!("{}.meta", Self::key(uid))) + } + + /// The validated blob path for `uid`, or `None` if absent or stale against + /// `(mtime, size)`. + fn valid_blob(&self, uid: &NodeUid, mtime: i64, size: u64) -> Option { + let want = Meta { mtime, size }; + let meta: Meta = serde_json::from_slice(&std::fs::read(self.meta_path(uid)).ok()?).ok()?; + if meta != want { + return None; + } + let blob = self.blob_path(uid); + // Guard against a torn write: the blob must match the recorded size. + match std::fs::metadata(&blob) { + Ok(m) if m.len() == size => Some(blob), + _ => None, + } + } + + /// Whether a fresh cached blob exists for `uid`. + pub fn is_cached(&self, uid: &NodeUid, mtime: i64, size: u64) -> bool { + self.valid_blob(uid, mtime, size).is_some() + } + + /// Serve `len` bytes from `offset` out of the cached blob, or `None` on a + /// miss/stale entry. Reads only the requested window off disk. + pub fn read_range( + &self, + uid: &NodeUid, + mtime: i64, + size: u64, + offset: u64, + len: u64, + ) -> Option> { + let blob = self.valid_blob(uid, mtime, size)?; + // Record the access for LRU: bump the blob's mtime to now. Best effort — + // a failed touch only makes eviction order slightly less accurate. + let mut f = std::fs::File::open(&blob).ok()?; + let _ = f.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now())); + if offset >= size { + return Some(Vec::new()); + } + let end = offset.saturating_add(len).min(size); + let want = (end - offset) as usize; + f.seek(SeekFrom::Start(offset)).ok()?; + let mut buf = vec![0u8; want]; + f.read_exact(&mut buf).ok()?; + Some(buf) + } + + /// Store `bytes` as the cached content for `uid`, tagged with `(mtime, + /// size)`. The blob is written to a temp file then renamed so a concurrent + /// reader never sees a partial blob; the meta tag is written last so a + /// crash mid-store fails validation rather than serving truncated data. + pub fn store(&self, uid: &NodeUid, mtime: i64, size: u64, bytes: &[u8]) -> Result<()> { + let blob = self.blob_path(uid); + let tmp = blob.with_extension("tmp"); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, &blob)?; + let meta = serde_json::to_vec(&Meta { mtime, size })?; + std::fs::write(self.meta_path(uid), meta)?; + self.enforce_budget(); + Ok(()) + } + + /// Remove any cached blob + meta for `uid`, including its thumbnails (best + /// effort; absence is fine). Thumbnails are tied to the revision, so a + /// content eviction must drop them too. + pub fn evict(&self, uid: &NodeUid) { + let _ = std::fs::remove_file(self.blob_path(uid)); + let _ = std::fs::remove_file(self.meta_path(uid)); + for ttype in [1, 2] { + let _ = std::fs::remove_file(self.thumb_blob(uid, ttype)); + let _ = std::fs::remove_file(self.thumb_meta(uid, ttype)); + } + } + + fn thumb_blob(&self, uid: &NodeUid, ttype: i32) -> PathBuf { + self.thumb_dir.join(format!("{}.t{ttype}", Self::key(uid))) + } + + fn thumb_meta(&self, uid: &NodeUid, ttype: i32) -> PathBuf { + self.thumb_dir.join(format!("{}.t{ttype}.meta", Self::key(uid))) + } + + /// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale. + /// Validated by node `mtime` alone: a new revision (which is what changes the + /// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails + /// are small, so the whole blob is read at once. + pub fn read_thumbnail(&self, uid: &NodeUid, ttype: i32, mtime: i64) -> Option> { + let recorded: i64 = + serde_json::from_slice(&std::fs::read(self.thumb_meta(uid, ttype)).ok()?).ok()?; + if recorded != mtime { + return None; + } + std::fs::read(self.thumb_blob(uid, ttype)).ok() + } + + /// On-disk path of the cached content blob for `uid`, validated against + /// `(mtime, size)`; `None` on miss/stale. Lets a front-end open the blob + /// directly instead of streaming its bytes back over the control socket. + pub fn cached_content_path(&self, uid: &NodeUid, mtime: i64, size: u64) -> Option { + self.valid_blob(uid, mtime, size) + } + + /// On-disk path where `uid`'s content blob lives once stored (the file may + /// not exist yet, or may be stale — pair with [`store`](Self::store)). + pub fn content_path(&self, uid: &NodeUid) -> PathBuf { + self.blob_path(uid) + } + + /// On-disk path of the cached `ttype` thumbnail for `uid`, fresh against + /// `tag` (the validity tag last passed to [`store_thumbnail`](Self::store_thumbnail)); + /// `None` on miss/stale. For Drive files the tag is the node mtime; for + /// photos it is the capture time. + pub fn cached_thumbnail_path(&self, uid: &NodeUid, ttype: i32, tag: i64) -> Option { + let recorded: i64 = + serde_json::from_slice(&std::fs::read(self.thumb_meta(uid, ttype)).ok()?).ok()?; + if recorded != tag { + return None; + } + let blob = self.thumb_blob(uid, ttype); + blob.exists().then_some(blob) + } + + /// On-disk path where `uid`'s `ttype` thumbnail lives once stored. + pub fn thumbnail_path(&self, uid: &NodeUid, ttype: i32) -> PathBuf { + self.thumb_blob(uid, ttype) + } + + /// Cache `bytes` as the `ttype` thumbnail for `uid`, tagged with `mtime`. + /// Blob written to a temp file then renamed; the meta tag is written last so + /// a crash mid-store fails validation rather than serving a torn thumbnail. + pub fn store_thumbnail( + &self, + uid: &NodeUid, + ttype: i32, + mtime: i64, + bytes: &[u8], + ) -> Result<()> { + let blob = self.thumb_blob(uid, ttype); + let tmp = blob.with_extension("tmp"); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, &blob)?; + std::fs::write(self.thumb_meta(uid, ttype), serde_json::to_vec(&mtime)?)?; + Ok(()) + } + + /// Evict least-recently-used *unpinned* blobs until total blob bytes fit the + /// configured `max_bytes` cap. No-op when the cap is disabled (`0`) or the + /// cache already fits. Pinned blobs are skipped, so a cache held entirely by + /// pins can legitimately stay over budget. + fn enforce_budget(&self) { + if self.max_bytes == 0 { + return; + } + // Pinned blobs (by cache key) are exempt from eviction. + let pinned: HashSet = + self.load_pins().pins.keys().map(|uid| Self::key_str(uid)).collect(); + + // Scan blobs (the `` files; skip `.meta`/`.tmp` siblings), record + // size + last-access (mtime) for each. + let mut total: u64 = 0; + let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new(); + let Ok(rd) = std::fs::read_dir(&self.content_dir) else { + return; + }; + for entry in rd.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name.ends_with(".meta") || name.ends_with(".tmp") { + continue; + } + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_file() { + continue; + } + total += meta.len(); + if pinned.contains(name) { + continue; // counts toward total but is never a victim + } + let atime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); + blobs.push((path, meta.len(), atime)); + } + if total <= self.max_bytes { + return; + } + // Oldest access first. + blobs.sort_by_key(|(_, _, atime)| *atime); + for (path, len, _) in blobs { + if total <= self.max_bytes { + break; + } + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(path.with_extension("meta")); + total = total.saturating_sub(len); + } + } + + fn load_pins(&self) -> PinFile { + std::fs::read(&self.pins_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() + } + + fn save_pins(&self, pins: &PinFile) -> Result<()> { + std::fs::write(&self.pins_path, serde_json::to_vec_pretty(pins)?)?; + Ok(()) + } + + /// Whether `uid` is pinned (independent of whether its blob is currently + /// cached). + pub fn is_pinned(&self, uid: &NodeUid) -> bool { + self.load_pins().pins.contains_key(&uid.to_string()) + } + + /// Record `uid` as pinned under `path`. The caller is responsible for having + /// already cached the content via [`store`](Self::store). + pub fn add_pin(&self, uid: &NodeUid, path: &Path) -> Result<()> { + let mut file = self.load_pins(); + let key = uid.to_string(); + file.pins.insert( + key.clone(), + Pin { uid: key, path: path.display().to_string() }, + ); + self.save_pins(&file) + } + + /// Drop `uid` from the pin registry and evict its blob. No-op if not pinned. + pub fn remove_pin(&self, uid: &NodeUid) -> Result<()> { + let mut file = self.load_pins(); + if file.pins.remove(&uid.to_string()).is_some() { + self.save_pins(&file)?; + } + self.evict(uid); + Ok(()) + } + + /// All pinned files, ordered by uid. + pub fn list_pins(&self) -> Vec { + self.load_pins().pins.into_values().collect() + } + + /// Total bytes of cached content blobs (pinned + unpinned), matching what + /// [`enforce_budget`](Self::enforce_budget) weighs against the cap. + /// Thumbnails live in a sibling dir and are not counted. Cheap directory + /// scan, safe to call from a front-end for a usage read-out. + pub fn usage(&self) -> u64 { + let Ok(rd) = std::fs::read_dir(&self.content_dir) else { + return 0; + }; + let mut total = 0u64; + for entry in rd.flatten() { + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if name.ends_with(".meta") || name.ends_with(".tmp") { + continue; + } + if let Ok(meta) = entry.metadata() + && meta.is_file() + { + total += meta.len(); + } + } + total + } + + /// Configured soft byte cap (`0` = unlimited), for display alongside + /// [`usage`](Self::usage). + pub fn budget(&self) -> u64 { + self.max_bytes + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId}; + + fn uid(link: &str) -> NodeUid { + NodeUid::new(VolumeId::from("vol"), LinkId::from(link)) + } + + /// A unique temp directory removed on drop; avoids a dev-dependency. + struct TempDir(PathBuf); + impl TempDir { + fn new() -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let p = std::env::temp_dir().join(format!( + "pdfs-cache-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&p).unwrap(); + TempDir(p) + } + fn path(&self) -> &Path { + &self.0 + } + } + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn cache() -> (ContentCache, TempDir) { + cache_capped(0) + } + + fn cache_capped(max_bytes: u64) -> (ContentCache, TempDir) { + let dir = TempDir::new(); + let c = ContentCache::open( + dir.path().join("content"), + dir.path().join("pins.json"), + max_bytes, + ) + .unwrap(); + (c, dir) + } + + #[test] + fn store_then_read_range() { + let (c, _d) = cache(); + let u = uid("a"); + let data = b"hello world"; + c.store(&u, 100, data.len() as u64, data).unwrap(); + assert!(c.is_cached(&u, 100, data.len() as u64)); + assert_eq!( + c.read_range(&u, 100, data.len() as u64, 6, 5).unwrap(), + b"world" + ); + // Offset past EOF yields an empty slice, not a miss. + assert_eq!( + c.read_range(&u, 100, data.len() as u64, 100, 5).unwrap(), + b"" + ); + } + + #[test] + fn stale_metadata_is_a_miss() { + let (c, _d) = cache(); + let u = uid("a"); + c.store(&u, 100, 3, b"abc").unwrap(); + // A newer mtime (new revision) invalidates the cached blob. + assert!(!c.is_cached(&u, 101, 3)); + assert!(c.read_range(&u, 101, 3, 0, 3).is_none()); + // A size mismatch also invalidates it. + assert!(c.read_range(&u, 100, 4, 0, 3).is_none()); + } + + #[test] + fn pin_lifecycle_evicts_on_unpin() { + let (c, _d) = cache(); + let u = uid("a"); + c.store(&u, 100, 3, b"abc").unwrap(); + c.add_pin(&u, Path::new("docs/a.txt")).unwrap(); + assert!(c.is_pinned(&u)); + assert_eq!(c.list_pins().len(), 1); + assert_eq!(c.list_pins()[0].path, "docs/a.txt"); + + c.remove_pin(&u).unwrap(); + assert!(!c.is_pinned(&u)); + assert!(c.list_pins().is_empty()); + // Unpin evicts the cached content. + assert!(!c.is_cached(&u, 100, 3)); + } + + #[test] + fn budget_evicts_lru_unpinned() { + // Cap fits two 4-byte blobs but not three. + let (c, _d) = cache_capped(8); + let (a, b, d) = (uid("a"), uid("b"), uid("d")); + + c.store(&a, 1, 4, b"aaaa").unwrap(); + // Distinct mtimes so LRU order is deterministic (oldest = a). + std::thread::sleep(std::time::Duration::from_millis(10)); + c.store(&b, 1, 4, b"bbbb").unwrap(); + // Touch `a` so `b` becomes the least-recently-used. + std::thread::sleep(std::time::Duration::from_millis(10)); + assert!(c.read_range(&a, 1, 4, 0, 4).is_some()); + + // Third store pushes over budget → evicts LRU unpinned (`b`). + std::thread::sleep(std::time::Duration::from_millis(10)); + c.store(&d, 1, 4, b"dddd").unwrap(); + assert!(c.is_cached(&a, 1, 4), "recently-read blob survives"); + assert!(!c.is_cached(&b, 1, 4), "LRU blob evicted"); + assert!(c.is_cached(&d, 1, 4), "newest blob survives"); + } + + #[test] + fn budget_never_evicts_pinned() { + let (c, _d) = cache_capped(8); + let (a, b, d) = (uid("a"), uid("b"), uid("d")); + + c.store(&a, 1, 4, b"aaaa").unwrap(); + c.add_pin(&a, Path::new("a")).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + c.store(&b, 1, 4, b"bbbb").unwrap(); + // Over budget: `a` is pinned (oldest) so `b` must be the victim. + std::thread::sleep(std::time::Duration::from_millis(10)); + c.store(&d, 1, 4, b"dddd").unwrap(); + assert!(c.is_cached(&a, 1, 4), "pinned blob never evicted"); + assert!(!c.is_cached(&b, 1, 4), "unpinned LRU blob evicted"); + assert!(c.is_cached(&d, 1, 4)); + } + + #[test] + fn thumbnail_store_read_and_evict() { + let (c, _d) = cache(); + let u = uid("a"); + c.store_thumbnail(&u, 1, 100, b"thumb1").unwrap(); + c.store_thumbnail(&u, 2, 100, b"preview2").unwrap(); + assert_eq!(c.read_thumbnail(&u, 1, 100).unwrap(), b"thumb1"); + assert_eq!(c.read_thumbnail(&u, 2, 100).unwrap(), b"preview2"); + // A newer revision (mtime bump) invalidates the cached thumbnail. + assert!(c.read_thumbnail(&u, 1, 101).is_none()); + // Content eviction drops thumbnails too. + c.evict(&u); + assert!(c.read_thumbnail(&u, 1, 100).is_none()); + assert!(c.read_thumbnail(&u, 2, 100).is_none()); + } + + #[test] + fn evict_removes_blob() { + let (c, _d) = cache(); + let u = uid("a"); + c.store(&u, 1, 3, b"abc").unwrap(); + c.evict(&u); + assert!(!c.is_cached(&u, 1, 3)); + } +} diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index 425bc74..4dc7a2a 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -14,6 +14,10 @@ pub const USER_AGENT: &str = "proton-drive-linux/0.1.0"; /// Keyring service name; one entry per credential kind keyed by username. pub const KEYRING_SERVICE: &str = "proton-drive-linux"; +/// Default soft cap on the on-disk content cache (5 GiB). LRU-evicts unpinned +/// blobs back under this; pinned files are exempt. See [`crate::cache`]. +pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024; + /// Resolved XDG locations for state, cache, and the default mountpoint. pub struct AppDirs { dirs: ProjectDirs, @@ -40,6 +44,21 @@ impl AppDirs { self.dirs.cache_dir().to_path_buf() } + /// Directory holding cached file-content blobs (pinned files). + pub fn content_cache_dir(&self) -> PathBuf { + self.cache_dir().join("content") + } + + /// JSON pin registry, in persistent state (not the evictable cache). + pub fn pins_path(&self) -> PathBuf { + self.state_dir().join("pins.json") + } + + /// Unix domain socket the mount daemon listens on for CLI control commands. + pub fn control_socket(&self) -> PathBuf { + self.state_dir().join("control.sock") + } + /// Default mountpoint when the user does not pass one explicitly. pub fn default_mountpoint(&self) -> PathBuf { directories::UserDirs::new() diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs new file mode 100644 index 0000000..81f6342 --- /dev/null +++ b/crates/pdfs-core/src/control.rs @@ -0,0 +1,125 @@ +//! IPC protocol between the CLI and a running mount daemon. +//! +//! The daemon listens on a Unix domain socket under the state dir; the CLI +//! (`pdfs pin` / `unpin` / `status`) connects, sends one [`Request`] as a single +//! JSON line, and reads one JSON-line [`Response`]. Keeping the wire format +//! line-delimited JSON makes the socket trivially scriptable. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::cache::Pin; +use crate::error::Result; + +/// Cap on the *write* half of a round-trip. A crashed daemon can leave its +/// listening socket in the kernel (e.g. as a `` zombie): `connect` +/// then succeeds but no one ever replies. A short write bound trips fast when +/// the daemon is wedged before it has read anything. +const WRITE_TIMEOUT: Duration = Duration::from_secs(2); + +/// Cap on the *read* half. Some requests make the daemon do real work before it +/// replies — `PhotosTimeline` downloads a page of thumbnails, `OpenFile` +/// downloads whole-file content — which easily takes more than a couple of +/// seconds. A 2s read bound timed those out and the GUI mistook the timeout for +/// "no daemon" (showing "Mount Proton Drive…" on a live mount). Give reads a +/// generous bound that still protects against a daemon that accepts but never +/// answers. +const READ_TIMEOUT: Duration = Duration::from_secs(120); + +/// A command sent from the CLI to the daemon. +#[derive(Serialize, Deserialize, Debug)] +pub enum Request { + /// Report mount status (account, mountpoint, pin count). + Status, + /// Pin a file (path relative to the mountpoint, or absolute under it). + Pin { path: String }, + /// Unpin a file, evicting its cached content. + Unpin { path: String }, + /// List pinned files. + ListPins, + /// List a directory for the in-app file browser. `path` is + /// mountpoint-relative (empty or "." = the mount root). + ListDir { path: String }, + /// Fetch a page of the photos timeline, newest first. Thumbnails for the + /// page are fetched into the cache and their on-disk paths returned. + PhotosTimeline { offset: usize, limit: usize }, + /// Download a photo's full content into the cache; replies with its path. + OpenPhoto { uid: String }, + /// Download a Drive file's full content into the cache; replies with the + /// on-disk path so the front-end can open it with the default app. `path` + /// is mountpoint-relative. + OpenFile { path: String }, +} + +/// One entry in a [`Request::ListDir`] listing. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct DirEntry { + /// Decrypted node name. + pub name: String, + pub is_dir: bool, + /// Plaintext size in bytes (0 for folders). + pub size: u64, + /// Modification time, epoch seconds. + pub modified: i64, + /// Whether the file is pinned to this device. + pub pinned: bool, + /// Node uid in `volume~link` form, for follow-up requests. + pub uid: String, +} + +/// One photo in a [`Request::PhotosTimeline`] page. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PhotoItem { + /// Node uid in `volume~link` form. + pub uid: String, + /// Capture time, epoch seconds (the timeline is newest-first). + pub capture_time: i64, + /// On-disk path to the cached thumbnail, if one was available/fetched. + pub thumb_path: Option, +} + +/// The daemon's reply to a [`Request`]. +#[derive(Serialize, Deserialize, Debug)] +pub enum Response { + /// Current mount status. + Status { + username: String, + mountpoint: String, + pinned: usize, + }, + /// A human-readable success message. + Ok { message: String }, + /// The pin registry. + Pins { pins: Vec }, + /// A directory listing (reply to [`Request::ListDir`]). + Entries { entries: Vec }, + /// A page of the photos timeline. `available` is false when the account + /// has no photos volume. + Photos { available: bool, items: Vec }, + /// An on-disk path the front-end can open (e.g. a downloaded photo). + FilePath { path: String }, + /// The request failed. + Error { message: String }, +} + +/// Send one [`Request`] to the daemon listening on `socket` and read its +/// [`Response`]. Errors (e.g. [`Error::Io`]) if no daemon is listening. +/// +/// Shared by the CLI and GUI so both speak the wire format identically. +pub fn send(socket: &Path, req: &Request) -> Result { + let mut stream = UnixStream::connect(socket)?; + stream.set_read_timeout(Some(READ_TIMEOUT))?; + stream.set_write_timeout(Some(WRITE_TIMEOUT))?; + let mut line = serde_json::to_vec(req)?; + line.push(b'\n'); + stream.write_all(&line)?; + stream.flush()?; + let mut reader = BufReader::new(stream); + let mut resp = String::new(); + reader.read_line(&mut resp)?; + Ok(serde_json::from_str(resp.trim())?) +} diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 1a2503e..881b3ff 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -3,7 +3,9 @@ //! front-end (CLI today, daemon + GUI later) shares. pub mod auth; +pub mod cache; pub mod config; +pub mod control; pub mod error; pub use error::{Error, Result}; diff --git a/crates/pdfs-fuse/Cargo.toml b/crates/pdfs-fuse/Cargo.toml index 06531b0..2c4857d 100644 --- a/crates/pdfs-fuse/Cargo.toml +++ b/crates/pdfs-fuse/Cargo.toml @@ -14,3 +14,4 @@ tokio.workspace = true tracing.workspace = true fuser.workspace = true libc.workspace = true +serde_json.workspace = true diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 8c5b478..f36c9a6 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -17,12 +17,20 @@ //! not byte ranges). New files are created empty up front so they get a real //! uid; namespace ops map to `create_folder`, `trash_nodes`, `rename_node` and //! `move_node`. +//! +//! Phase 4 adds Files-On-Demand pinning. A control socket (see [`control`]) +//! lets the CLI pin/unpin files; a pinned file's plaintext is downloaded once +//! into the on-disk [`ContentCache`] and every later `read` is served from disk +//! instead of the network. Writes and remote events evict the cache so it never +//! goes stale. use std::collections::HashMap; use std::ffi::OsStr; -use std::path::Path; +use std::io::{BufRead, BufReader, Write as _}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use fuser::{ BsdFileFlags, Config, Errno, FileAttr, FileHandle, FileType, Filesystem, FopenFlags, Generation, @@ -30,8 +38,14 @@ use fuser::{ ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen, ReplyWrite, Request, Session, TimeOrNow, WriteFlags, }; -use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid}; -use proton_drive_rs::{DriveEvent, DriveEventScopeId, Node, NodeKind, ProtonDriveClient}; +use fuser::ReplyXattr; +use pdfs_core::cache::ContentCache; +use pdfs_core::control::{DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse}; +use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId}; +use proton_drive_rs::{ + DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient, + ProtonPhotosClient, ThumbnailType, +}; use tracing::{debug, error, info, warn}; /// Attribute/entry cache lifetime handed back to the kernel. Long because the @@ -41,10 +55,19 @@ const TTL: Duration = Duration::from_secs(30); /// How often the background task polls the remote event cursor. const POLL_INTERVAL: Duration = Duration::from_secs(10); +/// How long a fetched photos timeline stays good before the next page request +/// re-fetches it. The SDK hands back the whole timeline at once, so we cache it +/// in [`Core`] and serve every page from memory until it goes stale. +const TIMELINE_TTL: Duration = Duration::from_secs(60); /// The FUSE root inode is always 1. const ROOT_INO: u64 = 1; +/// Extended attribute exposing the small server-side thumbnail of a file. +const XATTR_THUMBNAIL: &str = "user.proton.thumbnail"; +/// Extended attribute exposing the larger server-side preview image of a file. +const XATTR_PREVIEW: &str = "user.proton.preview"; + /// A node known to the filesystem, addressed by its kernel inode. struct Entry { uid: NodeUid, @@ -133,50 +156,31 @@ impl State { } } -/// The Proton Drive VFS. FUSE callbacks are synchronous, so the Tokio handle -/// bridges each one to the async SDK via [`Handle::block_on`]; the fuser -/// session thread is not a runtime worker, so blocking on it is sound. -pub struct ProtonFs { +/// Shared engine behind both the FUSE callbacks and the control socket: the +/// Drive client, a Tokio handle to bridge the synchronous FUSE/socket threads +/// to the async SDK, the inode bookkeeping, and the on-disk content cache. +/// +/// Cheaply cloneable (every field is a handle/`Arc`), so the control-socket task +/// gets its own copy while the FUSE session keeps another. +#[derive(Clone)] +struct Core { client: ProtonDriveClient, rt: tokio::runtime::Handle, state: Arc>, - uid: u32, - gid: u32, + cache: Arc, + /// Cached full photos timeline (newest first), so paged `PhotosTimeline` + /// requests slice memory instead of re-fetching the whole timeline per page. + /// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`]. + timeline: Arc>>, } -impl ProtonFs { - /// Build the filesystem rooted at `root` (the user's My Files folder). - pub fn new(client: ProtonDriveClient, rt: tokio::runtime::Handle, root: Node) -> Self { - let mut entries = HashMap::new(); - let mut by_uid = HashMap::new(); - by_uid.insert(root.uid.clone(), ROOT_INO); - entries.insert( - ROOT_INO, - Entry { uid: root.uid.clone(), parent: ROOT_INO, node: root }, - ); - // SAFETY: geteuid/getegid are infallible and have no preconditions. - let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; - Self { - client, - rt, - state: Arc::new(Mutex::new(State { - entries, - by_uid, - children: HashMap::new(), - next_ino: 2, - handles: HashMap::new(), - next_fh: 1, - })), - uid, - gid, - } - } - - /// A handle to the shared inode state, for the event poller. - fn shared_state(&self) -> Arc> { - Arc::clone(&self.state) - } +/// The whole photos timeline plus when it was fetched, for TTL freshness. +struct TimelineCache { + items: Vec, + fetched: Instant, +} +impl Core { /// Enumerate `ino`'s children from the remote and cache them. No-op if the /// directory has already been listed. Network I/O happens without the lock /// held so concurrent metadata reads aren't blocked behind a fetch. @@ -223,33 +227,6 @@ impl ProtonFs { Ok(()) } - fn attr(&self, ino: u64, node: &Node) -> FileAttr { - let (kind, perm) = match node.kind { - NodeKind::Folder => (FileType::Directory, 0o755), - NodeKind::File { .. } => (FileType::RegularFile, 0o644), - }; - let size = node_size(node); - let mtime = unix_secs(node.modification_time); - let crtime = unix_secs(node.creation_time); - FileAttr { - ino: INodeNo(ino), - size, - blocks: size.div_ceil(512), - atime: mtime, - mtime, - ctime: mtime, - crtime, - kind, - perm, - nlink: if kind == FileType::Directory { 2 } else { 1 }, - uid: self.uid, - gid: self.gid, - rdev: 0, - blksize: 512, - flags: 0, - } - } - /// Resolve a child `name` within `parent` to its `(inode, uid)`, ensuring /// the parent's listing is cached first. fn lookup_child(&self, parent: u64, name: &str) -> Result<(u64, NodeUid), Errno> { @@ -268,6 +245,30 @@ impl ProtonFs { .ok_or(Errno::ENOENT) } + /// Walk a mountpoint-relative path to its `(inode, uid)`, enumerating each + /// directory on the way as needed. Leading `/` and `.` components are + /// ignored; `..` is rejected. + fn resolve_path(&self, rel: &Path) -> Result<(u64, NodeUid), Errno> { + let mut ino = ROOT_INO; + let mut uid = { + let st = self.state.lock().unwrap(); + st.entries.get(&ROOT_INO).map(|e| e.uid.clone()).ok_or(Errno::ENOENT)? + }; + for comp in rel.components() { + match comp { + Component::RootDir | Component::CurDir => continue, + Component::Normal(name) => { + let (child_ino, child_uid) = + self.lookup_child(ino, &name.to_string_lossy())?; + ino = child_ino; + uid = child_uid; + } + _ => return Err(Errno::EINVAL), + } + } + Ok((ino, uid)) + } + /// Fetch a single node's current metadata from the remote. fn fetch_node(&self, uid: &NodeUid) -> Result { let nodes = self @@ -297,40 +298,370 @@ impl ProtonFs { error!(%uid, error = %e, "upload new revision failed"); Errno::EIO })?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); + let now = now_secs(); let mut st = self.state.lock().unwrap(); if let Some(h) = st.handles.get_mut(&fh) { h.dirty = false; } st.set_size(ino, buf.len() as u64); st.touch_mtime(ino, now); + drop(st); + // The sealed content differs from any cached blob; if the file is + // pinned, refresh the cache so reads stay served from disk. + if self.cache.is_pinned(&uid) { + let _ = self.cache.store(&uid, now, buf.len() as u64, &buf); + } else { + self.cache.evict(&uid); + } Ok(()) } + /// Pin the file at mountpoint-relative `rel`: download its full plaintext + /// into the content cache and record it in the pin registry. Returns the + /// resolved node name on success. + fn pin(&self, rel: &Path) -> Result { + let (ino, uid) = self.resolve_path(rel).map_err(|e| format!("resolve path: {e:?}"))?; + let (name, mtime, size) = { + let st = self.state.lock().unwrap(); + let e = st.entries.get(&ino).ok_or("node vanished")?; + if !e.node.is_file() { + return Err("not a regular file".into()); + } + (e.node.name.clone(), e.node.modification_time, node_size(&e.node)) + }; + let bytes = self + .rt + .block_on(self.client.download_file(&uid)) + .map_err(|e| format!("download: {e}"))?; + self.cache + .store(&uid, mtime, size, &bytes) + .map_err(|e| format!("cache store: {e}"))?; + self.cache.add_pin(&uid, rel).map_err(|e| format!("pin: {e}"))?; + Ok(name) + } + + /// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache + /// when fresh and otherwise downloaded from the remote and cached. Returns + /// `Ok(None)` when the node is not a file or has no thumbnail of that type. + fn thumbnail(&self, ino: u64, ttype: ThumbnailType) -> Result>, Errno> { + let (uid, mtime) = { + let st = self.state.lock().unwrap(); + match st.entries.get(&ino) { + Some(e) if e.node.is_file() => (e.uid.clone(), e.node.modification_time), + Some(_) => return Ok(None), + None => return Err(Errno::ENOENT), + } + }; + if let Some(bytes) = self.cache.read_thumbnail(&uid, ttype.as_i32(), mtime) { + return Ok(Some(bytes)); + } + let bytes = self + .rt + .block_on(self.client.download_thumbnail(&uid, ttype)) + .map_err(|e| { + warn!(%uid, error = %e, "download thumbnail failed"); + Errno::EIO + })?; + if let Some(bytes) = &bytes { + let _ = self.cache.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes); + } + Ok(bytes) + } + + /// Unpin the file at `rel`, evicting its cached content. + fn unpin(&self, rel: &Path) -> Result { + let (ino, uid) = self.resolve_path(rel).map_err(|e| format!("resolve path: {e:?}"))?; + let name = { + let st = self.state.lock().unwrap(); + st.entries.get(&ino).map(|e| e.node.name.clone()).unwrap_or_default() + }; + self.cache.remove_pin(&uid).map_err(|e| format!("unpin: {e}"))?; + Ok(name) + } + + /// A Photos API handle sharing this Core's Drive client and session, so it + /// reuses the daemon's single authenticated session rather than logging in + /// again (Proton refresh tokens are single-use). Cheap — the Drive client + /// is `Clone` over `Arc`-backed state. + fn photos(&self) -> ProtonPhotosClient { + ProtonPhotosClient::from_drive_client(self.client.clone()) + } + + /// List the directory at mountpoint-relative `rel` for the in-app browser: + /// the same lazy remote enumeration `readdir` uses, projected into + /// serializable [`DirEntry`]s (with per-file pin state). + fn list_dir(&self, rel: &Path) -> Result, String> { + let (ino, _uid) = self.resolve_path(rel).map_err(|e| format!("resolve path: {e:?}"))?; + self.ensure_children(ino).map_err(|e| format!("enumerate: {e:?}"))?; + // Snapshot the listing, then drop the lock before touching the on-disk + // pin registry so a slow disk read doesn't block FUSE metadata ops. + let rows: Vec<(String, bool, u64, i64, NodeUid)> = { + let st = self.state.lock().unwrap(); + st.children + .get(&ino) + .map(|kids| { + kids.iter() + .filter_map(|k| st.entries.get(k)) + .map(|e| { + ( + e.node.name.clone(), + e.node.is_folder(), + node_size(&e.node), + e.node.modification_time, + e.uid.clone(), + ) + }) + .collect() + }) + .unwrap_or_default() + }; + Ok(rows + .into_iter() + .map(|(name, is_dir, size, modified, uid)| DirEntry { + name, + is_dir, + size, + modified, + pinned: self.cache.is_pinned(&uid), + uid: uid.to_string(), + }) + .collect()) + } + + /// A page of the photos timeline (newest first), with the page's thumbnails + /// fetched into the cache and their paths attached. `Ok(None)` when the + /// account has no photos volume. + /// + /// The SDK's `enumerate_timeline` returns the *whole* timeline, so the first + /// request (or the first after [`TIMELINE_TTL`] elapses) fetches and caches + /// it in [`Core::timeline`]; every page then slices that cached vec, so + /// "load more" scrolling doesn't re-hit the network per page. + fn photos_timeline( + &self, + offset: usize, + limit: usize, + ) -> Result>, String> { + let page: Vec = { + // Refresh the cached timeline if absent or stale. The lock is only + // held for the freshness check and the store, never across the fetch. + let stale = { + let guard = self.timeline.lock().unwrap(); + guard + .as_ref() + .is_none_or(|c| c.fetched.elapsed() >= TIMELINE_TTL) + }; + if stale { + let photos = self.photos(); + if self + .rt + .block_on(photos.get_photos_root()) + .map_err(|e| format!("photos root: {e}"))? + .is_none() + { + return Ok(None); + } + let items = self + .rt + .block_on(photos.enumerate_timeline()) + .map_err(|e| format!("timeline: {e}"))?; + *self.timeline.lock().unwrap() = Some(TimelineCache { + items, + fetched: Instant::now(), + }); + } + let guard = self.timeline.lock().unwrap(); + match guard.as_ref() { + Some(c) => c.items.iter().skip(offset).take(limit).cloned().collect(), + None => return Ok(None), + } + }; + let ttype = ThumbnailType::Thumbnail.as_i32(); + + // Batch-fetch only the thumbnails not already cached fresh. + let want: Vec = page + .iter() + .filter(|it| self.cache.cached_thumbnail_path(&it.uid, ttype, it.capture_time).is_none()) + .map(|it| it.uid.clone()) + .collect(); + if !want.is_empty() { + let cap: HashMap = + page.iter().map(|it| (it.uid.clone(), it.capture_time)).collect(); + match self + .rt + .block_on(self.photos().enumerate_thumbnails(&want, ThumbnailType::Thumbnail)) + { + Ok(thumbs) => { + for ft in thumbs { + if let (Ok(bytes), Some(&tag)) = (ft.result, cap.get(&ft.file_uid)) { + let _ = self.cache.store_thumbnail(&ft.file_uid, ttype, tag, &bytes); + } + } + } + Err(e) => warn!(error = %e, "batch photo thumbnails failed"), + } + } + + Ok(Some( + page.into_iter() + .map(|it| { + let thumb_path = self + .cache + .cached_thumbnail_path(&it.uid, ttype, it.capture_time) + .map(|p| p.display().to_string()); + PhotoItem { + uid: it.uid.to_string(), + capture_time: it.capture_time, + thumb_path, + } + }) + .collect(), + )) + } + + /// Download a photo's full content into the content cache, returning its + /// on-disk path (served from cache when a fresh blob already exists). + fn open_photo(&self, uid: &NodeUid) -> Result { + let photos = self.photos(); + let node = self + .rt + .block_on(photos.get_node(uid)) + .map_err(|e| format!("photo node: {e}"))? + .ok_or("photo not found")?; + let (mtime, size) = (node.modification_time, node_size(&node)); + if let Some(p) = self.cache.cached_content_path(uid, mtime, size) { + return Ok(p); + } + let bytes = self + .rt + .block_on(photos.download_photo(uid)) + .map_err(|e| format!("download photo: {e}"))?; + self.cache + .store(uid, mtime, size, &bytes) + .map_err(|e| format!("cache store: {e}"))?; + Ok(self.cache.content_path(uid)) + } + + /// Download the full content of the Drive file at mountpoint-relative `rel` + /// into the content cache, returning its on-disk path (served from cache + /// when a fresh blob already exists). Lets a front-end open the file with + /// the user's default application without pinning it. + fn open_file(&self, rel: &Path) -> Result { + let (ino, uid) = self.resolve_path(rel).map_err(|e| format!("resolve path: {e:?}"))?; + let (mtime, size) = { + let st = self.state.lock().unwrap(); + let e = st.entries.get(&ino).ok_or("node vanished")?; + if !e.node.is_file() { + return Err("not a regular file".into()); + } + (e.node.modification_time, node_size(&e.node)) + }; + if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) { + return Ok(p); + } + let bytes = self + .rt + .block_on(self.client.download_file(&uid)) + .map_err(|e| format!("download: {e}"))?; + self.cache + .store(&uid, mtime, size, &bytes) + .map_err(|e| format!("cache store: {e}"))?; + Ok(self.cache.content_path(&uid)) + } +} + +/// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends +/// receive uids as strings over the control socket and pass them back verbatim. +fn parse_uid(s: &str) -> Option { + let (vol, link) = s.split_once('~')?; + Some(NodeUid::new(VolumeId::from(vol), LinkId::from(link))) +} + +/// The Proton Drive VFS. FUSE callbacks are synchronous, so the Tokio handle +/// bridges each one to the async SDK via [`Handle::block_on`]; the fuser +/// session thread is not a runtime worker, so blocking on it is sound. +pub struct ProtonFs { + core: Core, + uid: u32, + gid: u32, +} + +impl ProtonFs { + /// Build the filesystem rooted at `root` (the user's My Files folder). + fn new(core: Core, root: Node) -> Self { + { + let mut st = core.state.lock().unwrap(); + st.by_uid.insert(root.uid.clone(), ROOT_INO); + st.entries.insert( + ROOT_INO, + Entry { uid: root.uid.clone(), parent: ROOT_INO, node: root }, + ); + } + // SAFETY: geteuid/getegid are infallible and have no preconditions. + let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + Self { core, uid, gid } + } + + fn attr(&self, ino: u64, node: &Node) -> FileAttr { + let (kind, perm) = match node.kind { + NodeKind::Folder => (FileType::Directory, 0o755), + NodeKind::File { .. } => (FileType::RegularFile, 0o644), + }; + let size = node_size(node); + let mtime = unix_secs(node.modification_time); + let crtime = unix_secs(node.creation_time); + FileAttr { + ino: INodeNo(ino), + size, + blocks: size.div_ceil(512), + atime: mtime, + mtime, + ctime: mtime, + crtime, + kind, + perm, + nlink: if kind == FileType::Directory { 2 } else { 1 }, + uid: self.uid, + gid: self.gid, + rdev: 0, + blksize: 512, + flags: 0, + } + } + /// Trash the child `name` under `parent` on the remote, then drop it from the /// local cache. Backs both `unlink` and `rmdir` (Proton trashes whole /// subtrees, so an `rmdir` of a non-empty dir behaves the same). fn trash_child(&self, parent: u64, name: &str, reply: ReplyEmpty) { - let (_ino, uid) = match self.lookup_child(parent, name) { + let (_ino, uid) = match self.core.lookup_child(parent, name) { Ok(x) => x, Err(e) => { reply.error(e); return; } }; - if let Err(e) = self.rt.block_on(self.client.trash_nodes(std::slice::from_ref(&uid))) { + if let Err(e) = self + .core + .rt + .block_on(self.core.client.trash_nodes(std::slice::from_ref(&uid))) + { error!(%uid, error = %e, "trash failed"); reply.error(Errno::EIO); return; } - self.state.lock().unwrap().forget(&uid); + self.core.state.lock().unwrap().forget(&uid); + self.core.cache.evict(&uid); reply.ok(); } } +/// Current wall-clock time as epoch seconds (0 if the clock is before the epoch). +fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + /// A coarse MIME type guessed from a file name's extension; Proton stores this /// on the node but an exact value is not required for correctness. fn media_type_for(name: &str) -> &'static str { @@ -369,12 +700,12 @@ fn unix_secs(secs: i64) -> SystemTime { impl Filesystem for ProtonFs { fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { let parent = parent.0; - if let Err(e) = self.ensure_children(parent) { + if let Err(e) = self.core.ensure_children(parent) { reply.error(e); return; } let name = name.to_string_lossy(); - let st = self.state.lock().unwrap(); + let st = self.core.state.lock().unwrap(); let hit = st.children.get(&parent).and_then(|kids| { kids.iter().copied().find_map(|ino| { st.entries @@ -393,7 +724,7 @@ impl Filesystem for ProtonFs { } fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option, reply: ReplyAttr) { - let st = self.state.lock().unwrap(); + let st = self.core.state.lock().unwrap(); match st.entries.get(&ino.0) { Some(e) => { let attr = self.attr(ino.0, &e.node); @@ -412,11 +743,11 @@ impl Filesystem for ProtonFs { mut reply: ReplyDirectory, ) { let ino = ino.0; - if let Err(e) = self.ensure_children(ino) { + if let Err(e) = self.core.ensure_children(ino) { reply.error(e); return; } - let st = self.state.lock().unwrap(); + let st = self.core.state.lock().unwrap(); let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent); // "." and ".." occupy offsets 0 and 1; real children follow. @@ -449,7 +780,7 @@ impl Filesystem for ProtonFs { fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { let uid = { - let st = self.state.lock().unwrap(); + let st = self.core.state.lock().unwrap(); match st.entries.get(&ino.0) { Some(e) if e.node.is_file() => e.uid.clone(), Some(_) => { @@ -468,7 +799,7 @@ impl Filesystem for ProtonFs { reply.opened(FileHandle(0), FopenFlags::empty()); return; } - let mut st = self.state.lock().unwrap(); + let mut st = self.core.state.lock().unwrap(); let fh = st.next_fh; st.next_fh += 1; st.handles.insert( @@ -489,8 +820,8 @@ impl Filesystem for ProtonFs { _lock_owner: Option, reply: ReplyData, ) { - let uid = { - let st = self.state.lock().unwrap(); + let (uid, mtime, fsize) = { + let st = self.core.state.lock().unwrap(); // If the file is open for writing and its buffer is populated, serve // from there so reads see the in-flight (possibly unsaved) content. if let Some(h) = st.handles.values().find(|h| h.ino == ino.0 && h.seeded) { @@ -500,7 +831,9 @@ impl Filesystem for ProtonFs { return; } match st.entries.get(&ino.0) { - Some(e) if e.node.is_file() => e.uid.clone(), + Some(e) if e.node.is_file() => { + (e.uid.clone(), e.node.modification_time, node_size(&e.node)) + } Some(_) => { reply.error(Errno::EISDIR); return; @@ -511,9 +844,19 @@ impl Filesystem for ProtonFs { } } }; + // Pinned (or otherwise cached) files are served straight from disk. + if let Some(bytes) = self + .core + .cache + .read_range(&uid, mtime, fsize, offset, size as u64) + { + reply.data(&bytes); + return; + } match self + .core .rt - .block_on(self.client.download_range(&uid, offset, size as u64)) + .block_on(self.core.client.download_range(&uid, offset, size as u64)) { Ok(bytes) => reply.data(&bytes), Err(e) => { @@ -534,13 +877,13 @@ impl Filesystem for ProtonFs { reply: ReplyCreate, ) { let parent = parent.0; - if let Err(e) = self.ensure_children(parent) { + if let Err(e) = self.core.ensure_children(parent) { reply.error(e); return; } let name = name.to_string_lossy().into_owned(); let parent_uid = { - let st = self.state.lock().unwrap(); + let st = self.core.state.lock().unwrap(); match st.entries.get(&parent) { Some(e) => e.uid.clone(), None => { @@ -551,7 +894,7 @@ impl Filesystem for ProtonFs { }; // Create an empty file on the remote so it has a real uid immediately; // written bytes are buffered and sealed as a new revision on close. - let new_uid = match self.rt.block_on(self.client.upload_file( + let new_uid = match self.core.rt.block_on(self.core.client.upload_file( &parent_uid, &name, media_type_for(&name), @@ -564,14 +907,14 @@ impl Filesystem for ProtonFs { return; } }; - let node = match self.fetch_node(&new_uid) { + let node = match self.core.fetch_node(&new_uid) { Ok(n) => n, Err(e) => { reply.error(e); return; } }; - let mut st = self.state.lock().unwrap(); + let mut st = self.core.state.lock().unwrap(); let ino = st.intern(parent, node); if let Some(kids) = st.children.get_mut(&parent) && !kids.contains(&ino) @@ -603,7 +946,7 @@ impl Filesystem for ProtonFs { let fh = fh.0; // Partial writes need the file's current content; hydrate it once. let need_seed = { - let st = self.state.lock().unwrap(); + let st = self.core.state.lock().unwrap(); match st.handles.get(&fh) { Some(h) => !h.seeded, None => { @@ -614,6 +957,7 @@ impl Filesystem for ProtonFs { }; if need_seed { let uid = self + .core .state .lock() .unwrap() @@ -624,7 +968,7 @@ impl Filesystem for ProtonFs { reply.error(Errno::EBADF); return; }; - let base = match self.rt.block_on(self.client.download_file(&uid)) { + let base = match self.core.rt.block_on(self.core.client.download_file(&uid)) { Ok(b) => b, Err(e) => { warn!(%uid, error = %e, "seed write buffer failed"); @@ -632,7 +976,7 @@ impl Filesystem for ProtonFs { return; } }; - let mut st = self.state.lock().unwrap(); + let mut st = self.core.state.lock().unwrap(); if let Some(h) = st.handles.get_mut(&fh) && !h.seeded { @@ -642,7 +986,7 @@ impl Filesystem for ProtonFs { } let off = offset as usize; let new_len = { - let mut st = self.state.lock().unwrap(); + let mut st = self.core.state.lock().unwrap(); let Some(h) = st.handles.get_mut(&fh) else { reply.error(Errno::EBADF); return; @@ -686,7 +1030,7 @@ impl Filesystem for ProtonFs { let fh = fh.map(|f| f.0).filter(|&f| f != 0); let handled = match fh { Some(fh) => { - let mut st = self.state.lock().unwrap(); + let mut st = self.core.state.lock().unwrap(); match st.handles.get_mut(&fh) { Some(h) => { h.buf.resize(size as usize, 0); @@ -703,7 +1047,7 @@ impl Filesystem for ProtonFs { // Path-based truncate with no open write handle: resize the // remote content and seal it now. let uid = { - let st = self.state.lock().unwrap(); + let st = self.core.state.lock().unwrap(); match st.entries.get(&ino.0) { Some(e) if e.node.is_file() => e.uid.clone(), Some(_) => { @@ -719,7 +1063,7 @@ impl Filesystem for ProtonFs { let mut content = if size == 0 { Vec::new() } else { - match self.rt.block_on(self.client.download_file(&uid)) { + match self.core.rt.block_on(self.core.client.download_file(&uid)) { Ok(b) => b, Err(e) => { warn!(%uid, error = %e, "truncate base download failed"); @@ -729,15 +1073,25 @@ impl Filesystem for ProtonFs { } }; content.resize(size as usize, 0); - if let Err(e) = self.rt.block_on(self.client.upload_new_revision(&uid, &content)) { + if let Err(e) = self + .core + .rt + .block_on(self.core.client.upload_new_revision(&uid, &content)) + { error!(%uid, error = %e, "truncate upload failed"); reply.error(Errno::EIO); return; } + // Keep any pinned cache consistent with the new content. + if self.core.cache.is_pinned(&uid) { + let _ = self.core.cache.store(&uid, now_secs(), size, &content); + } else { + self.core.cache.evict(&uid); + } } - self.state.lock().unwrap().set_size(ino.0, size); + self.core.state.lock().unwrap().set_size(ino.0, size); } - let st = self.state.lock().unwrap(); + let st = self.core.state.lock().unwrap(); match st.entries.get(&ino.0) { Some(e) => { let attr = self.attr(ino.0, &e.node); @@ -748,14 +1102,14 @@ impl Filesystem for ProtonFs { } fn flush(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, _lock_owner: LockOwner, reply: ReplyEmpty) { - match self.commit(fh.0) { + match self.core.commit(fh.0) { Ok(()) => reply.ok(), Err(e) => reply.error(e), } } fn fsync(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, _datasync: bool, reply: ReplyEmpty) { - match self.commit(fh.0) { + match self.core.commit(fh.0) { Ok(()) => reply.ok(), Err(e) => reply.error(e), } @@ -771,8 +1125,8 @@ impl Filesystem for ProtonFs { _flush: bool, reply: ReplyEmpty, ) { - let res = self.commit(fh.0); - self.state.lock().unwrap().handles.remove(&fh.0); + let res = self.core.commit(fh.0); + self.core.state.lock().unwrap().handles.remove(&fh.0); match res { Ok(()) => reply.ok(), Err(e) => reply.error(e), @@ -797,13 +1151,13 @@ impl Filesystem for ProtonFs { reply: ReplyEntry, ) { let parent = parent.0; - if let Err(e) = self.ensure_children(parent) { + if let Err(e) = self.core.ensure_children(parent) { reply.error(e); return; } let name = name.to_string_lossy().into_owned(); let parent_uid = { - let st = self.state.lock().unwrap(); + let st = self.core.state.lock().unwrap(); match st.entries.get(&parent) { Some(e) => e.uid.clone(), None => { @@ -816,7 +1170,11 @@ impl Filesystem for ProtonFs { .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .ok(); - let new_uid = match self.rt.block_on(self.client.create_folder(&parent_uid, &name, now)) { + let new_uid = match self + .core + .rt + .block_on(self.core.client.create_folder(&parent_uid, &name, now)) + { Ok(u) => u, Err(e) => { error!(%parent_uid, name, error = %e, "create folder failed"); @@ -824,14 +1182,14 @@ impl Filesystem for ProtonFs { return; } }; - let node = match self.fetch_node(&new_uid) { + let node = match self.core.fetch_node(&new_uid) { Ok(n) => n, Err(e) => { reply.error(e); return; } }; - let mut st = self.state.lock().unwrap(); + let mut st = self.core.state.lock().unwrap(); let ino = st.intern(parent, node); if let Some(kids) = st.children.get_mut(&parent) && !kids.contains(&ino) @@ -856,7 +1214,7 @@ impl Filesystem for ProtonFs { let newparent = newparent.0; let name = name.to_string_lossy().into_owned(); let newname = newname.to_string_lossy().into_owned(); - let (_ino, uid) = match self.lookup_child(parent, &name) { + let (_ino, uid) = match self.core.lookup_child(parent, &name) { Ok(x) => x, Err(e) => { reply.error(e); @@ -865,12 +1223,12 @@ impl Filesystem for ProtonFs { }; // Move first if the parent changed, then rename if the name changed. if newparent != parent { - if let Err(e) = self.ensure_children(newparent) { + if let Err(e) = self.core.ensure_children(newparent) { reply.error(e); return; } let new_parent_uid = { - let st = self.state.lock().unwrap(); + let st = self.core.state.lock().unwrap(); match st.entries.get(&newparent) { Some(e) => e.uid.clone(), None => { @@ -879,14 +1237,21 @@ impl Filesystem for ProtonFs { } } }; - if let Err(e) = self.rt.block_on(self.client.move_node(&uid, &new_parent_uid)) { + if let Err(e) = self + .core + .rt + .block_on(self.core.client.move_node(&uid, &new_parent_uid)) + { error!(%uid, error = %e, "move failed"); reply.error(Errno::EIO); return; } } if newname != name - && let Err(e) = self.rt.block_on(self.client.rename_node(&uid, &newname, None)) + && let Err(e) = self + .core + .rt + .block_on(self.core.client.rename_node(&uid, &newname, None)) { error!(%uid, error = %e, "rename failed"); reply.error(Errno::EIO); @@ -894,11 +1259,79 @@ impl Filesystem for ProtonFs { } // Forget the node so it re-interns under its new parent, and drop the // destination listing so it re-enumerates on next access. - let mut st = self.state.lock().unwrap(); + let mut st = self.core.state.lock().unwrap(); st.forget(&uid); st.children.remove(&newparent); reply.ok(); } + + /// Expose a file's server-side thumbnail/preview as an extended attribute, so + /// a previewing client can fetch it without downloading the whole file. The + /// bytes are fetched on demand and cached; absence yields `ENODATA`. + fn getxattr(&self, _req: &Request, ino: INodeNo, name: &OsStr, size: u32, reply: ReplyXattr) { + let ttype = match name.to_str() { + Some(XATTR_THUMBNAIL) => ThumbnailType::Thumbnail, + Some(XATTR_PREVIEW) => ThumbnailType::Preview, + // Any other attribute simply does not exist on this filesystem. + _ => { + reply.error(Errno::ENODATA); + return; + } + }; + let bytes = match self.core.thumbnail(ino.0, ttype) { + Ok(Some(b)) => b, + Ok(None) => { + reply.error(Errno::ENODATA); + return; + } + Err(e) => { + reply.error(e); + return; + } + }; + let len = bytes.len() as u32; + // The xattr protocol: a zero `size` is a probe for the length; otherwise + // the caller's buffer must be large enough or it gets `ERANGE`. + if size == 0 { + reply.size(len); + } else if size < len { + reply.error(Errno::ERANGE); + } else { + reply.data(&bytes); + } + } + + /// Advertise the thumbnail/preview attribute names for regular files. The + /// names are listed unconditionally for files; a `getxattr` for one a given + /// file lacks returns `ENODATA`. + fn listxattr(&self, _req: &Request, ino: INodeNo, size: u32, reply: ReplyXattr) { + let is_file = { + let st = self.core.state.lock().unwrap(); + match st.entries.get(&ino.0) { + Some(e) => e.node.is_file(), + None => { + reply.error(Errno::ENOENT); + return; + } + } + }; + // xattr names are returned as a NUL-terminated, concatenated list. + let mut buf = Vec::new(); + if is_file { + for name in [XATTR_THUMBNAIL, XATTR_PREVIEW] { + buf.extend_from_slice(name.as_bytes()); + buf.push(0); + } + } + let len = buf.len() as u32; + if size == 0 { + reply.size(len); + } else if size < len { + reply.error(Errno::ERANGE); + } else { + reply.data(&buf); + } + } } /// Apply one remote event to the local cache and notify the kernel so it drops @@ -907,7 +1340,7 @@ impl Filesystem for ProtonFs { /// The cache is authoritative-by-absence: dropping a directory's `children` /// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so /// most events only need to invalidate listings rather than re-fetch eagerly. -fn apply_event(state: &Mutex, notifier: &Notifier, event: &DriveEvent) { +fn apply_event(state: &Mutex, content: &ContentCache, notifier: &Notifier, event: &DriveEvent) { match event { DriveEvent::NodeUpdated { node_uid, parent_node_uid, is_trashed, .. } => { let mut st = state.lock().unwrap(); @@ -915,6 +1348,7 @@ fn apply_event(state: &Mutex, notifier: &Notifier, event: &DriveEvent) { // Trashing makes a node vanish from its parent listing. let child = st.by_uid.get(node_uid).copied(); if let Some((parent, name)) = st.forget(node_uid) { + content.evict(node_uid); match child { Some(child) => { let _ = @@ -927,8 +1361,10 @@ fn apply_event(state: &Mutex, notifier: &Notifier, event: &DriveEvent) { } } else if let Some(&ino) = st.by_uid.get(node_uid) { // Known node changed: drop its cached attrs/data (and listing if - // it is a directory) so the next access re-fetches. + // it is a directory) so the next access re-fetches. Its content + // blob may now be stale, so evict it too. st.children.remove(&ino); + content.evict(node_uid); let _ = notifier.inval_inode(INodeNo(ino), 0, 0); } // A create (or move-in) shows up as a change to the parent listing; @@ -944,6 +1380,7 @@ fn apply_event(state: &Mutex, notifier: &Notifier, event: &DriveEvent) { let mut st = state.lock().unwrap(); // Capture the inode before `forget` clears the uid mapping. let child = st.by_uid.get(node_uid).copied(); + content.evict(node_uid); if let Some((parent, name)) = st.forget(node_uid) { match child { Some(child) => { @@ -980,6 +1417,7 @@ async fn run_event_sync( client: ProtonDriveClient, scope: DriveEventScopeId, state: Arc>, + content: Arc, notifier: Notifier, ) { // Seed: a `None` cursor yields a single `CursorAdvanced` at the server head. @@ -1006,28 +1444,151 @@ async fn run_event_sync( } debug!(count = events.len(), "applying remote events"); for event in &events { - apply_event(&state, ¬ifier, event); + apply_event(&state, &content, ¬ifier, event); } cursor = events.last().map(|e| e.id().clone()); } } +/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path +/// must live under `mountpoint`; a relative path is taken as already relative to +/// the mount root. +fn rel_to_mount(mountpoint: &Path, path: &str) -> Result { + let p = Path::new(path); + if p.is_absolute() { + p.strip_prefix(mountpoint) + .map(Path::to_path_buf) + .map_err(|_| format!("{path} is not under the mountpoint")) + } else { + Ok(p.to_path_buf()) + } +} + +/// Handle one control-socket connection: read a single JSON request line, +/// dispatch it against `core`, and write back a JSON response line. +fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) { + let mut reader = BufReader::new(match stream.try_clone() { + Ok(s) => s, + Err(e) => { + warn!(error = %e, "control: clone stream failed"); + return; + } + }); + let mut line = String::new(); + if reader.read_line(&mut line).is_err() || line.trim().is_empty() { + return; + } + let response = match serde_json::from_str::(line.trim()) { + Ok(CtlRequest::Status) => CtlResponse::Status { + username: username.to_string(), + mountpoint: mountpoint.display().to_string(), + pinned: core.cache.list_pins().len(), + }, + Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) { + Ok(rel) => match core.pin(&rel) { + Ok(name) => CtlResponse::Ok { message: format!("pinned {name}") }, + Err(e) => CtlResponse::Error { message: e }, + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) { + Ok(rel) => match core.unpin(&rel) { + Ok(name) => CtlResponse::Ok { message: format!("unpinned {name}") }, + Err(e) => CtlResponse::Error { message: e }, + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::ListPins) => CtlResponse::Pins { pins: core.cache.list_pins() }, + Ok(CtlRequest::ListDir { path }) => match rel_to_mount(mountpoint, &path) { + Ok(rel) => match core.list_dir(&rel) { + Ok(entries) => CtlResponse::Entries { entries }, + Err(e) => CtlResponse::Error { message: e }, + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::PhotosTimeline { offset, limit }) => { + match core.photos_timeline(offset, limit) { + Ok(Some(items)) => CtlResponse::Photos { available: true, items }, + Ok(None) => CtlResponse::Photos { available: false, items: Vec::new() }, + Err(e) => CtlResponse::Error { message: e }, + } + } + Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) { + Some(u) => match core.open_photo(&u) { + Ok(p) => CtlResponse::FilePath { path: p.display().to_string() }, + Err(e) => CtlResponse::Error { message: e }, + }, + None => CtlResponse::Error { message: format!("bad uid: {uid}") }, + }, + Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) { + Ok(rel) => match core.open_file(&rel) { + Ok(p) => CtlResponse::FilePath { path: p.display().to_string() }, + Err(e) => CtlResponse::Error { message: e }, + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Err(e) => CtlResponse::Error { message: format!("bad request: {e}") }, + }; + let mut out = match serde_json::to_vec(&response) { + Ok(v) => v, + Err(e) => { + warn!(error = %e, "control: serialize response failed"); + return; + } + }; + out.push(b'\n'); + let mut stream = stream; + let _ = stream.write_all(&out); +} + +/// Listen on the control socket, serving one CLI command per connection. Runs on +/// its own thread; returns only if the listener itself fails. +fn run_control_socket(core: Core, username: String, mountpoint: PathBuf, listener: UnixListener) { + info!("control socket listening"); + for conn in listener.incoming() { + match conn { + Ok(stream) => handle_control_conn(&core, &username, &mountpoint, stream), + Err(e) => { + warn!(error = %e, "control: accept failed"); + } + } + } +} + /// Mount the filesystem at `mountpoint` and block until it is unmounted. /// /// Fetches the My Files root up front (so an auth/network failure surfaces -/// before the kernel mount), then spawns the Phase 2 event-sync task and takes -/// over the calling thread in the FUSE session loop. `rt` must be a handle to a -/// *running* multi-threaded runtime. +/// before the kernel mount), then spawns the Phase 2 event-sync task, the +/// Phase 4 control socket, and takes over the calling thread in the FUSE session +/// loop. `rt` must be a handle to a *running* multi-threaded runtime. pub fn mount( client: ProtonDriveClient, rt: tokio::runtime::Handle, mountpoint: &Path, + cache: ContentCache, + control_socket: &Path, + username: String, ) -> std::io::Result<()> { let root = rt .block_on(client.get_my_files_folder()) .map_err(|e| std::io::Error::other(format!("fetch My Files root: {e}")))?; let scope = root.tree_event_scope_id(); + let core = Core { + client: client.clone(), + rt: rt.clone(), + state: Arc::new(Mutex::new(State { + entries: HashMap::new(), + by_uid: HashMap::new(), + children: HashMap::new(), + next_ino: 2, + handles: HashMap::new(), + next_fh: 1, + })), + cache: Arc::new(cache), + timeline: Arc::new(Mutex::new(None)), + }; + let mut config = Config::default(); config.mount_options = vec![ MountOption::FSName("protondrive".to_string()), @@ -1036,8 +1597,20 @@ pub fn mount( ]; info!(mountpoint = %mountpoint.display(), "mounting Proton Drive"); - let fs = ProtonFs::new(client.clone(), rt.clone(), root); - let shared = fs.shared_state(); + // Bind the control socket before the FUSE session takes over the thread. A + // stale socket file from a previous run would block the bind, so clear it. + let _ = std::fs::remove_file(control_socket); + let listener = UnixListener::bind(control_socket)?; + { + let core = core.clone(); + let username = username.clone(); + let mountpoint = mountpoint.to_path_buf(); + std::thread::Builder::new() + .name("pdfs-control".into()) + .spawn(move || run_control_socket(core, username, mountpoint, listener))?; + } + + let fs = ProtonFs::new(core.clone(), root); // Build the session explicitly (not `mount2`) so we can grab a `Notifier` // for the event task. `spawn` runs the session loop on its own thread; @@ -1045,7 +1618,9 @@ pub fn mount( // `mount`'s blocking contract. let session = Session::new(fs, mountpoint, &config)?.spawn()?; let notifier = session.notifier(); - rt.spawn(run_event_sync(client, scope, shared, notifier)); + rt.spawn(run_event_sync(client, scope, core.state, core.cache, notifier)); - session.join() + let result = session.join(); + let _ = std::fs::remove_file(control_socket); + result } diff --git a/crates/pdfs-gui/Cargo.toml b/crates/pdfs-gui/Cargo.toml new file mode 100644 index 0000000..e6da753 --- /dev/null +++ b/crates/pdfs-gui/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "pdfs-gui" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +rust-version.workspace = true + +[[bin]] +name = "pdfs-tray" +path = "src/main.rs" + +[[bin]] +name = "pdfs-app" +path = "src/app.rs" + +[dependencies] +pdfs-core.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +anyhow.workspace = true +tokio.workspace = true +ksni = "0.2" +gtk4 = { version = "0.9", features = ["v4_12"] } +adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] } +async-channel = "2" diff --git a/crates/pdfs-gui/src/app.rs b/crates/pdfs-gui/src/app.rs new file mode 100644 index 0000000..8e4ecdc --- /dev/null +++ b/crates/pdfs-gui/src/app.rs @@ -0,0 +1,1307 @@ +//! `pdfs-app` — GTK4 / libadwaita desktop window for the Proton Drive Linux +//! client. +//! +//! Slice 2 of the GUI phase: the settings/management window that complements the +//! [`pdfs-tray`](crate) SNI icon. It presents four things in a Proton-purple, +//! Google-Drive-style layout: +//! +//! * a **login form** (email / password / optional 2FA) driving +//! [`pdfs_core::auth::login`]; +//! * a **mount toggle** that spawns `pdfs mount` / `fusermount3 -u`; +//! * a **cache usage** read-out from the on-disk [`ContentCache`]; and +//! * the **pin list** with per-file unpin. +//! +//! State the GUI only displays (cache size, pins, login identity) is read +//! straight off disk via `pdfs-core`, so the window is useful even with no mount +//! daemon running; live mount status comes from the daemon over the control +//! socket the CLI and tray already speak. + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::rc::Rc; +use std::time::Duration; + +use adw::prelude::*; +use gtk4::gio; +use gtk4::glib; +use gtk4::glib::{BoxedAnyObject, SignalHandlerId}; + +use pdfs_core::auth; +use pdfs_core::cache::ContentCache; +use pdfs_core::config::{AppDirs, DEFAULT_CACHE_BUDGET_BYTES}; +use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, send}; + +/// How many photos to pull per [`Request::PhotosTimeline`] page. +const PHOTOS_PAGE: usize = 60; + +const APP_ID: &str = "io.narl.proton-drive-linux"; +/// Proton brand purple, applied as the libadwaita accent so switches, buttons, +/// links and the storage bar all pick it up. +const PROTON_PURPLE: &str = "#6d4aff"; +/// How often the window re-reads mount status, cache usage and the pin list. +const REFRESH_INTERVAL: Duration = Duration::from_secs(2); + +/// All widgets the periodic refresh and the action handlers mutate, plus the +/// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout +/// closure share one instance. +struct Ui { + dirs: AppDirs, + stack: adw::ViewStack, + // Login page. + email: adw::EntryRow, + password: adw::PasswordEntryRow, + totp: adw::EntryRow, + login_button: gtk4::Button, + login_status: gtk4::Label, + // Main page. + account_row: adw::ActionRow, + mount_row: adw::SwitchRow, + mount_handler: RefCell>, + cache_bar: gtk4::ProgressBar, + cache_label: gtk4::Label, + pins_group: adw::PreferencesGroup, + /// Rows currently shown under [`Self::pins_group`], so a refresh can clear + /// the previous batch before re-adding. + pin_rows: RefCell>, + /// Whether the last refresh saw a live mount daemon. Gates the unpin + /// buttons, which need the daemon to evict + re-hydrate. + mounted: RefCell, + /// Bottom switcher between the Account / Files / Photos pages; hidden on the + /// login page so the user can't jump to pages that need a session. + switcher: adw::ViewSwitcherBar, + // Files (browser) page. + /// Shared model behind the grid and column views; repopulated per directory. + browser_model: gio::ListStore, + browser_back: gtk4::Button, + browser_crumb: gtk4::Label, + browser_status: gtk4::Label, + /// Mountpoint-relative path the browser is showing (empty = root). + browser_path: RefCell, + // Photos (gallery) page. + gallery_model: gio::ListStore, + gallery_status: gtk4::Label, + gallery_more: gtk4::Button, +} + +fn main() -> glib::ExitCode { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + + let app = adw::Application::builder().application_id(APP_ID).build(); + app.connect_startup(|_| load_proton_theme()); + app.connect_activate(build_window); + app.run() +} + +/// Install a CSS provider that overrides libadwaita's accent colour with Proton +/// purple, app-wide. Named-colour overrides recolour the stock widgets (switch, +/// buttons, progress fill) without per-widget styling. +fn load_proton_theme() { + let css = format!( + "@define-color accent_bg_color {PROTON_PURPLE};\n\ + @define-color accent_color {PROTON_PURPLE};\n\ + .brand-title {{ font-size: 1.6rem; font-weight: 800; }}\n\ + .brand-icon {{ color: {PROTON_PURPLE}; }}\n\ + .file-grid {{ padding: 6px; }}\n\ + .file-tile {{ padding: 8px; border-radius: 10px; }}\n\ + .file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n" + ); + let provider = gtk4::CssProvider::new(); + provider.load_from_string(&css); + if let Some(display) = gtk4::gdk::Display::default() { + gtk4::style_context_add_provider_for_display( + &display, + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} + +/// Build the application window, wire the two pages, kick off the refresh loop, +/// and present it. +fn build_window(app: &adw::Application) { + let dirs = match AppDirs::new() { + Ok(d) => d, + Err(e) => { + tracing::error!("cannot resolve app dirs: {e}"); + return; + } + }; + + let stack = adw::ViewStack::new(); + let (login_page, login_widgets) = build_login_page(); + let (main_page, main_widgets) = build_main_page(); + let (browser_page, browser_widgets) = build_browser_page(); + let (gallery_page, gallery_widgets) = build_gallery_page(); + // Login has no title, so it never appears in the switcher; the three signed-in + // pages are titled + iconed so the bottom switcher lists them. + stack.add_named(&login_page, Some("login")); + stack.add_titled_with_icon(&main_page, Some("main"), "Account", "avatar-default-symbolic"); + stack.add_titled_with_icon(&browser_page, Some("browser"), "Files", "folder-symbolic"); + stack.add_titled_with_icon( + &gallery_page, + Some("gallery"), + "Photos", + "image-x-generic-symbolic", + ); + + let switcher = adw::ViewSwitcherBar::builder().stack(&stack).build(); + + let ui = Rc::new(Ui { + dirs, + stack: stack.clone(), + email: login_widgets.0, + password: login_widgets.1, + totp: login_widgets.2, + login_button: login_widgets.3, + login_status: login_widgets.4, + account_row: main_widgets.0, + mount_row: main_widgets.1, + mount_handler: RefCell::new(None), + cache_bar: main_widgets.2, + cache_label: main_widgets.3, + pins_group: main_widgets.4, + pin_rows: RefCell::new(Vec::new()), + mounted: RefCell::new(false), + switcher: switcher.clone(), + browser_model: browser_widgets.0, + browser_back: browser_widgets.1, + browser_crumb: browser_widgets.2, + browser_status: browser_widgets.3, + browser_path: RefCell::new(String::new()), + gallery_model: gallery_widgets.0, + gallery_status: gallery_widgets.1, + gallery_more: gallery_widgets.2, + }); + + wire_login(&ui); + wire_logout(&ui, &main_widgets.5); + wire_mount_switch(&ui); + wire_browser(&ui, &browser_widgets.4, &browser_widgets.5); + wire_gallery(&ui, &gallery_widgets.3); + + // Lazily load the Files / Photos pages the first time they're shown, so the + // network round-trip only happens on demand rather than on every refresh. + let ui_nav = ui.clone(); + stack.connect_visible_child_name_notify(move |st| match st.visible_child_name().as_deref() { + Some("browser") => load_browser(&ui_nav), + Some("gallery") => load_gallery(&ui_nav, false), + _ => {} + }); + + // Header with a Proton-branded title; the content is the page stack inside a + // clamp so it stays comfortably narrow on a wide window, Google-Drive style. + let header = adw::HeaderBar::new(); + let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let icon = gtk4::Image::from_icon_name("folder-remote-symbolic"); + icon.add_css_class("brand-icon"); + brand.append(&icon); + brand.append(>k4::Label::new(Some("Proton Drive"))); + header.set_title_widget(Some(&brand)); + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&stack)); + toolbar.add_bottom_bar(&switcher); + + let window = adw::ApplicationWindow::builder() + .application(app) + .title("Proton Drive") + .default_width(760) + .default_height(620) + .content(&toolbar) + .build(); + + refresh(&ui); + // Periodic refresh while the window lives. The closure holds a strong `Rc`; + // it is dropped when the source is removed on window close. + let ui_tick = ui.clone(); + let source = glib::timeout_add_local(REFRESH_INTERVAL, move || { + refresh(&ui_tick); + glib::ControlFlow::Continue + }); + let cell = RefCell::new(Some(source)); + window.connect_close_request(move |_| { + if let Some(id) = cell.borrow_mut().take() { + id.remove(); + } + glib::Propagation::Proceed + }); + + window.present(); +} + +/// The login page: an email row, password row, optional 2FA row, a primary +/// "Sign in" button and a status label, centred in a clamp. +#[allow(clippy::type_complexity)] +fn build_login_page() -> ( + gtk4::Widget, + ( + adw::EntryRow, + adw::PasswordEntryRow, + adw::EntryRow, + gtk4::Button, + gtk4::Label, + ), +) { + let group = adw::PreferencesGroup::builder() + .title("Sign in to Proton") + .description("Use your Proton account to connect Drive.") + .build(); + + let email = adw::EntryRow::builder().title("Email or username").build(); + let password = adw::PasswordEntryRow::builder().title("Password").build(); + let totp = adw::EntryRow::builder() + .title("Two-factor code (if enabled)") + .build(); + group.add(&email); + group.add(&password); + group.add(&totp); + + let login_button = gtk4::Button::builder() + .label("Sign in") + .halign(gtk4::Align::Center) + .build(); + login_button.add_css_class("suggested-action"); + login_button.add_css_class("pill"); + + let login_status = gtk4::Label::builder() + .wrap(true) + .justify(gtk4::Justification::Center) + .build(); + login_status.add_css_class("dim-label"); + + let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + let logo = gtk4::Image::from_icon_name("folder-remote-symbolic"); + logo.set_pixel_size(64); + logo.add_css_class("brand-icon"); + let title = gtk4::Label::new(Some("Proton Drive")); + title.add_css_class("brand-title"); + header.append(&logo); + header.append(&title); + header.set_margin_bottom(12); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 16); + inner.set_margin_top(32); + inner.set_margin_bottom(32); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&header); + inner.append(&group); + inner.append(&login_button); + inner.append(&login_status); + + let clamp = adw::Clamp::builder() + .maximum_size(420) + .child(&inner) + .build(); + let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build(); + + ( + scroll.upcast(), + (email, password, totp, login_button, login_status), + ) +} + +/// The main (logged-in) page: account header, mount toggle, storage usage and +/// the pin list. Returns the widgets the refresh loop updates plus the logout +/// button to wire. +#[allow(clippy::type_complexity)] +fn build_main_page() -> ( + gtk4::Widget, + ( + adw::ActionRow, + adw::SwitchRow, + gtk4::ProgressBar, + gtk4::Label, + adw::PreferencesGroup, + gtk4::Button, + ), +) { + // Account group: identity + sign-out. + let account_group = adw::PreferencesGroup::new(); + let account_row = adw::ActionRow::builder().title("Not signed in").build(); + let avatar = adw::Avatar::new(40, None, true); + account_row.add_prefix(&avatar); + let logout_button = gtk4::Button::builder() + .label("Sign out") + .valign(gtk4::Align::Center) + .build(); + logout_button.add_css_class("flat"); + account_row.add_suffix(&logout_button); + account_group.add(&account_row); + + // Mount group: a switch row driving the FUSE mount. + let mount_group = adw::PreferencesGroup::builder().title("Drive").build(); + let mount_row = adw::SwitchRow::builder() + .title("Mount Proton Drive") + .subtitle("Not mounted") + .build(); + mount_group.add(&mount_row); + + // Storage group: a progress bar + "X of Y used" label. + let storage_group = adw::PreferencesGroup::builder() + .title("Storage") + .description("Local cache for pinned and recently opened files.") + .build(); + let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + storage_box.set_margin_top(6); + storage_box.set_margin_bottom(6); + let cache_bar = gtk4::ProgressBar::new(); + let cache_label = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .build(); + cache_label.add_css_class("dim-label"); + storage_box.append(&cache_bar); + storage_box.append(&cache_label); + storage_group.add(&storage_box); + + // Pins group: filled in by refresh. + let pins_group = adw::PreferencesGroup::builder() + .title("Pinned files") + .description("Kept available offline on this device.") + .build(); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 18); + inner.set_margin_top(18); + inner.set_margin_bottom(18); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&account_group); + inner.append(&mount_group); + inner.append(&storage_group); + inner.append(&pins_group); + + let clamp = adw::Clamp::builder().maximum_size(560).child(&inner).build(); + let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build(); + + ( + scroll.upcast(), + ( + account_row, + mount_row, + cache_bar, + cache_label, + pins_group, + logout_button, + ), + ) +} + +/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker +/// thread, and report the outcome back on the main loop. +fn wire_login(ui: &Rc) { + let ui = ui.clone(); + let button = ui.login_button.clone(); + button.connect_clicked(move |_| { + let username = ui.email.text().to_string(); + let password = ui.password.text().to_string(); + let totp = ui.totp.text().to_string(); + if username.is_empty() || password.is_empty() { + ui.login_status.set_text("Enter your email and password."); + return; + } + + ui.login_button.set_sensitive(false); + ui.login_status.set_text("Signing in…"); + let rx = spawn_login(username, password, totp); + + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await.unwrap_or_else(|_| Err("login cancelled".into())); + ui.login_button.set_sensitive(true); + match result { + Ok(()) => { + ui.login_status.set_text(""); + ui.password.set_text(""); + ui.totp.set_text(""); + refresh(&ui); + } + Err(e) => ui.login_status.set_text(&format!("Sign-in failed: {e}")), + } + }); + }); +} + +/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio +/// runtime, returning a channel that yields the result once. The TOTP closure +/// supplies whatever was typed in the 2FA field, erroring if the account needs a +/// code but none was entered. +fn spawn_login( + username: String, + password: String, + totp: String, +) -> async_channel::Receiver> { + let (tx, rx) = async_channel::bounded(1); + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send_blocking(Err(e.to_string())); + return; + } + }; + let result = rt.block_on(async move { + auth::login(&username, &password, || { + let code = totp.trim(); + if code.is_empty() { + Err(pdfs_core::Error::Other("two-factor code required".into())) + } else { + Ok(code.to_string()) + } + }) + .await + .map_err(|e| e.to_string()) + }); + let _ = tx.send_blocking(result); + }); + rx +} + +/// Connect the sign-out button: forget the stored session and drop back to the +/// login page. Unmounts first so the daemon isn't left without credentials. +fn wire_logout(ui: &Rc, button: >k4::Button) { + let ui = ui.clone(); + button.connect_clicked(move |_| { + if *ui.mounted.borrow() { + unmount(&ui.dirs.default_mountpoint()); + } + if let Err(e) = auth::logout() { + tracing::error!("logout failed: {e}"); + } + refresh(&ui); + }); +} + +/// Connect the mount switch. The handler id is stored so the refresh loop can +/// block it while syncing the switch to the real mount state (otherwise setting +/// `active` would re-trigger a mount/unmount). +fn wire_mount_switch(ui: &Rc) { + let ui_for_handler = ui.clone(); + let handler = ui.mount_row.connect_active_notify(move |row| { + let mountpoint = ui_for_handler.dirs.default_mountpoint(); + if row.is_active() { + spawn_mount(); + } else { + unmount(&mountpoint); + } + }); + *ui.mount_handler.borrow_mut() = Some(handler); +} + +/// Re-read mount status, login identity, cache usage and the pin list, and +/// repaint the window. Cheap, synchronous local reads — safe on the main loop. +fn refresh(ui: &Rc) { + // Login identity decides which page is shown. + let session = auth::load().ok(); + match &session { + Some(s) => { + // Only pull the user onto "main" when they're sitting on the login + // page; otherwise leave whichever signed-in page they navigated to. + if ui.stack.visible_child_name().as_deref() == Some("login") { + ui.stack.set_visible_child_name("main"); + } + ui.switcher.set_reveal(true); + ui.account_row.set_title(&s.username); + ui.account_row.set_subtitle("Proton account"); + } + None => { + ui.stack.set_visible_child_name("login"); + ui.switcher.set_reveal(false); + return; + } + } + + // Live mount status from the daemon, falling back to "not mounted". + let socket = ui.dirs.control_socket(); + let mount = match send(&socket, &Request::Status) { + Ok(Response::Status { mountpoint, .. }) => Some(mountpoint), + _ => None, + }; + *ui.mounted.borrow_mut() = mount.is_some(); + + // Sync the switch without re-triggering its handler. + if let Some(id) = ui.mount_handler.borrow().as_ref() { + ui.mount_row.block_signal(id); + ui.mount_row.set_active(mount.is_some()); + ui.mount_row.unblock_signal(id); + } + ui.mount_row.set_subtitle(&match &mount { + Some(mp) => format!("Mounted at {mp}"), + None => "Not mounted".into(), + }); + + // Cache usage + pins, read straight off disk so they work offline too. + let cache = ContentCache::open( + ui.dirs.content_cache_dir(), + ui.dirs.pins_path(), + DEFAULT_CACHE_BUDGET_BYTES, + ); + match cache { + Ok(cache) => { + let used = cache.usage(); + let budget = cache.budget(); + let fraction = if budget == 0 { + 0.0 + } else { + (used as f64 / budget as f64).min(1.0) + }; + ui.cache_bar.set_fraction(fraction); + ui.cache_label.set_text(&format!( + "{} of {} used", + human_bytes(used), + human_bytes(budget) + )); + repaint_pins(ui, &cache.list_pins()); + } + Err(e) => ui.cache_label.set_text(&format!("Cache unavailable: {e}")), + } +} + +/// Replace the rows under the pins group with one [`adw::ActionRow`] per pin, +/// each with an unpin button enabled only while a mount daemon is running. +fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin]) { + for row in ui.pin_rows.borrow_mut().drain(..) { + ui.pins_group.remove(&row); + } + if pins.is_empty() { + let row = adw::ActionRow::builder() + .title("No pinned files") + .subtitle("Right-click a file in the mount to keep it offline.") + .build(); + ui.pins_group.add(&row); + ui.pin_rows.borrow_mut().push(row); + return; + } + + let mounted = *ui.mounted.borrow(); + for pin in pins { + let name = Path::new(&pin.path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&pin.path) + .to_string(); + let row = adw::ActionRow::builder() + .title(&name) + .subtitle(&pin.path) + .build(); + let icon = gtk4::Image::from_icon_name("emblem-documents-symbolic"); + row.add_prefix(&icon); + + let unpin = gtk4::Button::builder() + .icon_name("user-trash-symbolic") + .valign(gtk4::Align::Center) + .tooltip_text("Unpin (remove offline copy)") + .sensitive(mounted) + .build(); + unpin.add_css_class("flat"); + let ui_btn = ui.clone(); + let path = pin.path.clone(); + unpin.connect_clicked(move |_| { + let socket = ui_btn.dirs.control_socket(); + match send(&socket, &Request::Unpin { path: path.clone() }) { + Ok(Response::Error { message }) => tracing::error!("unpin failed: {message}"), + Ok(_) => refresh(&ui_btn), + Err(e) => tracing::error!("unpin request failed: {e}"), + } + }); + row.add_suffix(&unpin); + + ui.pins_group.add(&row); + ui.pin_rows.borrow_mut().push(row); + } +} + +/// Run one blocking control-socket round-trip on a worker thread, returning a +/// channel that yields the [`Response`] once. Browser/gallery requests reach the +/// network through the daemon, so they must not block the GTK main loop the way +/// the cheap [`Request::Status`] poll in [`refresh`] can. +fn spawn_request(socket: PathBuf, req: Request) -> async_channel::Receiver> { + let (tx, rx) = async_channel::bounded(1); + std::thread::spawn(move || { + let result = send(&socket, &req).map_err(|e| e.to_string()); + let _ = tx.send_blocking(result); + }); + rx +} + +/// The Files page: a Nautilus-style file manager. A back/breadcrumb header with +/// a grid/list view toggle sits over a [`gtk4::Stack`] that swaps between an +/// **icon grid** ([`gtk4::GridView`]) and a **column list** ([`gtk4::ColumnView`] +/// with Name / Size / Modified columns). Both views are driven by one shared +/// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`DirEntry`]s, so a directory +/// load repopulates the model once and both views update. +/// +/// The factories that render entries — and the columns — need the [`Ui`] handle +/// for activation and the right-click menu, so they're installed later in +/// [`wire_browser`]; this builder only assembles the empty widgets. +fn build_browser_page() -> ( + gtk4::Widget, + ( + gio::ListStore, + gtk4::Button, + gtk4::Label, + gtk4::Label, + gtk4::GridView, + gtk4::ColumnView, + ), +) { + let model = gio::ListStore::new::(); + + let back = gtk4::Button::builder() + .icon_name("go-previous-symbolic") + .tooltip_text("Up one folder") + .valign(gtk4::Align::Center) + .sensitive(false) + .build(); + back.add_css_class("flat"); + let crumb = gtk4::Label::builder() + .label("Proton Drive") + .halign(gtk4::Align::Start) + .hexpand(true) + .ellipsize(gtk4::pango::EllipsizeMode::Start) + .build(); + crumb.add_css_class("heading"); + + // Linked grid/list toggle, top-right, Nautilus-style. + let grid_toggle = gtk4::ToggleButton::builder() + .icon_name("view-grid-symbolic") + .tooltip_text("Grid view") + .active(true) + .build(); + let list_toggle = gtk4::ToggleButton::builder() + .icon_name("view-list-symbolic") + .tooltip_text("List view") + .build(); + list_toggle.set_group(Some(&grid_toggle)); + let toggles = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + toggles.add_css_class("linked"); + toggles.append(&grid_toggle); + toggles.append(&list_toggle); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.append(&back); + header.append(&crumb); + header.append(&toggles); + + let status = gtk4::Label::builder().wrap(true).build(); + status.add_css_class("dim-label"); + status.set_visible(false); + + // Icon grid. + let grid = gtk4::GridView::builder() + .model(>k4::SingleSelection::new(Some(model.clone()))) + .min_columns(2) + .max_columns(10) + .build(); + grid.add_css_class("file-grid"); + let grid_scroll = gtk4::ScrolledWindow::builder().vexpand(true).child(&grid).build(); + + // Column list. + let column_view = gtk4::ColumnView::builder() + .model(>k4::SingleSelection::new(Some(model.clone()))) + .build(); + column_view.add_css_class("data-table"); + let column_scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&column_view) + .build(); + + // Stack swapped by the toggle buttons. + let view_stack = gtk4::Stack::new(); + view_stack.set_vexpand(true); + view_stack.add_named(&grid_scroll, Some("grid")); + view_stack.add_named(&column_scroll, Some("list")); + let vs = view_stack.clone(); + grid_toggle.connect_toggled(move |b| { + if b.is_active() { + vs.set_visible_child_name("grid"); + } + }); + let vs = view_stack.clone(); + list_toggle.connect_toggled(move |b| { + if b.is_active() { + vs.set_visible_child_name("list"); + } + }); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&header); + inner.append(&status); + inner.append(&view_stack); + + (inner.upcast(), (model, back, crumb, status, grid, column_view)) +} + +/// Install the entry factories, columns, activation handlers and the back +/// button. Split out from [`build_browser_page`] because every renderer needs +/// the [`Ui`] handle to open entries and raise the context menu. +fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k4::ColumnView) { + // Back: pop one path segment and reload. + let ui_back = ui.clone(); + ui.browser_back.clone().connect_clicked(move |_| { + { + let mut path = ui_back.browser_path.borrow_mut(); + *path = match path.rfind('/') { + Some(i) => path[..i].to_string(), + None => String::new(), + }; + } + load_browser(&ui_back); + }); + + // Grid tiles: a big icon over an ellipsized name, with a right-click menu. + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup({ + let ui = ui.clone(); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let icon = gtk4::Image::builder().pixel_size(64).build(); + let label = gtk4::Label::builder() + .ellipsize(gtk4::pango::EllipsizeMode::End) + .justify(gtk4::Justification::Center) + .max_width_chars(13) + .width_chars(13) + .wrap(true) + .lines(2) + .build(); + let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + tile.add_css_class("file-tile"); + tile.append(&icon); + tile.append(&label); + attach_context_menu(&ui, item, &tile); + item.set_child(Some(&tile)); + } + }); + factory.connect_bind(|_, item| { + let item = item.downcast_ref::().unwrap(); + let tile = item.child().and_downcast::().unwrap(); + let icon = tile.first_child().and_downcast::().unwrap(); + let label = tile.last_child().and_downcast::().unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let entry = obj.borrow::(); + icon.set_icon_name(Some(icon_base_for(&entry))); + label.set_label(&display_name(&entry)); + }); + grid.set_factory(Some(&factory)); + + let ui_grid = ui.clone(); + grid.connect_activate(move |grid, pos| { + if let Some(entry) = entry_at(grid.model().as_ref(), pos) { + activate_entry(&ui_grid, &entry); + } + }); + + // Column list: Name (icon + label, right-clickable), Size, Modified. + column_view.append_column(&name_column(ui)); + column_view.append_column(&text_column("Size", |e| { + if e.is_dir { + "—".to_string() + } else { + human_bytes(e.size) + } + })); + column_view.append_column(&text_column("Modified", |e| format_modified(e.modified))); + + let ui_col = ui.clone(); + column_view.connect_activate(move |view, pos| { + if let Some(entry) = entry_at(view.model().as_ref(), pos) { + activate_entry(&ui_col, &entry); + } + }); +} + +/// Build the Name column: a small icon plus the (star-prefixed when pinned) name, +/// with the same right-click menu the grid tiles carry. +fn name_column(ui: &Rc) -> gtk4::ColumnViewColumn { + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup({ + let ui = ui.clone(); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let icon = gtk4::Image::builder().pixel_size(16).build(); + let label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + cell.append(&icon); + cell.append(&label); + attach_context_menu(&ui, item, &cell); + item.set_child(Some(&cell)); + } + }); + factory.connect_bind(|_, item| { + let item = item.downcast_ref::().unwrap(); + let cell = item.child().and_downcast::().unwrap(); + let icon = cell.first_child().and_downcast::().unwrap(); + let label = cell.last_child().and_downcast::().unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let entry = obj.borrow::(); + icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry)))); + label.set_label(&display_name(&entry)); + }); + let column = gtk4::ColumnViewColumn::new(Some("Name"), Some(factory)); + column.set_expand(true); + column +} + +/// Build a trailing text column whose cell text is derived from each [`DirEntry`] +/// by `render`. +fn text_column( + title: &str, + render: impl Fn(&DirEntry) -> String + 'static, +) -> gtk4::ColumnViewColumn { + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let item = item.downcast_ref::().unwrap(); + let label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + label.add_css_class("dim-label"); + item.set_child(Some(&label)); + }); + factory.connect_bind(move |_, item| { + let item = item.downcast_ref::().unwrap(); + let label = item.child().and_downcast::().unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let entry = obj.borrow::(); + label.set_label(&render(&entry)); + }); + gtk4::ColumnViewColumn::new(Some(title), Some(factory)) +} + +/// Attach a secondary-button [`gtk4::GestureClick`] to a cell that pops a context +/// menu for whatever entry the owning `item` is currently bound to. Capturing the +/// [`gtk4::ListItem`] (rather than a snapshot of the entry) keeps the menu correct +/// as the view recycles cells while scrolling. +fn attach_context_menu(ui: &Rc, item: >k4::ListItem, anchor: >k4::Box) { + let gesture = gtk4::GestureClick::new(); + gesture.set_button(gtk4::gdk::BUTTON_SECONDARY); + let ui = ui.clone(); + let item = item.clone(); + let target = anchor.clone(); + gesture.connect_pressed(move |_, _, x, y| { + if let Some(obj) = item.item().and_downcast::() { + let entry = obj.borrow::().clone(); + show_context_menu(&ui, &entry, &target, x, y); + } + }); + anchor.add_controller(gesture); +} + +/// Pop a context menu next to `anchor` at the click point, offering Open and a +/// Pin/Unpin toggle (files only). Built fresh per click because the items are +/// entry-specific; it unparents itself once dismissed. +fn show_context_menu(ui: &Rc, entry: &DirEntry, anchor: >k4::Box, x: f64, y: f64) { + let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + let popover = gtk4::Popover::builder() + .has_arrow(false) + .position(gtk4::PositionType::Bottom) + .pointing_to(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)) + .child(&menu) + .build(); + popover.set_parent(anchor); + popover.connect_closed(|p| p.unparent()); + + let open = menu_item("Open", "document-open-symbolic"); + let ui_open = ui.clone(); + let entry_open = entry.clone(); + let pop = popover.clone(); + open.connect_clicked(move |_| { + pop.popdown(); + activate_entry(&ui_open, &entry_open); + }); + menu.append(&open); + + if !entry.is_dir { + let (label, icon) = if entry.pinned { + ("Unpin", "non-starred-symbolic") + } else { + ("Keep offline", "starred-symbolic") + }; + let pin = menu_item(label, icon); + let ui_pin = ui.clone(); + let entry_pin = entry.clone(); + let pop = popover.clone(); + pin.connect_clicked(move |_| { + pop.popdown(); + toggle_pin(&ui_pin, &entry_pin); + }); + menu.append(&pin); + } + + popover.popup(); +} + +/// A left-aligned, flat icon+label button for the context menu. +fn menu_item(label: &str, icon: &str) -> gtk4::Button { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + row.append(>k4::Image::from_icon_name(icon)); + row.append( + >k4::Label::builder() + .label(label) + .halign(gtk4::Align::Start) + .hexpand(true) + .build(), + ); + let button = gtk4::Button::builder().child(&row).build(); + button.add_css_class("flat"); + button +} + +/// Fetch the [`DirEntry`] backing the model item at `pos`, if any. +fn entry_at(model: Option<&impl IsA>, pos: u32) -> Option { + let obj = model?.item(pos).and_downcast::()?; + let entry = obj.borrow::().clone(); + Some(entry) +} + +/// Open an entry the Nautilus way: folders descend, files download-and-open. +fn activate_entry(ui: &Rc, entry: &DirEntry) { + let rel = entry_rel(ui, entry); + if entry.is_dir { + *ui.browser_path.borrow_mut() = rel; + load_browser(ui); + } else { + let rx = spawn_request(ui.dirs.control_socket(), Request::OpenFile { path: rel }); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::FilePath { path })) => open_path(&path), + Ok(Ok(Response::Error { message })) => tracing::error!("open file failed: {message}"), + _ => tracing::error!("open file request failed"), + } + }); + } +} + +/// Pin or unpin an entry through the daemon, then reload to reflect the new +/// state. +fn toggle_pin(ui: &Rc, entry: &DirEntry) { + let rel = entry_rel(ui, entry); + let req = if entry.pinned { + Request::Unpin { path: rel } + } else { + Request::Pin { path: rel } + }; + let rx = spawn_request(ui.dirs.control_socket(), req); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Error { message })) => tracing::error!("pin toggle failed: {message}"), + Ok(Ok(_)) => load_browser(&ui), + _ => tracing::error!("pin toggle request failed"), + } + }); +} + +/// Join the entry name onto the current browser directory to get its +/// mountpoint-relative path. +fn entry_rel(ui: &Rc, entry: &DirEntry) -> String { + let base = ui.browser_path.borrow(); + if base.is_empty() { + entry.name.clone() + } else { + format!("{base}/{}", entry.name) + } +} + +/// The name shown under a tile / in the Name column, marked with a star when the +/// file is kept offline. +fn display_name(entry: &DirEntry) -> String { + if entry.pinned { + format!("★ {}", entry.name) + } else { + entry.name.clone() + } +} + +/// Pick a freedesktop icon base name for an entry from its kind / extension. +/// Callers append `-symbolic` for the column view's small icons. +fn icon_base_for(entry: &DirEntry) -> &'static str { + if entry.is_dir { + return "folder"; + } + let ext = entry + .name + .rsplit('.') + .next() + .unwrap_or("") + .to_ascii_lowercase(); + match ext.as_str() { + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "heic" | "tiff" => { + "image-x-generic" + } + "mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" => "video-x-generic", + "mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" => "audio-x-generic", + "pdf" | "doc" | "docx" | "odt" => "x-office-document", + "xls" | "xlsx" | "ods" | "csv" => "x-office-spreadsheet", + "ppt" | "pptx" | "odp" => "x-office-presentation", + "zip" | "tar" | "gz" | "xz" | "bz2" | "7z" | "rar" => "package-x-generic", + _ => "text-x-generic", + } +} + +/// Format an epoch-seconds modification time as a short local date. +fn format_modified(secs: i64) -> String { + match glib::DateTime::from_unix_local(secs) { + Ok(dt) => dt.format("%-d %b %Y").map(|s| s.to_string()).unwrap_or_default(), + Err(_) => String::new(), + } +} + +/// Request the current browser directory from the daemon and repaint both views. +fn load_browser(ui: &Rc) { + let path = ui.browser_path.borrow().clone(); + ui.browser_crumb.set_label(if path.is_empty() { + "Proton Drive" + } else { + &path + }); + ui.browser_back.set_sensitive(!path.is_empty()); + ui.browser_status.set_visible(false); + + let rx = spawn_request(ui.dirs.control_socket(), Request::ListDir { path }); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries), + Ok(Ok(Response::Error { message })) => browser_message(&ui, &message), + Ok(Ok(_)) => browser_message(&ui, "Unexpected reply from daemon."), + Ok(Err(_)) | Err(_) => { + browser_message(&ui, "Mount Proton Drive to browse your files.") + } + } + }); +} + +/// Clear the model and show a single status line instead. +fn browser_message(ui: &Rc, message: &str) { + ui.browser_model.remove_all(); + ui.browser_status.set_label(message); + ui.browser_status.set_visible(true); +} + +/// Repopulate the shared model — folders first, then case-insensitive by name — +/// which refreshes both the grid and the column list. +fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { + ui.browser_model.remove_all(); + if entries.is_empty() { + browser_message(ui, "This folder is empty."); + return; + } + ui.browser_status.set_visible(false); + + let mut sorted = entries.to_vec(); + sorted.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + for entry in sorted { + ui.browser_model.append(&BoxedAnyObject::new(entry)); + } +} + +/// The Photos page: a [`gtk4::GridView`] of thumbnails backed by a +/// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`PhotoItem`]s, plus a +/// "Load more" button and a status label. +fn build_gallery_page() -> ( + gtk4::Widget, + (gio::ListStore, gtk4::Label, gtk4::Button, gtk4::GridView), +) { + let model = gio::ListStore::new::(); + + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let item = item.downcast_ref::().unwrap(); + let picture = gtk4::Picture::builder() + .content_fit(gtk4::ContentFit::Cover) + .width_request(150) + .height_request(150) + .build(); + picture.add_css_class("card"); + item.set_child(Some(&picture)); + }); + factory.connect_bind(|_, item| { + let item = item.downcast_ref::().unwrap(); + let picture = item.child().and_downcast::().unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let photo = obj.borrow::(); + match photo.thumb_path.as_deref() { + Some(p) => match gtk4::gdk::Texture::from_filename(p) { + Ok(texture) => picture.set_paintable(Some(&texture)), + Err(_) => picture.set_paintable(gtk4::gdk::Paintable::NONE), + }, + None => picture.set_paintable(gtk4::gdk::Paintable::NONE), + } + }); + + let selection = gtk4::NoSelection::new(Some(model.clone())); + let grid = gtk4::GridView::builder() + .model(&selection) + .factory(&factory) + .min_columns(2) + .max_columns(6) + .build(); + + let status = gtk4::Label::builder().wrap(true).build(); + status.add_css_class("dim-label"); + status.set_visible(false); + + let more = gtk4::Button::builder() + .label("Load more") + .halign(gtk4::Align::Center) + .build(); + more.add_css_class("pill"); + more.set_visible(false); + + let scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&grid) + .build(); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&status); + inner.append(&scroll); + inner.append(&more); + + (inner.upcast(), (model, status, more, grid)) +} + +/// Wire the gallery: activating a tile downloads the photo and opens it; the +/// "Load more" button appends the next page. +fn wire_gallery(ui: &Rc, grid: >k4::GridView) { + let ui_open = ui.clone(); + grid.connect_activate(move |grid, pos| { + let Some(obj) = grid.model().and_then(|m| m.item(pos)) else { + return; + }; + let uid = obj + .downcast_ref::() + .map(|o| o.borrow::().uid.clone()); + let Some(uid) = uid else { return }; + let rx = spawn_request(ui_open.dirs.control_socket(), Request::OpenPhoto { uid }); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::FilePath { path })) => open_path(&path), + Ok(Ok(Response::Error { message })) => tracing::error!("open photo failed: {message}"), + _ => tracing::error!("open photo request failed"), + } + }); + }); + + let ui_more = ui.clone(); + ui.gallery_more.clone().connect_clicked(move |_| { + load_gallery(&ui_more, true); + }); +} + +/// Fetch a timeline page from the daemon. When `append` is false the model is +/// cleared first (fresh load); otherwise the next page is tacked on. +fn load_gallery(ui: &Rc, append: bool) { + if !append { + ui.gallery_model.remove_all(); + } + let offset = ui.gallery_model.n_items() as usize; + ui.gallery_status.set_visible(false); + ui.gallery_more.set_sensitive(false); + + let rx = spawn_request( + ui.dirs.control_socket(), + Request::PhotosTimeline { + offset, + limit: PHOTOS_PAGE, + }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + ui.gallery_more.set_sensitive(true); + match rx.recv().await { + Ok(Ok(Response::Photos { available, items })) => { + if !available { + gallery_message(&ui, "This account has no photos."); + return; + } + for item in &items { + ui.gallery_model.append(&BoxedAnyObject::new(item.clone())); + } + if ui.gallery_model.n_items() == 0 { + gallery_message(&ui, "No photos yet."); + } else { + ui.gallery_status.set_visible(false); + } + // Offer "Load more" only when the page came back full. + ui.gallery_more.set_visible(items.len() == PHOTOS_PAGE); + } + Ok(Ok(Response::Error { message })) => gallery_message(&ui, &message), + Ok(Ok(_)) => gallery_message(&ui, "Unexpected reply from daemon."), + Ok(Err(_)) | Err(_) => { + gallery_message(&ui, "Mount Proton Drive to see your photos.") + } + } + }); +} + +/// Show a gallery status line and hide the pager. +fn gallery_message(ui: &Rc, message: &str) { + ui.gallery_status.set_label(message); + ui.gallery_status.set_visible(true); + ui.gallery_more.set_visible(false); +} + +/// Open a local path with the user's default handler. +fn open_path(path: &str) { + if let Err(e) = Command::new("xdg-open").arg(path).spawn() { + tracing::error!("xdg-open {path} failed: {e}"); + } +} + +/// Launch `pdfs mount` detached so the window keeps running independently. +fn spawn_mount() { + match Command::new("pdfs").arg("mount").spawn() { + Ok(_) => tracing::info!("spawned `pdfs mount`"), + Err(e) => tracing::error!("failed to spawn `pdfs mount`: {e}"), + } +} + +/// Tear down the FUSE mount. `fusermount3 -u` is the native path; fall back to +/// `fusermount` on older systems. +fn unmount(mountpoint: &Path) { + let try_unmount = |bin: &str| Command::new(bin).arg("-u").arg(mountpoint).status(); + match try_unmount("fusermount3").or_else(|_| try_unmount("fusermount")) { + Ok(s) if s.success() => tracing::info!("unmounted {}", mountpoint.display()), + Ok(s) => tracing::error!("unmount exited with {s}"), + Err(e) => tracing::error!("failed to run fusermount: {e}"), + } +} + +/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`). +fn human_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + if bytes == 0 { + return "0 B".into(); + } + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} \ No newline at end of file diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs new file mode 100644 index 0000000..4ffd7fa --- /dev/null +++ b/crates/pdfs-gui/src/main.rs @@ -0,0 +1,205 @@ +//! `pdfs-tray` — system-tray front-end for the Proton Drive Linux client. +//! +//! Slice 1 of the GUI phase: a StatusNotifierItem (SNI) tray icon with a menu +//! showing login/mount status and offering mount, unmount, open-folder and quit. +//! It talks to the running mount daemon over the same control socket the CLI +//! uses, so the tray is a thin presentation layer over [`pdfs_core::control`]. +//! +//! GTK4 has no tray widget (GNOME dropped the systray), so the tray itself is an +//! SNI item via the `ksni` crate; the GTK4/libadwaita settings window arrives in +//! a later slice. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use anyhow::{Context, Result}; +use ksni::menu::StandardItem; +use ksni::{MenuItem, Tray, TrayService}; +use pdfs_core::auth; +use pdfs_core::config::AppDirs; +use pdfs_core::control::{Request, Response, send}; + +/// How often the tray re-polls the daemon to refresh its menu. +const POLL_INTERVAL: Duration = Duration::from_secs(3); + +/// Snapshot of what the tray knows about the daemon, recomputed each poll. +#[derive(Clone)] +struct DriveState { + /// Human-readable first menu line ("Mounted at …", "Not running", …). + line: String, + /// Whether a mount daemon is currently serving the control socket. + mounted: bool, + /// Mountpoint to act on (from the daemon when mounted, else the default). + mountpoint: PathBuf, +} + +struct DriveTray { + state: DriveState, +} + +/// Ask the daemon for its status, falling back to the stored session so the +/// menu can still say "Logged in …" / "Not logged in" when no mount is running. +fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState { + match send(socket, &Request::Status) { + Ok(Response::Status { mountpoint, pinned, .. }) => DriveState { + line: format!("Mounted at {mountpoint} ({pinned} pinned)"), + mounted: true, + mountpoint: PathBuf::from(mountpoint), + }, + // Socket answered but with something unexpected — treat as up but odd. + Ok(_) => DriveState { + line: "Mount: unexpected daemon response".into(), + mounted: true, + mountpoint: default_mountpoint.to_path_buf(), + }, + // No daemon: describe login state instead so the menu is still useful. + Err(_) => { + let line = match auth::load() { + Ok(s) => format!("Logged in as {} — not mounted", s.username), + Err(pdfs_core::Error::NotLoggedIn) => "Not logged in".into(), + Err(e) => format!("Error: {e}"), + }; + DriveState { + line, + mounted: false, + mountpoint: default_mountpoint.to_path_buf(), + } + } + } +} + +/// Launch `pdfs mount` detached so the tray keeps running independently. +fn spawn_mount() { + match Command::new("pdfs").arg("mount").spawn() { + Ok(_) => tracing::info!("spawned `pdfs mount`"), + Err(e) => tracing::error!("failed to spawn `pdfs mount`: {e}"), + } +} + +/// Unmount the FUSE filesystem. `fusermount3 -u` is the FUSE-native teardown; +/// fall back to `fusermount` on older systems. +fn unmount(mountpoint: &Path) { + let try_unmount = |bin: &str| Command::new(bin).arg("-u").arg(mountpoint).status(); + let result = try_unmount("fusermount3").or_else(|_| try_unmount("fusermount")); + match result { + Ok(s) if s.success() => tracing::info!("unmounted {}", mountpoint.display()), + Ok(s) => tracing::error!("unmount exited with {s}"), + Err(e) => tracing::error!("failed to run fusermount: {e}"), + } +} + +fn open_folder(mountpoint: &Path) { + if let Err(e) = Command::new("xdg-open").arg(mountpoint).spawn() { + tracing::error!("failed to xdg-open {}: {e}", mountpoint.display()); + } +} + +impl Tray for DriveTray { + fn id(&self) -> String { + "io.narl.proton-drive-linux".into() + } + + fn title(&self) -> String { + "Proton Drive".into() + } + + fn icon_name(&self) -> String { + "folder-remote".into() + } + + fn menu(&self) -> Vec> { + // Values captured into the action closures (which only get `&mut Self`). + let mountpoint = self.state.mountpoint.clone(); + let open_mp = mountpoint.clone(); + + let mut items: Vec> = vec![ + StandardItem { + label: self.state.line.clone(), + enabled: false, + ..Default::default() + } + .into(), + MenuItem::Separator, + ]; + + if self.state.mounted { + items.push( + StandardItem { + label: "Open folder".into(), + activate: Box::new(move |_: &mut Self| open_folder(&open_mp)), + ..Default::default() + } + .into(), + ); + items.push( + StandardItem { + label: "Unmount".into(), + activate: Box::new(move |this: &mut Self| { + unmount(&mountpoint); + this.state.mounted = false; + this.state.line = "Unmounting…".into(); + }), + ..Default::default() + } + .into(), + ); + } else { + items.push( + StandardItem { + label: "Mount".into(), + activate: Box::new(|this: &mut Self| { + spawn_mount(); + this.state.line = "Mounting…".into(); + }), + ..Default::default() + } + .into(), + ); + } + + items.push(MenuItem::Separator); + items.push( + StandardItem { + label: "Quit".into(), + activate: Box::new(|_: &mut Self| std::process::exit(0)), + ..Default::default() + } + .into(), + ); + items + } +} + +fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + + let dirs = AppDirs::new().context("resolve app dirs")?; + let socket = dirs.control_socket(); + let default_mountpoint = dirs.default_mountpoint(); + + let tray = DriveTray { + state: poll_state(&socket, &default_mountpoint), + }; + + let service = TrayService::new(tray); + let handle = service.handle(); + std::thread::spawn(move || { + if let Err(e) = service.run() { + tracing::error!("tray service stopped: {e:?}"); + } + }); + + // Poll the daemon forever, pushing each fresh snapshot into the tray so the + // menu reflects mount/login changes made elsewhere (e.g. via the CLI). + loop { + std::thread::sleep(POLL_INTERVAL); + let st = poll_state(&socket, &default_mountpoint); + handle.update(move |t: &mut DriveTray| t.state = st); + } +} diff --git a/packaging/.gitignore b/packaging/.gitignore new file mode 100644 index 0000000..68414fb --- /dev/null +++ b/packaging/.gitignore @@ -0,0 +1,4 @@ +*.pkg.tar.zst +*.pkg.tar.zst.sig +pkg/ +src/ diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD new file mode 100644 index 0000000..62e0766 --- /dev/null +++ b/packaging/PKGBUILD @@ -0,0 +1,63 @@ +# Maintainer: Nils Pukropp +# +# Local-build PKGBUILD. Builds the proton-drive-linux workspace from the +# checkout this file lives in. Path deps point at a sibling ../proton-sdk-rs +# checkout (the Proton SDK is unpublished), so this is NOT a source-fetch +# package: it compiles the working tree in place. +# +# Build + install: cd packaging && makepkg -fi + +pkgname=proton-drive-linux +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray" +arch=('x86_64') +url="https://github.com/narl/proton-drive-linux" +license=('MIT') +depends=('fuse3' 'gtk4' 'libadwaita') +makedepends=('cargo') +provides=('pdfs') +# Files-on-demand mount + keyring need these at runtime. +optdepends=('gnome-keyring: credential storage via Secret Service' + 'xdg-utils: "Open folder" action from the tray') +options=('!lto') + +# Built in place from the checkout; nothing to fetch or checksum. +source=() +sha256sums=() + +# Absolute path to the workspace root (packaging/ lives directly under it). +_workspace="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +pkgver() { + cd "$_workspace" + local v + v=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) + printf '%s' "${v:-0.1.0}" +} + +build() { + cd "$_workspace" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR="$_workspace/target" + cargo build --release --frozen \ + --bin pdfs \ + --bin pdfs-tray \ + --bin pdfs-app +} + +package() { + cd "$_workspace" + local rel="target/release" + + install -Dm755 "$rel/pdfs" "$pkgdir/usr/bin/pdfs" + install -Dm755 "$rel/pdfs-tray" "$pkgdir/usr/bin/pdfs-tray" + install -Dm755 "$rel/pdfs-app" "$pkgdir/usr/bin/pdfs-app" + + install -Dm644 "packaging/io.narl.proton-drive-linux.desktop" \ + "$pkgdir/usr/share/applications/io.narl.proton-drive-linux.desktop" + install -Dm644 "packaging/io.narl.proton-drive-linux.svg" \ + "$pkgdir/usr/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg" + + install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/packaging/io.narl.proton-drive-linux.desktop b/packaging/io.narl.proton-drive-linux.desktop new file mode 100644 index 0000000..06a1987 --- /dev/null +++ b/packaging/io.narl.proton-drive-linux.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Proton Drive +GenericName=Cloud Drive +Comment=Proton Drive files-on-demand for Linux +Exec=pdfs-app +Icon=io.narl.proton-drive-linux +Terminal=false +Categories=Network;FileTransfer;GTK; +Keywords=proton;drive;cloud;sync;storage; +StartupNotify=true +SingleMainWindow=true diff --git a/packaging/io.narl.proton-drive-linux.svg b/packaging/io.narl.proton-drive-linux.svg new file mode 100644 index 0000000..5f9afd8 --- /dev/null +++ b/packaging/io.narl.proton-drive-linux.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + +