This commit is contained in:
nvrl committed 2026-06-30 03:27:13 +02:00
1 parent 5ac94848f3
commit 862740c4ee
20 files changed
+3830 -149

No files matched your search

Generated
+545 -11
View File
@@ -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
View File
@@ -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"
+21
View File
@@ -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.
+1
View File
@@ -20,3 +20,4 @@ tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
rpassword.workspace = true
serde_json.workspace = true
+214 -7
View File
@@ -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()))
}
+1
View File
@@ -16,4 +16,5 @@ serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
directories.workspace = true
sha2.workspace = true
keyring.workspace = true
+18 -4
View File
@@ -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
}
+549
View File
@@ -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));
}
}
+19
View File
@@ -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()
+125
View File
@@ -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())?)
}
+2
View File
@@ -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};
+1
View File
@@ -14,3 +14,4 @@ tokio.workspace = true
tracing.workspace = true
fuser.workspace = true
libc.workspace = true
serde_json.workspace = true
File diff suppressed because it is too large. Load diff
+26
View File
@@ -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
+205
View File
@@ -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);
}
}
+4
View File
@@ -0,0 +1,4 @@
*.pkg.tar.zst
*.pkg.tar.zst.sig
pkg/
src/
+63
View File
@@ -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
+14
View File
@@ -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