mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
init
This commit is contained in:
1 parent
5ac94848f3
commit
862740c4ee
20 files changed
+3830
-149
No files matched your search
Generated
+545
-11
@@ -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"
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -20,3 +20,4 @@ tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
clap.workspace = true
|
||||
rpassword.workspace = true
|
||||
serde_json.workspace = true
|
||||
+214
-7
@@ -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<PathBuf>,
|
||||
},
|
||||
/// 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<PathBuf>,
|
||||
},
|
||||
/// 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<PathBuf>) -> 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<PathBuf>) -> 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<String> {
|
||||
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<CtlResponse> {
|
||||
let socket = AppDirs::new()?.control_socket();
|
||||
pdfs_core::control::send(&socket, &req)
|
||||
.with_context(|| format!("talk to mount daemon at {}", socket.display()))
|
||||
}
|
||||
@@ -16,4 +16,5 @@ serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
directories.workspace = true
|
||||
sha2.workspace = true
|
||||
keyring.workspace = true
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<String, Pin>,
|
||||
}
|
||||
|
||||
/// Content cache rooted at a directory, with a sibling pin-registry file.
|
||||
pub struct ContentCache {
|
||||
/// Directory holding `<key>` blobs and `<key>.meta` tags.
|
||||
content_dir: PathBuf,
|
||||
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.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<Self> {
|
||||
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<PathBuf> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<String> =
|
||||
self.load_pins().pins.keys().map(|uid| Self::key_str(uid)).collect();
|
||||
|
||||
// Scan blobs (the `<key>` 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<Pin> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 `<defunct>` 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<String>,
|
||||
}
|
||||
|
||||
/// 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<Pin> },
|
||||
/// A directory listing (reply to [`Request::ListDir`]).
|
||||
Entries { entries: Vec<DirEntry> },
|
||||
/// A page of the photos timeline. `available` is false when the account
|
||||
/// has no photos volume.
|
||||
Photos { available: bool, items: Vec<PhotoItem> },
|
||||
/// 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<Response> {
|
||||
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())?)
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -14,3 +14,4 @@ tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
fuser.workspace = true
|
||||
libc.workspace = true
|
||||
serde_json.workspace = true
|
||||
+701
-126
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<MenuItem<Self>> {
|
||||
// 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<MenuItem<Self>> = 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
*.pkg.tar.zst
|
||||
*.pkg.tar.zst.sig
|
||||
pkg/
|
||||
src/
|
||||
@@ -0,0 +1,63 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
#
|
||||
# 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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#8b6dff"/>
|
||||
<stop offset="1" stop-color="#6d4aff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="112" height="112" rx="26" fill="url(#g)"/>
|
||||
<!-- cloud -->
|
||||
<path fill="#ffffff" d="M44 84c-11 0-20-9-20-20 0-10 7-18 17-20 3-11 13-19 25-19 13 0 24 10 26 23 9 1 16 9 16 18 0 10-8 18-18 18z"/>
|
||||
<!-- down arrow (on-demand pull) -->
|
||||
<path fill="#6d4aff" d="M64 50v22m0 0l-9-9m9 9l9-9" stroke="#6d4aff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 733 B |
Reference in new issue
Block a user