mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
470948c191 | ||
|
|
b095b9f6b0 | ||
|
|
f4d6580cd3 | ||
|
|
b2f62f70e1 |
No files matched your search
Generated
+512
-793
File diff suppressed because it is too large.
Load diff
+14
-10
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.10.0"
|
||||
version = "1.10.2"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.6.3" }
|
||||
proton-drive-rs = { version = "0.6.3" }
|
||||
proton-sdk = { version = "0.6.4" }
|
||||
proton-drive-rs = { version = "0.6.4" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
@@ -25,7 +25,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||
@@ -35,24 +35,28 @@ anyhow = "1"
|
||||
parking_lot = "0.12"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
directories = "5"
|
||||
directories = "6"
|
||||
ignore = "0.4"
|
||||
sha2 = "0.10"
|
||||
sha2 = "0.11"
|
||||
# Google Takeout exports are zip sets; the importer reads their central
|
||||
# directories and streams entries out without extracting to disk first.
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||
# import has to speak SHA-1 whatever its age.
|
||||
sha1 = "0.10"
|
||||
sha1 = "0.11"
|
||||
# Thumbnail generation for photos the server has none for. Decoders only for the
|
||||
# formats a camera or phone actually produces — the rest of the crate's codecs
|
||||
# would be dead weight in the daemon.
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
||||
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
||||
# with no `target` it matches items v3 wrote, so existing logins survive.
|
||||
keyring-core = "1"
|
||||
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rpassword = "7"
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
fuser = "0.17"
|
||||
fuser = "0.18"
|
||||
libc = "0.2"
|
||||
@@ -23,7 +23,8 @@ sha2.workspace = true
|
||||
sha1.workspace = true
|
||||
zip.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring.workspace = true
|
||||
keyring-core.workspace = true
|
||||
dbus-secret-service-keyring-store.workspace = true
|
||||
parking_lot.workspace = true
|
||||
async-trait.workspace = true
|
||||
libc.workspace = true
|
||||
@@ -6,7 +6,7 @@
|
||||
//! interactive step; the refresh token auto-renews via the HTTP client's 401
|
||||
//! path, so no fresh 2FA is required until the refresh token itself expires.
|
||||
|
||||
use keyring::Entry;
|
||||
use keyring_core::Entry;
|
||||
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
use proton_sdk::cache::EncryptedCacheRepository;
|
||||
@@ -81,6 +81,11 @@ fn client_config() -> ProtonClientConfiguration {
|
||||
}
|
||||
|
||||
fn keyring_entry() -> Result<Entry> {
|
||||
// Installed lazily rather than once at startup so a session bus that was not
|
||||
// up yet on the first attempt is retried on the next one.
|
||||
if keyring_core::get_default_store().is_none() {
|
||||
keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?);
|
||||
}
|
||||
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
|
||||
}
|
||||
|
||||
@@ -215,7 +220,7 @@ pub fn load() -> Result<StoredSession> {
|
||||
let entry = keyring_entry()?;
|
||||
match entry.get_password() {
|
||||
Ok(json) => Ok(serde_json::from_str(&json)?),
|
||||
Err(keyring::Error::NoEntry) => Err(Error::NotLoggedIn),
|
||||
Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
@@ -238,7 +243,7 @@ pub fn logout() -> Result<()> {
|
||||
}
|
||||
}
|
||||
match keyring_entry()?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ pub enum Error {
|
||||
Proton(#[from] proton_sdk::ProtonError),
|
||||
|
||||
#[error("keyring: {0}")]
|
||||
Keyring(#[from] keyring::Error),
|
||||
Keyring(#[from] keyring_core::Error),
|
||||
|
||||
#[error("serialization: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
@@ -199,6 +199,9 @@ fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
|
||||
/// The paths a scan should never descend into: the Drive mountpoint (walking it
|
||||
/// would fault every remote node in through FUSE) plus our own state and cache
|
||||
/// dirs, which hold blobs no user searches for by name.
|
||||
///
|
||||
/// Callers should extend this with [`nested_mount_points`] for each scan root —
|
||||
/// see that function for why `same_file_system(true)` is not enough on its own.
|
||||
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
mountpoint.to_path_buf(),
|
||||
@@ -207,6 +210,66 @@ pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -
|
||||
]
|
||||
}
|
||||
|
||||
/// Every mountpoint strictly below `root`, read from `/proc/self/mounts`.
|
||||
///
|
||||
/// [`scan`] already asks the walker to stay on one filesystem, but that check is
|
||||
/// a *stat of the mountpoint* (`ignore`'s `is_same_file_system`), performed after
|
||||
/// the directory is queued — so the walker has to touch a foreign mount to learn
|
||||
/// it should skip it. On a dead network FUSE mount (an sshfs whose server is
|
||||
/// gone, an unresponsive rclone) that stat blocks in `request_wait_answer` with
|
||||
/// nobody left to answer it, and blocks *uninterruptibly*: the walker thread then
|
||||
/// ignores SIGKILL, so the daemon can never exit, systemd's stop times out, and
|
||||
/// the half-dead process keeps the cache.db lock that the next start needs
|
||||
/// (docs/BUGS.md B90).
|
||||
///
|
||||
/// Excluding the mountpoints by path avoids that stat entirely. It does not
|
||||
/// change what ends up indexed: a nested mount is a different device, so
|
||||
/// `same_file_system(true)` was already going to skip it.
|
||||
pub fn nested_mount_points(root: &Path) -> Vec<PathBuf> {
|
||||
let Ok(mounts) = std::fs::read_to_string("/proc/self/mounts") else {
|
||||
return Vec::new();
|
||||
};
|
||||
parse_nested_mount_points(&mounts, root)
|
||||
}
|
||||
|
||||
/// The mountpoint-field parse behind [`nested_mount_points`], split out so it can
|
||||
/// be tested without a real `/proc`.
|
||||
fn parse_nested_mount_points(mounts: &str, root: &Path) -> Vec<PathBuf> {
|
||||
mounts
|
||||
.lines()
|
||||
.filter_map(|line| line.split_whitespace().nth(1))
|
||||
.map(|field| PathBuf::from(unescape_mount_field(field)))
|
||||
// Strictly below: `root` itself being a mountpoint must not exclude the
|
||||
// whole scan.
|
||||
.filter(|path| path.starts_with(root) && path != root)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Undo the octal escaping `/proc/self/mounts` applies to space, tab, newline and
|
||||
/// backslash in path fields. An un-unescaped `\040` would simply fail to match a
|
||||
/// real path, silently putting a mount with a space in its name back in the walk.
|
||||
fn unescape_mount_field(field: &str) -> String {
|
||||
let mut out = String::with_capacity(field.len());
|
||||
let mut rest = field;
|
||||
while let Some(at) = rest.find('\\') {
|
||||
out.push_str(&rest[..at]);
|
||||
let escape = rest[at + 1..].get(..3);
|
||||
match escape.and_then(|digits| u8::from_str_radix(digits, 8).ok()) {
|
||||
Some(byte) => {
|
||||
out.push(byte as char);
|
||||
rest = &rest[at + 4..];
|
||||
}
|
||||
// Not an octal escape after all — keep the backslash verbatim.
|
||||
None => {
|
||||
out.push('\\');
|
||||
rest = &rest[at + 1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -268,4 +331,42 @@ mod tests {
|
||||
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
/// A dead network FUSE mount under the scan root must be skipped by *path*,
|
||||
/// before anything stats it — see `nested_mount_points`. The scan root itself
|
||||
/// being a mountpoint must not exclude the entire walk.
|
||||
#[test]
|
||||
fn nested_mount_points_are_excluded_but_the_root_mount_is_not() {
|
||||
let mounts = "\
|
||||
/dev/sda2 /home ext4 rw 0 0
|
||||
narl@narl.io:/opt /home/narl/remote/narl.io fuse.sshfs rw 0 0
|
||||
google_drive: /home/narl/remote/gdrive fuse.rclone rw 0 0
|
||||
tmpfs /run/user/1000 tmpfs rw 0 0
|
||||
";
|
||||
let nested = parse_nested_mount_points(mounts, Path::new("/home/narl"));
|
||||
assert_eq!(
|
||||
nested,
|
||||
vec![
|
||||
PathBuf::from("/home/narl/remote/narl.io"),
|
||||
PathBuf::from("/home/narl/remote/gdrive"),
|
||||
]
|
||||
);
|
||||
|
||||
// `/home` is an ancestor, not a descendant: excluding it would empty the
|
||||
// scan. `/run/user/1000` is unrelated.
|
||||
assert!(parse_nested_mount_points(mounts, Path::new("/home")).len() == 2);
|
||||
}
|
||||
|
||||
/// `/proc/self/mounts` octal-escapes a space; leaving it escaped would put a
|
||||
/// mount whose path contains one back into the walk.
|
||||
#[test]
|
||||
fn mount_paths_are_unescaped() {
|
||||
let mounts = "srv:/x /home/narl/My\\040Cloud fuse.sshfs rw 0 0\n";
|
||||
assert_eq!(
|
||||
parse_nested_mount_points(mounts, Path::new("/home/narl")),
|
||||
vec![PathBuf::from("/home/narl/My Cloud")]
|
||||
);
|
||||
assert_eq!(unescape_mount_field("/a\\134b"), "/a\\b");
|
||||
assert_eq!(unescape_mount_field("/plain/path"), "/plain/path");
|
||||
}
|
||||
}
|
||||
@@ -487,7 +487,12 @@ fn scan_local_once(
|
||||
}
|
||||
};
|
||||
|
||||
let excludes = localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
|
||||
let mut excludes =
|
||||
localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
|
||||
// Resolved per scan rather than once at startup: mounts come and go, and a
|
||||
// mount that appeared since the daemon started is exactly the one most
|
||||
// likely to be a half-alive network filesystem (see `nested_mount_points`).
|
||||
excludes.extend(localindex::nested_mount_points(&home));
|
||||
indexing.store(true, Ordering::Relaxed);
|
||||
let started = Instant::now();
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
ksni = "0.2"
|
||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
||||
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
||||
ksni = { version = "0.3", features = ["blocking"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||
async-channel = "2"
|
||||
exif = { version = "0.6", package = "kamadak-exif" }
|
||||
webkit6 = "0.4"
|
||||
webkit6 = "0.6"
|
||||
@@ -14,8 +14,9 @@ use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use ksni::blocking::TrayMethods;
|
||||
use ksni::menu::StandardItem;
|
||||
use ksni::{MenuItem, Tray, TrayService};
|
||||
use ksni::{MenuItem, Tray};
|
||||
use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
@@ -282,13 +283,13 @@ fn main() -> Result<()> {
|
||||
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:?}");
|
||||
}
|
||||
});
|
||||
// The tray autostarts with the session and can beat the desktop's SNI
|
||||
// watcher onto the bus. Without `assume_sni_available` that race is a hard
|
||||
// spawn error; with it the item waits and registers once the watcher shows.
|
||||
let handle = tray
|
||||
.assume_sni_available(true)
|
||||
.spawn()
|
||||
.context("start tray service")?;
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -1364,7 +1364,7 @@ fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
|
||||
if is_dir {
|
||||
return gio::ThemedIcon::new("folder").upcast();
|
||||
}
|
||||
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), &[]);
|
||||
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None);
|
||||
gio::functions::content_type_get_icon(&content_type)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,79 @@ Conventions:
|
||||
|
||||
---
|
||||
|
||||
## B90 — The local-file indexer walks into foreign FUSE mounts, and a dead one makes the daemon unkillable
|
||||
|
||||
**Status:** Fixed (unverified) — the walker no longer stats a nested mountpoint. The stuck-daemon
|
||||
half cannot be fixed in userspace at all; see "Why there is no second line of defence".
|
||||
**Found:** 2026-09-16, from a user report of `systemctl --user restart` failing in a loop.
|
||||
**Where:** `crates/pdfs-core/src/localindex.rs` (`default_excludes`, `scan`),
|
||||
`crates/pdfs-fuse/src/background.rs` (`scan_local_once`).
|
||||
|
||||
**Symptom.** Stopping the service times out, SIGKILL does not help, and every subsequent start
|
||||
fails with `another Proton Drive daemon is already using .../cache.db`:
|
||||
|
||||
```
|
||||
systemd[2196]: proton-drive.service: State 'final-sigterm' timed out. Killing.
|
||||
systemd[2196]: proton-drive.service: Killing process 2221 (pdfs) with signal SIGKILL.
|
||||
systemd[2196]: proton-drive.service: Processes still around after final SIGKILL. Entering failed mode.
|
||||
systemd[2196]: proton-drive.service: Unit process 2221 (pdfs) remains running after unit stopped.
|
||||
pdfs[73921]: ERROR mount failed; retrying in 5s error="open cache db: another Proton Drive daemon
|
||||
is already using /home/narl/.local/state/proton-drive-linux/cache.db"
|
||||
```
|
||||
|
||||
**Diagnosis.** The surviving process is a zombie that still has a live thread:
|
||||
|
||||
```
|
||||
$ cat /proc/2221/status | grep -E 'State|Threads'
|
||||
State: Z (zombie)
|
||||
Threads: 2
|
||||
$ cat /proc/2221/task/73231/comm; cat /proc/2221/task/73231/stack
|
||||
pdfs-localindex
|
||||
request_wait_answer
|
||||
```
|
||||
|
||||
`pdfs-localindex` is parked in a FUSE request nobody will answer, in uninterruptible `D` state, so
|
||||
it ignores SIGKILL. The thread group leader exits, the thread does not, the process cannot be
|
||||
reaped — and it keeps the `cache.db` lock, which is what turns one stuck scan into a permanent
|
||||
restart loop. The same dead connection also blocked an unrelated `duf` in `fuse_statfs`
|
||||
(`/sys/fs/fuse/connections/69/waiting` was 2).
|
||||
|
||||
**Cause.** `scan_local_once` walks `$HOME`, and `default_excludes` only excluded the Drive
|
||||
mountpoint plus our own state and cache dirs. The reporter's home also held
|
||||
`~/remote/narl.io` (sshfs) and `~/remote/gdrive` (rclone) — third-party FUSE mounts the walk
|
||||
descends into.
|
||||
|
||||
`scan` does set `same_file_system(true)`, which looks like it should already cover this. It does
|
||||
not: in `ignore` 0.4.33 the check is `is_same_file_system` → `device_num(path)` →
|
||||
`path.metadata()` (`walk.rs:2164`), i.e. **a stat of the mountpoint**, performed in `run_one`
|
||||
*after* the directory has been queued. To discover that a mount is foreign, the walker first has
|
||||
to touch it. Against a network FUSE mount whose server is gone, that stat never returns.
|
||||
|
||||
**Fix.** `localindex::nested_mount_points` reads `/proc/self/mounts` and returns every mountpoint
|
||||
strictly below the scan root; `scan_local_once` appends those to the excludes. `filter_entry` runs
|
||||
before the device stat (`walk.rs:1926`, before `send`), so a path-only rejection keeps the walker
|
||||
from ever touching the mount. Mountpoint fields are octal-unescaped, because a `\040` left in
|
||||
place would silently fail to match and put the mount back in the walk. Re-read per scan, not
|
||||
cached at startup: a mount that appeared since the daemon started is the likeliest to be a
|
||||
half-alive network filesystem.
|
||||
|
||||
This changes nothing about what gets indexed. A nested mount is a different device, so
|
||||
`same_file_system(true)` was always going to skip it — the exclude only removes the stat that had
|
||||
to happen first.
|
||||
|
||||
**Why there is no second line of defence.** Once a thread is in uninterruptible FUSE wait, no
|
||||
userspace change rescues it: not a join timeout, not `std::process::exit`, not SIGKILL. The kernel
|
||||
will not reap the task until the syscall returns, which needs someone to answer the request or
|
||||
abort the connection (`echo 1 > /sys/fs/fuse/connections/<id>/abort`, root-only). Avoiding entry is
|
||||
the entire fix. That makes any *new* code path that stats a user-supplied or mount-crossing path
|
||||
from a daemon thread a repeat of this bug — mirror-folder scanning is the obvious next candidate.
|
||||
|
||||
**Recovery for an already-wedged daemon.** As root, abort the connection with waiters
|
||||
(`grep . /sys/fs/fuse/connections/*/waiting` finds it), which releases the thread, lets the zombie
|
||||
be reaped and frees the `cache.db` lock. A reboot also clears it.
|
||||
|
||||
---
|
||||
|
||||
## B89 — The recorded SDK dependency is three minor versions out of date in the working memory
|
||||
|
||||
**Status:** Open — documentation only, but it misdirects every reader of the SDK code.
|
||||
|
||||
@@ -13,6 +13,41 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
Nothing yet.
|
||||
|
||||
## [1.10.2] — 2026-09-16
|
||||
|
||||
Fixes a daemon that could not be stopped or restarted. No schema change.
|
||||
Schema: **28**; no SDK change.
|
||||
|
||||
### Fixed
|
||||
- **A dead network mount in your home directory no longer wedges the daemon (B90).** The
|
||||
"This computer" file indexer walked `$HOME` and descended into other FUSE mounts — an sshfs
|
||||
whose server had gone away, an unresponsive rclone. Stat'ing one of those blocks
|
||||
uninterruptibly, so the indexer thread stopped responding to SIGKILL, the daemon could never
|
||||
exit, `systemctl --user stop` timed out, and the half-dead process kept the cache database lock
|
||||
that the next start needed: every restart then failed with "another Proton Drive daemon is
|
||||
already using cache.db". The indexer now skips mountpoints below your home directory by path,
|
||||
without touching them. Nothing changes about which files are indexed — those mounts were always
|
||||
excluded from the results, just not before they had been stat'ed.
|
||||
|
||||
## [1.10.1] — 2026-09-15
|
||||
|
||||
Dependency upgrade release, clearing both open Dependabot advisories. No schema change.
|
||||
Schema: **28**; SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.4**.
|
||||
|
||||
### Security
|
||||
- **`lru` IterMut Stacked Borrows advisory.** The SDK now depends on `lru` 0.18.
|
||||
- **`atty` unaligned read advisory.** The tray moved to `ksni` 0.3, which no longer pulls
|
||||
`dbus-codegen` → `clap` 2 → `atty`.
|
||||
|
||||
### Changed
|
||||
- **Keyring backend is `keyring-core` + `dbus-secret-service-keyring-store`** (keyring 4's split).
|
||||
Same libdbus Secret Service as before; stored sessions written by earlier releases are still
|
||||
found, so no re-login is needed.
|
||||
- **The tray waits for a late StatusNotifierWatcher** instead of failing to start when it beats
|
||||
the desktop's tray host onto the session bus.
|
||||
- Upgraded `gtk4` 0.11, `libadwaita` 0.9, `webkit6` 0.6, `rusqlite` 0.40 (bundled SQLite),
|
||||
`fuser` 0.18, `directories` 6, `sha1`/`sha2` 0.11, `base64` 0.23.
|
||||
|
||||
## [1.10.0] — 2026-09-15
|
||||
|
||||
One-request move-and-rename in the drain, and who shared an item with you. No schema change.
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=1.10.0
|
||||
pkgver=1.10.2
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
|
||||
Reference in new issue
Block a user