Compare commits

...
12 Commits
Author SHA1 Message Date
nvrl b46abfc07c chore(release): 3.4.0 2026-10-12 18:12:00 +02:00
nvrl fd78f088ec test(fuse): let a held create carry its bytes in the simulation
FUSE delivers `release` after `close()` returns, and the release is what
attaches the written bytes to the queued create. A drain worker that is
free can claim the create first and send the file empty, with the bytes
following as a revision. Tests that hold a create, its reply, or lose
its reply then act on the empty create, and their assertions about the
file's content race the revision. CI saw this as an occasional failure
of a_listing_taken_before_a_create_commits_keeps_the_landed_file.

Add Daemon::write_new, which pauses sync, writes the file, waits until
the pending create carries the bytes, and resumes. Use it for every
write that directly follows a create hold.
2026-10-12 18:05:45 +02:00
nvrl 005f7ded1f fix(fuse): a file landing while its folder is pinned stays available offline (B208)
A folder pin reads each file's modification time and size before it
downloads the file. If the file's create landed during the download, the
landing set Drive's time, and the pin then stored the blob under the old
time. The file showed as kept offline but read as stale.

After each download, the pin now compares the file's time and size with
the tree's. If they changed, it downloads the file again under the new
values, at most three times.

The simulation gains FakeClient::hold_next_download to force that order.
2026-10-12 15:40:56 +02:00
nvrl 3e4886f965 fix(fuse): refuse a move between locations under a just-opened file (B207)
The account run of 3.3.0 failed "move refuses a file still being
written" on the on-demand to mirror pairing: pdfs move moved the folder
while open.txt in it was open for writing, and the write landed after
the move.

retire_create re-keys a landed create's nodes row to the real uid first
and repoints the tree and the open write handles after. A file opened in
between holds a handle with the placeholder uid. has_pending_work_under
placed each open handle by looking its uid up in the nodes table, which
no longer has the placeholder, so it saw nothing open under the folder,
and with the queue already empty the move went ahead. Open handles are
now placed in the in-memory tree by inode, which a landing create does
not change.

The simulation test holds the drain at that point with the new
FakeClient::hold_next_landing_of_any, and fails without the fix.
2026-10-12 00:57:10 +02:00
nvrl aac53e3139 test(fuse): let the simulation sync local folders
A simulated daemon could not add a synced folder, so nothing that moves
between a mount, an on-demand folder and a mirror folder could be tested
without a real account.

Listing and registering devices now go through DriveApi, and the fake
Drive keeps the account's computers, each with a root folder outside My
files. The simulated daemons also read their config.json from a scratch
XDG directory instead of the developer's own: an adopted device of the
real account made every sync folder fail to register against the fake
Drive.
2026-10-12 00:57:04 +02:00
nvrl c2bf87596f test(acceptance): remove the local-only symlink the mirror-copy case leaves
"move never loses a mirror copy Drive lacks" puts a symlink in a mirror
folder to prove a move does not lose what sync never uploads, and left it
there. The next mode pairing switches that folder to on-demand, which the
daemon refuses because the link exists only on this computer, so the
account run stopped after pairing 2 of 4. The case now removes the link
wherever the move left it, whether it passes or fails.
2026-10-12 00:24:40 +02:00
nvrl 69d4feaa07 docs: add a security model page for users
Readers asked whether the client can be trusted with their files. The
threat model in ARCHITECTURE.md is written for contributors, so
docs/SECURITY_MODEL.md summarises it for users: encryption comes from
the SDK and rPGP, sign-in uses SRP, what is stored where and how it is
protected, who can reach the daemon, every network connection, what the
client does not protect against, and how each package is built and
signed. It says that there has been no independent audit.

It is linked from the README, the documentation index and SECURITY.md.
2026-10-12 00:07:18 +02:00
nvrl d481dcc15c feat(fuse,core,cli,gui): make folders available offline from the app
The daemon has kept recursive folder pins for a long time, but the app
offered "Available offline" for files only. The context menu, the
details pane and the selection bar now offer it for folders too, and a
pinned folder carries the offline badge in the browser and the launcher.

A folder pin used to download the whole subtree inside the control
request, so a large folder outlasted the client's 120 s read timeout,
and every pinned file was read into memory before it was cached. The
daemon now records the pin, answers at once and downloads on a
background thread as a job the tray and the app show, stopping when the
folder is unpinned or the daemon stops. Pins of files and folders both
go through fetch_content, which streams to disk and skips fresh blobs.
A folder pin is refused while offline instead of looking kept.

A file kept only because a folder above it is pinned has no pin of its
own, and unpinning it silently did nothing. ListDir now reports
pinned_by_folder; the app shows such an entry as available offline with
the toggle greyed out, and the daemon refuses the unpin with a message
naming the folder. Unpinning a folder no longer evicts files that are
pinned on their own.

Requested on Reddit.
2026-10-12 00:07:18 +02:00
nvrl e0442c56b9 fix(packaging): stop the service before the package is removed
Removing the package left the user service running from the deleted
binary, with ~/ProtonDrive still mounted. A user who then deleted the
folder to clean up had every file in it moved to the Proton Drive trash,
as any delete through the mount is.

The .deb (prerm on remove), the .rpm (%preun on erase) and the Arch
packages (pre_remove) now stop proton-drive.service for every user with
a running user manager, through runuser with that user's
XDG_RUNTIME_DIR, so no systemd-machined is needed. Upgrades are left
alone. Queued writes stay in the state directory for a later install.

The pacman install script lives once in packaging/ and the AUR
directories link it; push.sh copies its content into each AUR
repository. The uninstall guide now warns not to delete the mount point
while the service runs.

Reported on Reddit by a user who restored the files from the trash.
2026-10-12 00:07:07 +02:00
nvrl ee76f4ac60 fix(packaging): recommend each keyring only on its own desktop
The RPM recommended both gnome-keyring and kwallet unconditionally, and
dnf installs weak dependencies by default, so a GNOME install on Fedora
pulled in KWallet and KDE Frameworks with it. The recommendations are
now rich dependencies: KWallet only with plasma-workspace, GNOME Keyring
only without it, and the AppIndicator extension only with gnome-shell.
RPM does not accept `unless` in a weak dependency, so the keyring uses
the equivalent `(gnome-keyring or plasma-workspace)`.

Reported on Reddit by a Fedora GNOME user.
2026-10-12 00:06:51 +02:00
nvrl f6c651b06b ci(nix): push Nix builds to a Cachix binary cache
Every Nix build of main and of each release tag now goes to
proton-drive-linux.cachix.org, so installing the flake downloads the
client instead of compiling it. The flake names the cache in nixConfig
for nix run and nix shell, and the install guide shows the nix.settings
a NixOS system build needs, since it does not read an input's
nixConfig. A fork's pull request has no token and only pulls.
2026-10-11 23:02:22 +02:00
Nils Pukropp 8b7d7ae87f chore(aur): update packages to 3.3.0 2026-10-11 20:08:31 +00:00
59 changed files with 4163 additions and 2930 deletions

No files matched your search

+7
View File
@@ -38,6 +38,13 @@ jobs:
- uses: cachix/install-nix-action@v31 - uses: cachix/install-nix-action@v31
# Pulls from the project's binary cache, and pushes what this run built
# when the token is there; a fork's pull request has none and only pulls.
- uses: cachix/cachix-action@v17
with:
name: proton-drive-linux
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Check the flake - name: Check the flake
run: nix flake check --print-build-logs --no-build run: nix flake check --print-build-logs --no-build
+10
View File
@@ -153,6 +153,9 @@ jobs:
cp LICENSE deb/usr/share/licenses/proton-drive-linux/ cp LICENSE deb/usr/share/licenses/proton-drive-linux/
po/build.sh deb/usr/share/locale po/build.sh deb/usr/share/locale
# Stops the running daemon before removal, so the mount stops mirroring deletes
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
# Calculate Installed-Size in KB for the control file # Calculate Installed-Size in KB for the control file
INSTALLED_SIZE=$(du -sk deb | cut -f1) INSTALLED_SIZE=$(du -sk deb | cut -f1)
@@ -240,6 +243,13 @@ jobs:
- uses: cachix/install-nix-action@v31 - uses: cachix/install-nix-action@v31
# Pulls from the project's binary cache, and pushes what this run built
# when the token is there; a fork's pull request has none and only pulls.
- uses: cachix/cachix-action@v17
with:
name: proton-drive-linux
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Build the flake - name: Build the flake
run: nix build --print-build-logs .#proton-drive-linux run: nix build --print-build-logs .#proton-drive-linux
Generated
+4 -4
View File
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "3.3.0" version = "3.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3490,7 +3490,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "3.3.0" version = "3.4.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3516,7 +3516,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "3.3.0" version = "3.4.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3538,7 +3538,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "3.3.0" version = "3.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "3.3.0" version = "3.4.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
+1
View File
@@ -109,6 +109,7 @@ video thumbnails), headless use and uninstalling.
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service | | [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones | | [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine | | [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
| [Security model](docs/SECURITY_MODEL.md) | What protects your files, what stays on this computer, what the client connects to |
| [Changelog](docs/CHANGELOG.md) | What changed in each release | | [Changelog](docs/CHANGELOG.md) | What changed in each release |
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md), For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
+3
View File
@@ -4,6 +4,9 @@
Fixes land on the latest release. Please reproduce on the most recent version before reporting. Fixes land on the latest release. Please reproduce on the most recent version before reporting.
For what the client protects and what it does not, written for users, see the
[security model](docs/SECURITY_MODEL.md).
## Reporting a vulnerability ## Reporting a vulnerability
**Do not open a public issue.** Report privately through GitHub's **Do not open a public issue.** Report privately through GitHub's
+8 -5
View File
@@ -172,17 +172,19 @@ enum Command {
/// Mountpoint; defaults to ~/ProtonDrive. /// Mountpoint; defaults to ~/ProtonDrive.
mountpoint: Option<PathBuf>, mountpoint: Option<PathBuf>,
}, },
/// Keep a file on this device (download + cache it). Needs a running mount. /// Keep a file or folder on this device (download + cache it). A folder's
/// files download in the background; `pdfs transfers` shows the progress.
/// Needs a running mount.
Pin { Pin {
/// File path, inside the mountpoint or relative to it. /// File or folder path, inside the mountpoint or relative to it.
path: PathBuf, path: PathBuf,
}, },
/// Stop keeping a file on this device, evicting its cached content. /// Stop keeping a file or folder on this device, evicting its cached content.
Unpin { Unpin {
/// File path, inside the mountpoint or relative to it. /// File or folder path, inside the mountpoint or relative to it.
path: PathBuf, path: PathBuf,
}, },
/// List files pinned to this device. /// List files and folders pinned to this device.
Pins, Pins,
/// List a directory via the running daemon (in-app browser backend). /// List a directory via the running daemon (in-app browser backend).
Ls { Ls {
@@ -3793,6 +3795,7 @@ mod login_wait_tests {
size: 1, size: 1,
modified: 0, modified: 0,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: "vol~link".into(), uid: "vol~link".into(),
path: String::new(), path: String::new(),
+8 -1
View File
@@ -1260,8 +1260,15 @@ pub struct DirEntry {
pub size: u64, pub size: u64,
/// Modification time, epoch seconds. /// Modification time, epoch seconds.
pub modified: i64, pub modified: i64,
/// Whether the file is pinned to this device. /// Whether the node is pinned to this device, on its own or through a
/// pinned folder above it.
pub pinned: bool, pub pinned: bool,
/// Whether the node is kept on this device because a folder above it is
/// pinned. Such a node cannot be unpinned on its own. Set in
/// [`Request::ListDir`] listings only. Defaulted for wire-compat with
/// daemons predating the field.
#[serde(default)]
pub pinned_by_folder: bool,
/// Whether the file's full content is present in the local cache (a current, /// Whether the file's full content is present in the local cache (a current,
/// non-stale blob). Always false for folders. Defaulted for wire-compat with /// non-stale blob). Always false for folders. Defaulted for wire-compat with
/// clients/daemons predating the field. /// clients/daemons predating the field.
+5 -5
View File
@@ -157,7 +157,7 @@ impl Core {
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> { pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
let devices = self let devices = self
.rt .rt
.block_on(self.client.enumerate_devices()) .block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "list devices"))?; .map_err(|e| CoreError::from_api(&e, "list devices"))?;
// No cached device row yet means this machine syncs nothing, so none of // No cached device row yet means this machine syncs nothing, so none of
// the listed devices is ours. // the listed devices is ours.
@@ -214,7 +214,7 @@ impl Core {
}; };
let remote = self let remote = self
.rt .rt
.block_on(self.client.enumerate_devices()) .block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
let dev = remote let dev = remote
.into_iter() .into_iter()
@@ -257,7 +257,7 @@ impl Core {
}; };
let remote = self let remote = self
.rt .rt
.block_on(self.client.enumerate_devices()) .block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
let d = remote let d = remote
.into_iter() .into_iter()
@@ -395,7 +395,7 @@ impl Core {
// any cached record, and recover an existing device by name. // any cached record, and recover an existing device by name.
let remote = self let remote = self
.rt .rt
.block_on(self.client.enumerate_devices()) .block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
// An adopted uid is an explicit instruction, so it outranks everything // An adopted uid is an explicit instruction, so it outranks everything
@@ -454,7 +454,7 @@ impl Core {
None => { None => {
let d = self let d = self
.rt .rt
.block_on(self.client.create_device(&name, DeviceType::Linux)) .block_on(self.drive.create_device(&name, DeviceType::Linux))
.map_err(|e| CoreError::from_api(&e, "create device"))?; .map_err(|e| CoreError::from_api(&e, "create device"))?;
StoredDevice { StoredDevice {
uid: d.uid.to_string(), uid: d.uid.to_string(),
+20 -3
View File
@@ -8,8 +8,10 @@
//! real client implements it by forwarding, and the simulation tests implement //! real client implements it by forwarding, and the simulation tests implement
//! it with an in-memory Drive that injects Drive's known faults. //! it with an in-memory Drive that injects Drive's known faults.
//! //!
//! Sharing, Photos, devices, invitations and public links stay on the concrete //! Sharing, Photos, invitations, public links and the rest of the device calls
//! client (`Core::client`) until a test needs them faked. //! stay on the concrete client (`Core::client`) until a test needs them faked.
//! Listing and registering devices are here, so a simulated daemon can sync a
//! local folder.
//! //!
//! The method names match the client's own, so a call site reads the same //! The method names match the client's own, so a call site reads the same
//! whichever it goes through. The client's generic methods (a reader or writer //! whichever it goes through. The client's generic methods (a reader or writer
@@ -26,7 +28,8 @@ use proton_drive_rs::proton_sdk::account::Quota;
use proton_drive_rs::proton_sdk::error::Result; use proton_drive_rs::proton_sdk::error::Result;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid}; use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
use proton_drive_rs::{ use proton_drive_rs::{
DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, RevisionReader, Thumbnail, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient,
RevisionReader, Thumbnail,
}; };
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`]. /// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
@@ -154,6 +157,12 @@ pub(crate) trait DriveApi: Send + Sync {
/// The account's storage, across every Proton product. /// The account's storage, across every Proton product.
async fn quota(&self) -> Result<Quota>; async fn quota(&self) -> Result<Quota>;
/// Every computer that backs up to the account.
async fn enumerate_devices(&self) -> Result<Vec<Device>>;
/// Register a computer, with a root folder of its own named `name`.
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device>;
/// Called by the drain once it has let go of the blob of an upload to /// Called by the drain once it has let go of the blob of an upload to
/// `uid` that landed, or retired the create that made `uid`, before the /// `uid` that landed, or retired the create that made `uid`, before the
/// tree has the node, so a simulation can hold it there. Blocks the drain /// tree has the node, so a simulation can hold it there. Blocks the drain
@@ -470,4 +479,12 @@ impl DriveApi for ProtonDriveClient {
async fn quota(&self) -> Result<Quota> { async fn quota(&self) -> Result<Quota> {
ProtonDriveClient::quota(self).await ProtonDriveClient::quota(self).await
} }
async fn enumerate_devices(&self) -> Result<Vec<Device>> {
ProtonDriveClient::enumerate_devices(self).await
}
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device> {
ProtonDriveClient::create_device(self, name, device_type).await
}
} }
+111 -34
View File
@@ -4227,8 +4227,10 @@ impl Core {
} }
/// Pin the node at mountpoint-relative `rel`. A file downloads its full /// Pin the node at mountpoint-relative `rel`. A file downloads its full
/// plaintext into the content cache; a folder records a recursive pin and /// plaintext into the content cache before this returns. A folder records a
/// downloads every descendant file (selective sync). Returns a human message. /// recursive pin and downloads every descendant file on a background thread,
/// reported as a job: a big folder far outlasts the socket read timeout.
/// Returns a human message.
fn pin(&self, rel: &Path) -> CoreResult<String> { fn pin(&self, rel: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?; let (ino, uid) = self.resolve(rel)?;
let (name, is_folder, mtime, size) = { let (name, is_folder, mtime, size) = {
@@ -4245,34 +4247,93 @@ impl Core {
) )
}; };
if is_folder { if is_folder {
// Offline, every download would fail and the folder would only look
// kept. Refuse instead, as a file pin does.
if !self.is_online() {
return Err(CoreError::offline());
}
// Record the recursive pin first so every descendant is eviction- // Record the recursive pin first so every descendant is eviction-
// exempt before we start filling the cache with the subtree. // exempt before we start filling the cache with the subtree.
self.cache self.cache
.add_pin(&uid, rel, true) .add_pin(&uid, rel, true)
.map_err(|e| CoreError::from_api(&e, "pin"))?; .map_err(|e| CoreError::from_api(&e, "pin"))?;
let n = self.pin_subtree(ino)?; let core = self.clone();
return Ok(format!("{name} ({n} files)")); let folder = name.clone();
std::thread::Builder::new()
.name("pin-folder".into())
.spawn(move || core.pin_subtree(ino, &uid, &folder))
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
return Ok(format!("{name}: downloading its files in the background"));
} }
let bytes = self self.fetch_content(&uid, &name, mtime, size)?;
.download_file_tracked(&uid, &name, size)
.map_err(|e| CoreError::from_api(&e, "download"))?;
self.cache
.store(&uid, mtime, size, &bytes)
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
self.cache self.cache
.add_pin(&uid, rel, false) .add_pin(&uid, rel, false)
.map_err(|e| CoreError::from_api(&e, "pin"))?; .map_err(|e| CoreError::from_api(&e, "pin"))?;
Ok(name) Ok(name)
} }
/// Download and cache every file in the subtree rooted at folder `ino`, /// Download and cache every file in the subtree rooted at the pinned folder
/// returning the count cached (already-fresh blobs counted, not re-fetched). /// `ino` (`folder`, named `name`), as a job the front ends show. Walks the
/// Walks the tree depth-first, enumerating each folder so a cold subtree is /// tree depth-first, enumerating each folder so a cold subtree is fully
/// fully discovered; the lock is dropped before each network download. /// discovered; the lock is dropped before each network download. Stops when
fn pin_subtree(&self, ino: u64) -> CoreResult<usize> { /// the folder is unpinned or the daemon stops.
fn pin_subtree(&self, ino: u64, folder: &NodeUid, name: &str) {
let job = self
.transfers
.begin_job(format!("Making {name} available offline"));
match self.pin_subtree_files(ino) {
Ok(files) => {
job.set_total(files.len() as u64);
let mut failed = 0usize;
for (uid, file, mtime, size) in files {
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
return;
}
job.detail(file.clone());
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
warn!(%uid, error = %e, "pin folder: download failed");
failed += 1;
}
job.step();
}
info!(folder = %name, failed, "pin folder: done");
}
Err(e) => warn!(folder = %name, error = %e, "pin folder: enumerate failed"),
}
}
/// Cache the file `uid` for a folder pin. Its `mtime` and `size` were read
/// before the download, and a create landing or a remote edit can change
/// them while it runs. The blob is then stored under the old ones and reads
/// as stale, so the file is listed as kept but not available offline. Fetch
/// it again under the new ones, a few times at most.
fn fetch_pinned(&self, uid: &NodeUid, name: &str, mtime: i64, size: u64) -> CoreResult<()> {
let mut fetched = (mtime, size);
for _ in 0..3 {
self.fetch_content(uid, name, fetched.0, fetched.1)?;
let current = {
let st = self.state();
st.by_uid
.get(uid)
.and_then(|ino| st.entries.get(ino))
.map(|e| (e.node.modification_time, node_size(&e.node)))
};
match current {
Some(now) if now != fetched => fetched = now,
_ => break,
}
}
Ok(())
}
/// Every file in the subtree rooted at folder `ino`.
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new(); let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
let mut stack = vec![ino]; let mut stack = vec![ino];
while let Some(dir) = stack.pop() { while let Some(dir) = stack.pop() {
if self.shutdown.is_stopping() {
break;
}
self.ensure_children(dir) self.ensure_children(dir)
.map_err(|e| self.errno_error(e, "enumerate"))?; .map_err(|e| self.errno_error(e, "enumerate"))?;
let st = self.state(); let st = self.state();
@@ -4293,22 +4354,7 @@ impl Core {
} }
} }
} }
let mut count = 0; Ok(files)
for (uid, name, mtime, size) in files {
if self.cache.is_cached(&uid, mtime, size) {
count += 1;
continue;
}
match self.download_file_tracked(&uid, &name, size) {
Ok(bytes) => {
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
count += 1;
}
}
Err(e) => warn!(%uid, error = %e, "pin subtree: download failed"),
}
}
Ok(count)
} }
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache /// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
@@ -4354,6 +4400,8 @@ impl Core {
/// Unpin the node at `rel`, evicting its cached content. For a folder, also /// Unpin the node at `rel`, evicting its cached content. For a folder, also
/// evicts every descendant's cached blob (the subtree is no longer kept). /// evicts every descendant's cached blob (the subtree is no longer kept).
/// A node kept only because a folder above it is pinned is refused: there
/// is no pin of its own to remove, and it would stay kept all the same.
fn unpin(&self, rel: &Path) -> CoreResult<String> { fn unpin(&self, rel: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?; let (ino, uid) = self.resolve(rel)?;
let (name, is_folder) = { let (name, is_folder) = {
@@ -4363,14 +4411,24 @@ impl Core {
.map(|e| (e.node.name.clone(), e.node.is_folder())) .map(|e| (e.node.name.clone(), e.node.is_folder()))
.unwrap_or_default() .unwrap_or_default()
}; };
if self.pinned_by_folder(ino) {
return Err(CoreError::conflict(format!(
"{name} is kept offline because a folder above it is; unpin that folder instead"
)));
}
self.cache self.cache
.remove_pin(&uid) .remove_pin(&uid)
.map_err(|e| CoreError::from_api(&e, "unpin"))?; .map_err(|e| CoreError::from_api(&e, "unpin"))?;
// A recursively-pinned folder's descendants were eviction-exempt; now // A recursively-pinned folder's descendants were eviction-exempt; now
// that the pin is gone, reclaim their blobs eagerly instead of waiting // that the pin is gone, reclaim their blobs eagerly instead of waiting
// for budget pressure. Descendants come from the DB node tree. // for budget pressure. Descendants come from the DB node tree. One
// pinned on its own, or under a pinned subfolder, is still kept.
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) { if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
let still_pinned = self.pinned_set();
for s in uids { for s in uids {
if still_pinned.contains(&s) {
continue;
}
if let Some(u) = parse_uid(&s) { if let Some(u) = parse_uid(&s) {
self.cache.evict(&u); self.cache.evict(&u);
self.evict_reader(&u); self.evict_reader(&u);
@@ -4380,6 +4438,20 @@ impl Core {
Ok(name) Ok(name)
} }
/// Whether the node at `ino` is kept offline because a folder above it is
/// pinned, whether or not it is also pinned on its own.
fn pinned_by_folder(&self, ino: u64) -> bool {
let parent = {
let st = self.state();
st.entries
.get(&ino)
.filter(|e| e.parent != ino)
.and_then(|e| st.entries.get(&e.parent))
.map(|p| p.uid.clone())
};
parent.is_some_and(|uid| self.cache.is_pinned(&uid))
}
/// A Photos API handle sharing this Core's Drive client and session, so it /// A Photos API handle sharing this Core's Drive client and session, so it
/// reuses the daemon's single authenticated session rather than logging in /// reuses the daemon's single authenticated session rather than logging in
/// again (Proton refresh tokens are single-use). Cheap — the Drive client /// again (Proton refresh tokens are single-use). Cheap — the Drive client
@@ -4392,10 +4464,13 @@ impl Core {
/// the same lazy remote enumeration `readdir` uses, projected into /// the same lazy remote enumeration `readdir` uses, projected into
/// serializable [`DirEntry`]s (with per-file pin state). /// serializable [`DirEntry`]s (with per-file pin state).
fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> { fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> {
let (ino, _uid) = self.resolve(rel)?; let (ino, uid) = self.resolve(rel)?;
self.ensure_children(ino) self.ensure_children(ino)
.map_err(|e| self.errno_error(e, "enumerate"))?; .map_err(|e| self.errno_error(e, "enumerate"))?;
self.settle_listing_sizes(ino); self.settle_listing_sizes(ino);
// Every entry shares this folder as its parent, so one lookup says
// whether they are all kept through it.
let pinned_by_folder = self.cache.is_pinned(&uid);
// Snapshot the listing, then drop the lock before touching the on-disk // Snapshot the listing, then drop the lock before touching the on-disk
// pin registry so a slow disk read doesn't block FUSE metadata ops. // pin registry so a slow disk read doesn't block FUSE metadata ops.
let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = { let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = {
@@ -4427,7 +4502,8 @@ impl Core {
is_dir, is_dir,
size, size,
modified, modified,
pinned: self.cache.is_pinned(&uid), pinned: pinned_by_folder || self.cache.is_pinned(&uid),
pinned_by_folder,
cached: !is_dir && self.cache.is_cached(&uid, modified, size), cached: !is_dir && self.cache.is_cached(&uid, modified, size),
uid: uid.to_string(), uid: uid.to_string(),
// Listing entries live in the requested dir; the caller derives // Listing entries live in the requested dir; the caller derives
@@ -5319,6 +5395,7 @@ impl Core {
// A trashed node can't be pinned or served from the mount, so its // A trashed node can't be pinned or served from the mount, so its
// content is never current cache: report neither. // content is never current cache: report neither.
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: item.uid, uid: item.uid,
path: String::new(), path: String::new(),
+35 -9
View File
@@ -25,6 +25,7 @@ use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{info, warn}; use tracing::{info, warn};
use super::state::State;
use super::sync::mirror_subtree_unsynced; use super::sync::mirror_subtree_unsynced;
use super::{Core, is_local_uid, parse_uid}; use super::{Core, is_local_uid, parse_uid};
@@ -130,6 +131,24 @@ fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
} }
} }
/// Whether inode `ino` is `root` or lies under it in the tree. Bounded, so a
/// parent cycle in corrupt data cannot hang the caller.
fn ino_is_under(st: &State, mut ino: u64, root: u64) -> bool {
for _ in 0..MAX_TREE_DEPTH {
if ino == root {
return true;
}
match st.entries.get(&ino) {
Some(entry) if entry.parent != ino => ino = entry.parent,
_ => return false,
}
}
false
}
/// How deep [`ino_is_under`] walks before it gives up.
const MAX_TREE_DEPTH: usize = 1024;
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows. /// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool { fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
let root = root.to_string(); let root = root.to_string();
@@ -336,7 +355,7 @@ impl Core {
core.require_uid_writable(authority) core.require_uid_writable(authority)
.map_err(|e| core.errno_error(e, "move access"))?; .map_err(|e| core.errno_error(e, "move access"))?;
} }
if self.has_pending_work_under(&uid)? { if self.has_pending_work_under(ino, &uid)? {
return Err(CoreError::conflict( return Err(CoreError::conflict(
"it has changes that are not uploaded yet; wait for the upload to finish", "it has changes that are not uploaded yet; wait for the upload to finish",
)); ));
@@ -410,10 +429,11 @@ impl Core {
Ok(uid) Ok(uid)
} }
/// Whether the queue owes Drive anything for `uid` or a node under it, or a /// Whether the queue owes Drive anything for `uid` (inode `ino`) or a node
/// file under it is open for writing. Moving such a node away would strand /// under it, or a file under it is open for writing. Moving such a node away
/// the pending change against a path that no longer leads to it. /// would strand the pending change against a path that no longer leads to
fn has_pending_work_under(&self, uid: &NodeUid) -> CoreResult<bool> { /// it.
fn has_pending_work_under(&self, ino: u64, uid: &NodeUid) -> CoreResult<bool> {
let ops = self let ops = self
.db .db
.pending_ops() .pending_ops()
@@ -427,12 +447,18 @@ impl Core {
}) { }) {
return Ok(true); return Ok(true);
} }
// Collected first so no State lock is held across the nodes-table reads. // Open handles are placed in the tree by inode, not by uid in the nodes
let mut writing = Vec::new(); // table. A file opened just after its create landed holds the
// placeholder uid until the tree catches up, and the nodes table no
// longer has that uid, so the move went ahead under an open write.
let mut writing = false;
self.for_each_state(|st| { self.for_each_state(|st| {
writing.extend(st.active_writes.values().map(|w| w.uid.to_string())); writing = st
.active_writes
.keys()
.any(|&open| ino_is_under(st, open, ino));
}); });
Ok(writing.iter().any(|w| under(self, uid, w))) Ok(writing)
} }
/// Bring the source location level with a move Drive has made. /// Bring the source location level with a move Drive has made.
+1
View File
@@ -66,6 +66,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
size, size,
modified: n.modification_time, modified: n.modification_time,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(), path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
uid: n.uid.to_string(), uid: n.uid.to_string(),
+326 -15
View File
@@ -46,6 +46,7 @@ impl Daemon {
/// cache and the database the last run left. /// cache and the database the last run left.
pub(crate) fn start(dir: &Path, client: FakeClient) -> io::Result<Self> { pub(crate) fn start(dir: &Path, client: FakeClient) -> io::Result<Self> {
log_to_test_output(); log_to_test_output();
isolate_from_the_developer();
let mountpoint = dir.join("mnt"); let mountpoint = dir.join("mnt");
let socket = dir.join("control.sock"); let socket = dir.join("control.sock");
std::fs::create_dir_all(&mountpoint)?; std::fs::create_dir_all(&mountpoint)?;
@@ -138,6 +139,40 @@ impl Daemon {
} }
} }
/// Write the new file `path` under the mount, and let the drain send its
/// create only once the create holds the bytes. Otherwise a drain worker
/// still busy with the op before can claim the create before the release
/// attaches them, which the kernel sends after `close` returns. The file
/// then goes out empty, and the bytes follow as a revision, past a hook on
/// the next create.
pub(crate) fn write_new(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
let rel = path
.strip_prefix(&self.mountpoint)
.map_err(io::Error::other)?
.to_string_lossy()
.into_owned();
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
self.request(&paused(true))?;
std::fs::write(path, bytes)?;
let attached = wait_until(Duration::from_secs(30), || {
self.pending().is_ok_and(|items| {
items
.iter()
.any(|item| item.kind == "create" && item.path == rel && item.exportable)
})
});
self.request(&paused(false))?;
match attached {
true => Ok(()),
false => Err(io::Error::other(format!(
"{rel}: create never held the bytes"
))),
}
}
/// Stop the daemon and start it again on the same state and link. /// Stop the daemon and start it again on the same state and link.
pub(crate) fn restart(mut self) -> io::Result<Self> { pub(crate) fn restart(mut self) -> io::Result<Self> {
self.shut_down()?; self.shut_down()?;
@@ -224,6 +259,33 @@ impl tracing::field::Visit for Message {
} }
} }
/// Point the XDG directories of the whole test process at a scratch
/// directory, once, before the first daemon starts.
///
/// The daemon reads `config.json` through `AppDirs` in a few places (the
/// adopted device, the ignore rules, the profile), and those would otherwise
/// be the developer's own: an adopted device of their real account made every
/// sync folder fail to register against the fake Drive.
fn isolate_from_the_developer() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let home = scratch("xdg");
for (var, sub) in [
("XDG_CONFIG_HOME", "config"),
("XDG_STATE_HOME", "state"),
("XDG_CACHE_HOME", "cache"),
("XDG_DATA_HOME", "data"),
] {
let path = home.join(sub);
std::fs::create_dir_all(&path).expect("create a simulation XDG directory");
// SAFETY: the simulations run one at a time (`--test-threads=1`,
// docs/TESTING.md), and this runs before the first of them starts a
// daemon, so no other thread reads the environment meanwhile.
unsafe { std::env::set_var(var, &path) };
}
});
}
/// A client for what `DriveApi` does not cover (sharing, Photos, devices), /// A client for what `DriveApi` does not cover (sharing, Photos, devices),
/// pointed at a closed local port so none of it leaves the machine. /// pointed at a closed local port so none of it leaves the machine.
fn offline_client() -> ProtonDriveClient { fn offline_client() -> ProtonDriveClient {
@@ -759,7 +821,7 @@ mod tests {
let path = daemon.mountpoint.join("c.bin"); let path = daemon.mountpoint.join("c.bin");
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(&path, b"abc").unwrap(); daemon.write_new(&path, b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
let uid = drive.lookup("c.bin").unwrap(); let uid = drive.lookup("c.bin").unwrap();
let landing = daemon.client.hold_next_landing(&uid); let landing = daemon.client.hold_next_landing(&uid);
@@ -805,7 +867,7 @@ mod tests {
); );
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(mnt.join("a.txt"), b"old").unwrap(); daemon.write_new(&mnt.join("a.txt"), b"old").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
let uid = drive.lookup("a.txt").unwrap(); let uid = drive.lookup("a.txt").unwrap();
let landing = daemon.client.hold_next_landing(&uid); let landing = daemon.client.hold_next_landing(&uid);
@@ -843,7 +905,7 @@ mod tests {
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone(); let mnt = daemon.mountpoint.clone();
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(mnt.join("a.txt"), b"old").unwrap(); daemon.write_new(&mnt.join("a.txt"), b"old").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
std::fs::write(mnt.join("a.txt"), b"newer").unwrap(); std::fs::write(mnt.join("a.txt"), b"newer").unwrap();
let uid = drive.lookup("a.txt").unwrap(); let uid = drive.lookup("a.txt").unwrap();
@@ -1026,7 +1088,7 @@ mod tests {
); );
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap(); daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
std::fs::rename(mnt.join("x/c.bin"), mnt.join("y/d")).unwrap(); std::fs::rename(mnt.join("x/c.bin"), mnt.join("y/d")).unwrap();
let uid = drive.lookup("x/c.bin").unwrap(); let uid = drive.lookup("x/c.bin").unwrap();
@@ -1104,7 +1166,7 @@ mod tests {
take_logged(); take_logged();
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap(); daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
let uid = drive.lookup("x/c.bin").unwrap(); let uid = drive.lookup("x/c.bin").unwrap();
// The read-back after the create, the one adopting it, and the size // The read-back after the create, the one adopting it, and the size
@@ -1152,7 +1214,7 @@ mod tests {
})); }));
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap(); daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
let uid = drive.lookup("x/c.bin").unwrap(); let uid = drive.lookup("x/c.bin").unwrap();
let read_back = daemon.client.hold_next_read(&uid); let read_back = daemon.client.hold_next_read(&uid);
@@ -1195,7 +1257,7 @@ mod tests {
})); }));
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap(); daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
// Long enough for the next event poll to bring the file's event, which // Long enough for the next event poll to bring the file's event, which
// drops the folder's listing. // drops the folder's listing.
@@ -1320,7 +1382,9 @@ mod tests {
let folder = drive.lookup("x").unwrap(); let folder = drive.lookup("x").unwrap();
let create = daemon.client.hold_next_create(); let create = daemon.client.hold_next_create();
std::fs::write(mnt.join("x/removed-while-uploading"), b"abc").unwrap(); daemon
.write_new(&mnt.join("x/removed-while-uploading"), b"abc")
.unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
std::fs::remove_file(mnt.join("x/removed-while-uploading")).unwrap(); std::fs::remove_file(mnt.join("x/removed-while-uploading")).unwrap();
std::fs::remove_dir(mnt.join("x")).unwrap(); std::fs::remove_dir(mnt.join("x")).unwrap();
@@ -1565,7 +1629,9 @@ mod tests {
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
daemon.client.lose_reply_to_next_create(); daemon.client.lose_reply_to_next_create();
std::fs::write(daemon.mountpoint.join("f.txt"), b"once").unwrap(); daemon
.write_new(&daemon.mountpoint.join("f.txt"), b"once")
.unwrap();
assert!( assert!(
wait_until(Duration::from_secs(30), || { wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) daemon.pending().is_ok_and(|items| items.is_empty())
@@ -1595,7 +1661,7 @@ mod tests {
let held = daemon.client.hold_reply_to_next_create(); let held = daemon.client.hold_reply_to_next_create();
daemon.client.lose_reply_to_next_create(); daemon.client.lose_reply_to_next_create();
std::fs::write(&path, b"first").unwrap(); daemon.write_new(&path, b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || held.reached())); assert!(wait_until(Duration::from_secs(30), || held.reached()));
std::fs::write(&path, b"second").unwrap(); std::fs::write(&path, b"second").unwrap();
drop(held); drop(held);
@@ -1627,7 +1693,7 @@ mod tests {
let path = daemon.mountpoint.join("f.txt"); let path = daemon.mountpoint.join("f.txt");
daemon.client.lose_reply_to_next_create(); daemon.client.lose_reply_to_next_create();
std::fs::write(&path, b"gone").unwrap(); daemon.write_new(&path, b"gone").unwrap();
assert!(wait_until(Duration::from_secs(30), || { assert!(wait_until(Duration::from_secs(30), || {
drive.tree().contains_key("f.txt") drive.tree().contains_key("f.txt")
})); }));
@@ -1660,7 +1726,7 @@ mod tests {
let held = daemon.client.hold_reply_to_next_create(); let held = daemon.client.hold_reply_to_next_create();
daemon.client.lose_reply_to_next_create(); daemon.client.lose_reply_to_next_create();
std::fs::write(&path, b"first").unwrap(); daemon.write_new(&path, b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || held.reached())); assert!(wait_until(Duration::from_secs(30), || held.reached()));
std::fs::write(&path, b"second").unwrap(); std::fs::write(&path, b"second").unwrap();
drop(held); drop(held);
@@ -1700,7 +1766,9 @@ mod tests {
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
daemon.client.lose_reply_to_next_create(); daemon.client.lose_reply_to_next_create();
std::fs::write(daemon.mountpoint.join("f.txt"), b"first").unwrap(); daemon
.write_new(&daemon.mountpoint.join("f.txt"), b"first")
.unwrap();
assert!(wait_until(Duration::from_secs(30), || { assert!(wait_until(Duration::from_secs(30), || {
daemon daemon
.pending() .pending()
@@ -1739,7 +1807,9 @@ mod tests {
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
daemon.client.lose_reply_to_next_create(); daemon.client.lose_reply_to_next_create();
std::fs::write(daemon.mountpoint.join("f.txt"), b"first").unwrap(); daemon
.write_new(&daemon.mountpoint.join("f.txt"), b"first")
.unwrap();
assert!(wait_until(Duration::from_secs(30), || { assert!(wait_until(Duration::from_secs(30), || {
daemon daemon
.pending() .pending()
@@ -1833,7 +1903,7 @@ mod tests {
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
let held = daemon.client.hold_reply_to_next_create(); let held = daemon.client.hold_reply_to_next_create();
std::fs::write(mount.join("c.bin"), b"first").unwrap(); daemon.write_new(&mount.join("c.bin"), b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || held.reached())); assert!(wait_until(Duration::from_secs(30), || held.reached()));
std::fs::rename(mount.join("c.bin"), mount.join("d")).unwrap(); std::fs::rename(mount.join("c.bin"), mount.join("d")).unwrap();
std::fs::write(mount.join("c.bin"), b"second").unwrap(); std::fs::write(mount.join("c.bin"), b"second").unwrap();
@@ -2099,4 +2169,245 @@ mod tests {
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_pinned_folder_keeps_every_file_under_it_until_it_is_unpinned() {
let drive = FakeDrive::new();
let dir = scratch("pin-folder");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir_all(daemon.mountpoint.join("Docs/sub")).unwrap();
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
std::fs::write(daemon.mountpoint.join("Docs/sub/b.txt"), b"beta").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Docs/sub/b.txt").is_some()
}));
daemon.request(&Request::PurgeCache).unwrap();
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
path: folder.into(),
}) {
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
_ => None,
};
assert!(entry("Docs", "a.txt").is_some_and(|e| !e.cached && !e.pinned));
daemon
.request(&Request::Pin {
path: "Docs".into(),
})
.unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached)
&& entry("Docs/sub", "b.txt").is_some_and(|e| e.cached)
}),
"the folder's files were downloaded in the background"
);
let a = entry("Docs", "a.txt").unwrap();
assert!(a.pinned && a.pinned_by_folder);
assert!(entry("", "Docs").is_some_and(|e| e.pinned && !e.pinned_by_folder));
assert!(
daemon
.request(&Request::Unpin {
path: "Docs/a.txt".into(),
})
.is_err(),
"a file kept through its folder cannot be unpinned on its own"
);
daemon
.request(&Request::Unpin {
path: "Docs".into(),
})
.unwrap();
let a = entry("Docs", "a.txt").unwrap();
assert!(!a.pinned && !a.pinned_by_folder && !a.cached);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_folder_pinned_while_a_create_in_it_lands_keeps_that_file() {
// The pin's download of a file started before the file's landing set
// its modification time from Drive, and ended after it: the blob was
// stored under the old time, and the file never read as cached (B208).
let drive = FakeDrive::new();
let dir = scratch("pin-folder-landing");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
let entry = || match daemon.request(&Request::ListDir {
path: "Docs".into(),
}) {
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == "a.txt"),
_ => None,
};
// Drive stamps the file with the time it landed, a second after it
// was written, so the landing moves its modification time.
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
daemon.request(&paused(true)).unwrap();
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
std::thread::sleep(Duration::from_millis(1100));
let landing = daemon.client.hold_next_landing_of_any();
daemon.request(&paused(false)).unwrap();
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
let real = drive.lookup("Docs/a.txt").unwrap();
let read_back = daemon.client.hold_next_read(&real);
drop(landing);
assert!(wait_until(Duration::from_secs(30), || read_back.reached()));
let written = entry().unwrap().modified;
// Without the blob the landing kept, the pin downloads the file.
daemon.request(&Request::PurgeCache).unwrap();
let download = daemon.client.hold_next_download(&real);
take_logged();
daemon
.request(&Request::Pin {
path: "Docs".into(),
})
.unwrap();
assert!(wait_until(Duration::from_secs(30), || download.reached()));
drop(read_back);
assert!(wait_until(Duration::from_secs(30), || {
entry().is_some_and(|e| e.modified != written)
}));
drop(download);
let mut logged = Vec::new();
assert!(
wait_until(Duration::from_secs(30), || {
logged.extend(take_logged());
logged
.iter()
.any(|line| line.starts_with("pin folder: done"))
}),
"{logged:?}"
);
assert!(
entry().is_some_and(|e| e.cached),
"the file kept through its folder is available offline: {:?}",
entry()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Register `local` as a synced folder in `mode` and wait until the daemon
/// lists it that way, returning its id.
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
std::fs::create_dir_all(local).unwrap();
let path = local.canonicalize().unwrap().to_string_lossy().into_owned();
daemon
.request(&Request::AddSyncFolder {
local_path: path.clone(),
})
.unwrap();
let listed = |mode: &str| match daemon.request(&Request::ListSyncFolders) {
Ok(Response::SyncFolders { items }) => items
.into_iter()
.find(|f| {
f.local_path == path
&& f.mode == mode
&& f.pending_mode.is_none()
&& f.last_sync > 0
})
.map(|f| f.id),
_ => None,
};
assert!(
wait_until(Duration::from_secs(30), || listed("mirror").is_some()),
"{} was registered: {:?}",
local.display(),
daemon.request(&Request::ListSyncFolders)
);
let id = listed("mirror").unwrap();
// Reads lag behind creates, so the remote folder the first pass made
// is not readable at once; switching modes reads it.
std::thread::sleep(Duration::from_millis(200));
if mode != "mirror" {
daemon
.request(&Request::SetSyncFolderMode {
id,
mode: mode.into(),
})
.unwrap();
assert!(
wait_until(Duration::from_secs(30), || listed(mode).is_some()
&& is_mounted(local)),
"{} switched to {mode}: {:?}",
local.display(),
daemon.request(&Request::ListSyncFolders)
);
}
id
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_move_out_of_an_on_demand_folder_refuses_a_file_still_open_for_writing() {
// The account run's "move refuses a file still being written" case let
// a move from an on-demand synced folder into a mirror folder go ahead
// while the file was open, so the write landed after the move.
let drive = FakeDrive::new();
let dir = scratch("move-open-out-of-ondemand");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let ondemand = dir.join("ondemand");
let mirror = dir.join("mirror");
sync_folder(&daemon, &ondemand, "ondemand");
sync_folder(&daemon, &mirror, "mirror");
let source = ondemand.join("open");
std::fs::create_dir(&source).unwrap();
daemon.request(&Request::SyncNow { id: None }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
// The account run opened the file a moment after its create landed:
// the queue was already empty, and the tree still had the
// placeholder uid it gave the file.
let landing = daemon.client.hold_next_landing_of_any();
{
let mut file = std::fs::File::create(source.join("open.txt")).unwrap();
std::io::Write::write_all(&mut file, b"before").unwrap();
file.sync_all().unwrap();
}
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(source.join("open.txt"))
.unwrap();
std::io::Write::write_all(&mut file, b" and after").unwrap();
let moved = daemon.request(&Request::Move {
path: source.to_string_lossy().into_owned(),
new_parent: mirror
.canonicalize()
.unwrap()
.to_string_lossy()
.into_owned(),
});
drop(file);
drop(landing);
match moved {
Err(e) => assert!(
e.to_string().contains("not uploaded yet"),
"refused for another reason: {e}"
),
Ok(answer) => panic!("the move went ahead: {answer:?}"),
}
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
} }
+96 -3
View File
@@ -31,6 +31,9 @@
//! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with //! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with
//! `InsufficientQuota`. //! `InsufficientQuota`.
//! //!
//! It also holds the account's computers, each with a root folder outside My
//! files, so a simulated daemon can register its own and sync a local folder.
//!
//! A test can also hold the next read of one node on its way to Drive //! A test can also hold the next read of one node on its way to Drive
//! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits //! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits
//! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]), //! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]),
@@ -54,9 +57,12 @@ use parking_lot::Mutex;
use proton_drive_rs::proton_sdk::account::Quota; use proton_drive_rs::proton_sdk::account::Quota;
use proton_drive_rs::proton_sdk::api::ResponseCode; use proton_drive_rs::proton_sdk::api::ResponseCode;
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError, Result}; use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError, Result};
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId}; use proton_drive_rs::proton_sdk::ids::{
DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, VolumeId,
};
use proton_drive_rs::{ use proton_drive_rs::{
DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem, RevisionState, Thumbnail, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem,
RevisionState, Thumbnail,
}; };
use sha1::{Digest as _, Sha1}; use sha1::{Digest as _, Sha1};
@@ -111,6 +117,9 @@ struct Server {
block_pattern: Vec<u64>, block_pattern: Vec<u64>,
/// The account's storage limit in bytes. /// The account's storage limit in bytes.
max_space: i64, max_space: i64,
/// The account's computers, oldest first, as `(name, type, root folder)`.
/// [`Computer`] is not `Clone`, so each answer builds them afresh.
devices: Vec<(String, DeviceType, NodeUid)>,
} }
impl Server { impl Server {
@@ -162,6 +171,20 @@ impl Server {
false false
} }
/// The `n`th computer of the account, as Drive lists it.
fn computer(&self, n: usize) -> Computer {
let (name, device_type, root) = &self.devices[n];
Computer {
uid: DeviceUid::new(format!("device{}", n + 1)),
device_type: *device_type,
name: Ok(name.clone()),
root_folder_uid: root.clone(),
creation_time: self.current(root).map_or(0, |remote| remote.created),
last_sync_time: None,
share_id: ShareId::new(format!("device-share{}", n + 1)),
}
}
fn mint_uid(&mut self) -> NodeUid { fn mint_uid(&mut self) -> NodeUid {
self.next_link += 1; self.next_link += 1;
uid(&format!("L{}", self.next_link)) uid(&format!("L{}", self.next_link))
@@ -305,6 +328,7 @@ impl FakeDrive {
events: Vec::new(), events: Vec::new(),
block_pattern: vec![pdfs_core::cache::BLOCK_SIZE], block_pattern: vec![pdfs_core::cache::BLOCK_SIZE],
max_space: 1 << 40, max_space: 1 << 40,
devices: Vec::new(),
}; };
let now = now_secs(); let now = now_secs();
server.nodes.insert( server.nodes.insert(
@@ -338,6 +362,8 @@ impl FakeDrive {
hold: Mutex::new(None), hold: Mutex::new(None),
hold_answer: Mutex::new(None), hold_answer: Mutex::new(None),
hold_trash: Mutex::new(None), hold_trash: Mutex::new(None),
hold_download: Mutex::new(None),
hold_any_landing: AtomicBool::new(false),
hold_landing: Mutex::new(None), hold_landing: Mutex::new(None),
drop_after_create: AtomicBool::new(false), drop_after_create: AtomicBool::new(false),
lose_create_reply: AtomicBool::new(false), lose_create_reply: AtomicBool::new(false),
@@ -616,8 +642,13 @@ struct ClientInner {
hold_answer: Mutex<Option<Arc<HoldState>>>, hold_answer: Mutex<Option<Arc<HoldState>>>,
/// The trash [`FakeClient::hold_next_trash`] is waiting for. /// The trash [`FakeClient::hold_next_trash`] is waiting for.
hold_trash: Mutex<Option<Arc<HoldState>>>, hold_trash: Mutex<Option<Arc<HoldState>>>,
/// The download [`FakeClient::hold_next_download`] is waiting for.
hold_download: Mutex<Option<Arc<HoldState>>>,
/// The landing [`FakeClient::hold_next_landing`] is waiting for. /// The landing [`FakeClient::hold_next_landing`] is waiting for.
hold_landing: Mutex<Option<Arc<HoldState>>>, hold_landing: Mutex<Option<Arc<HoldState>>>,
/// Set by [`FakeClient::hold_next_landing_of_any`]: hold whichever node
/// lands next.
hold_any_landing: AtomicBool,
/// Set by [`FakeClient::drop_link_after_next_create`]. /// Set by [`FakeClient::drop_link_after_next_create`].
drop_after_create: AtomicBool, drop_after_create: AtomicBool,
/// Set by [`FakeClient::lose_reply_to_next_create`]. /// Set by [`FakeClient::lose_reply_to_next_create`].
@@ -755,6 +786,18 @@ impl FakeClient {
Held(state) Held(state)
} }
/// Hold the next download of `uid`'s content once Drive has answered it,
/// before the bytes reach the daemon, until the answer is dropped.
pub(crate) fn hold_next_download(&self, uid: &NodeUid) -> Held {
let state = Arc::new(HoldState {
uid: uid.clone(),
reached: AtomicBool::new(false),
released: AtomicBool::new(false),
});
*self.0.hold_download.lock() = Some(state.clone());
Held(state)
}
/// Hold the drain once the next upload to `uid` has landed and its blob /// Hold the drain once the next upload to `uid` has landed and its blob
/// is let go of, before the tree has the node (`DriveApi::landing`), until /// is let go of, before the tree has the node (`DriveApi::landing`), until
/// the answer is dropped. /// the answer is dropped.
@@ -768,6 +811,14 @@ impl FakeClient {
Held(state) Held(state)
} }
/// [`FakeClient::hold_next_landing`] for whichever node lands next, such
/// as a new file, whose uid Drive has not minted yet.
pub(crate) fn hold_next_landing_of_any(&self) -> Held {
let held = self.hold_next_landing(&uid("*"));
self.0.hold_any_landing.store(true, Ordering::SeqCst);
held
}
/// Make the next file create on Drive, then hold its answer until the /// Make the next file create on Drive, then hold its answer until the
/// answer is dropped, as a slow link does. /// answer is dropped, as a slow link does.
pub(crate) fn hold_reply_to_next_create(&self) -> Held { pub(crate) fn hold_reply_to_next_create(&self) -> Held {
@@ -1396,6 +1447,8 @@ impl DriveApi for FakeClient {
.ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found")) .ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found"))
}) })
.await?; .await?;
self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid))
.await;
output.write_all(&content).map_err(io_error) output.write_all(&content).map_err(io_error)
} }
@@ -1484,8 +1537,12 @@ impl DriveApi for FakeClient {
fn landing(&self, uid: &NodeUid) { fn landing(&self, uid: &NodeUid) {
let state = { let state = {
let mut hold = self.0.hold_landing.lock(); let mut hold = self.0.hold_landing.lock();
let any = self.0.hold_any_landing.load(Ordering::SeqCst);
match hold.as_ref() { match hold.as_ref() {
Some(state) if state.uid == *uid => hold.take(), Some(state) if any || state.uid == *uid => {
self.0.hold_any_landing.store(false, Ordering::SeqCst);
hold.take()
}
_ => None, _ => None,
} }
}; };
@@ -1506,6 +1563,42 @@ impl DriveApi for FakeClient {
.await .await
} }
async fn enumerate_devices(&self) -> Result<Vec<Computer>> {
self.request(false, |server| {
Ok((0..server.devices.len())
.map(|n| server.computer(n))
.collect())
})
.await
}
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Computer> {
self.request(true, |server| {
// A device root has no parent: like on Drive, it is not in My files.
// It is listed at once, as the My files root is: the listing lag is
// about children appearing, and the daemon lists the new root next.
let root = server.mint_uid();
let now = now_secs();
server.nodes.insert(
root.clone(),
vec![(
Instant::now() - HISTORY,
Some(Remote {
parent: None,
name: name.to_owned(),
trashed: false,
created: now,
modified: now,
file: None,
}),
)],
);
server.devices.push((name.to_owned(), device_type, root));
Ok(server.computer(server.devices.len() - 1))
})
.await
}
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> { async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
self.request(false, |server| { self.request(false, |server| {
Ok(server Ok(server
+53 -27
View File
@@ -520,9 +520,9 @@ pub(crate) fn clear_selection(ui: &Rc<Ui>) {
} }
/// Reflect the current selection in the bulk bar: how many are selected, and /// Reflect the current selection in the bulk bar: how many are selected, and
/// which bulk actions apply to that mix. Offline state is a file-only notion, so /// which bulk actions apply to that mix. An entry kept offline through a pinned
/// a folder anywhere in the selection disables the two pin buttons rather than /// folder above it cannot be made online only on its own, so it does not count
/// half-failing once pressed. /// towards Make online only.
pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) { pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
let entries = selected_entries(ui); let entries = selected_entries(ui);
// One selected entry is the details pane's job; the bar is for a batch. // One selected entry is the details pane's job; the bar is for a batch.
@@ -530,8 +530,7 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
ui.browser.bulk.set_reveal_child(false); ui.browser.bulk.set_reveal_child(false);
return; return;
} }
let files_only = entries.iter().all(|e| !e.is_dir); let any_pinned = entries.iter().any(|e| e.pinned && !e.pinned_by_folder);
let any_pinned = entries.iter().any(|e| e.pinned);
let any_unpinned = entries.iter().any(|e| !e.pinned); let any_unpinned = entries.iter().any(|e| !e.pinned);
let mounted = *ui.mounted.borrow(); let mounted = *ui.mounted.borrow();
ui.browser.bulk_label.set_label(&ngettext_f( ui.browser.bulk_label.set_label(&ngettext_f(
@@ -541,12 +540,8 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
&[], &[],
)); ));
ui.browser.bulk_trash.set_sensitive(mounted); ui.browser.bulk_trash.set_sensitive(mounted);
ui.browser ui.browser.bulk_pin.set_sensitive(mounted && any_unpinned);
.bulk_pin ui.browser.bulk_unpin.set_sensitive(mounted && any_pinned);
.set_sensitive(mounted && files_only && any_unpinned);
ui.browser
.bulk_unpin
.set_sensitive(mounted && files_only && any_pinned);
ui.browser.bulk.set_reveal_child(true); ui.browser.bulk.set_reveal_child(true);
} }
@@ -652,16 +647,19 @@ pub(crate) fn restore_uids(ui: &Rc<Ui>, uids: Vec<String>, count: usize) {
); );
} }
/// Pin or unpin every selected file, one request at a time. /// Pin or unpin every selected entry, one request at a time. A folder's files
/// download in the background, so its request returns before they are there.
pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) { pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
let socket = ui.dirs.control_socket(); let socket = ui.dirs.control_socket();
// Only the entries that need changing: re-pinning an already-pinned file is // Only the entries that need changing: re-pinning an already-pinned file is
// a wasted round-trip, and it would inflate the count the toast reports. // a wasted round-trip, and it would inflate the count the toast reports. One
let paths: Vec<String> = entries // kept through a pinned folder above it has no pin of its own to remove.
let changing: Vec<&DirEntry> = entries
.iter() .iter()
.filter(|e| !e.is_dir && e.pinned != pin) .filter(|e| !e.pinned_by_folder && e.pinned != pin)
.map(|e| entry_rel(ui, e))
.collect(); .collect();
let any_folder = changing.iter().any(|e| e.is_dir);
let paths: Vec<String> = changing.iter().map(|e| entry_rel(ui, e)).collect();
if paths.is_empty() { if paths.is_empty() {
return; return;
} }
@@ -698,13 +696,27 @@ pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
(0, None) => {} (0, None) => {}
(n, _) => toast( (n, _) => toast(
&ui, &ui,
&if pin { &if pin && any_folder {
ngettext_f(
"Making {n} item available offline…",
"Making {n} items available offline…",
n as u64,
&[],
)
} else if pin {
ngettext_f( ngettext_f(
"{n} file is now available offline", "{n} file is now available offline",
"{n} files are now available offline", "{n} files are now available offline",
n as u64, n as u64,
&[], &[],
) )
} else if any_folder {
ngettext_f(
"{n} item is now online only",
"{n} items are now online only",
n as u64,
&[],
)
} else { } else {
ngettext_f( ngettext_f(
"{n} file is now online only", "{n} file is now online only",
@@ -860,13 +872,17 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
} }
menu.section(); menu.section();
if !entry.is_dir { // Kept through a pinned folder above it, the entry has no pin of its own
// to remove: the check shows that state without offering to change it.
if entry.pinned_by_folder {
menu.locked_toggle(&gettext("Available offline"), true);
} else {
let (ui_c, entry_c) = (ui.clone(), entry.clone()); let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.toggle(&gettext("Available offline"), entry.pinned, move |_| { menu.toggle(&gettext("Available offline"), entry.pinned, move |_| {
toggle_pin(&ui_c, &entry_c) toggle_pin(&ui_c, &entry_c)
}); });
menu.section();
} }
menu.section();
let (ui_c, entry_c) = (ui.clone(), entry.clone()); let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Share…"), move || { menu.item(&gettext("Share…"), move || {
@@ -908,9 +924,8 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
/// simply absent here rather than offered and then refused. /// simply absent here rather than offered and then refused.
pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu { pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
let mut menu = ActionMenu::new(); let mut menu = ActionMenu::new();
// Offline state is a file-only notion; the check shows whether the whole // The check shows whether the whole batch already is offline.
// batch already is. {
if entries.iter().all(|e| !e.is_dir) {
let (ui_c, batch) = (ui.clone(), entries.clone()); let (ui_c, batch) = (ui.clone(), entries.clone());
let all_pinned = entries.iter().all(|e| e.pinned); let all_pinned = entries.iter().all(|e| e.pinned);
menu.toggle(&gettext("Available offline"), all_pinned, move |pin| { menu.toggle(&gettext("Available offline"), all_pinned, move |pin| {
@@ -1147,6 +1162,7 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
let rx = spawn_request(ui.dirs.control_socket(), req); let rx = spawn_request(ui.dirs.control_socket(), req);
let name = entry.name.clone(); let name = entry.name.clone();
let pinned = entry.pinned; let pinned = entry.pinned;
let is_dir = entry.is_dir;
let ui = ui.clone(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
match rx.recv().await { match rx.recv().await {
@@ -1166,6 +1182,9 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
&ui, &ui,
&if pinned { &if pinned {
gettext_f("“{name}” is now online only", &[("name", &name)]) gettext_f("“{name}” is now online only", &[("name", &name)])
} else if is_dir {
// The daemon downloads a folder's files in the background.
gettext_f("Making “{name}” available offline…", &[("name", &name)])
} else { } else {
gettext_f("“{name}” is now available offline", &[("name", &name)]) gettext_f("“{name}” is now available offline", &[("name", &name)])
}, },
@@ -2487,16 +2506,19 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
}); });
} }
/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders /// The sync-state badge for an entry: `(icon, css-class)`. A folder carries no
/// (which carry no per-file cache state). A sync issue ranks first, on folders /// cache state of its own, so it has one only when kept offline. A sync issue
/// too: the change waits for the user. Pinned (kept offline) ranks above merely /// ranks first, on folders too: the change waits for the user. Pinned (kept
/// cached (downloaded, evictable); everything else is online-only. /// offline) ranks above merely cached (downloaded, evictable); everything else
/// is online-only.
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> { pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
if entry.issue.is_some() { if entry.issue.is_some() {
return Some(("dialog-warning-symbolic", "badge-issue")); return Some(("dialog-warning-symbolic", "badge-issue"));
} }
if entry.is_dir { if entry.is_dir {
return None; return entry
.pinned
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
} }
Some(if entry.pinned { Some(if entry.pinned {
("pdfs-offline-symbolic", "badge-pinned") ("pdfs-offline-symbolic", "badge-pinned")
@@ -2905,6 +2927,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
size: h.size, size: h.size,
modified: h.modified, modified: h.modified,
pinned: h.pinned, pinned: h.pinned,
pinned_by_folder: false,
cached: h.cached, cached: h.cached,
uid: h.uid.clone(), uid: h.uid.clone(),
path: h.path.clone(), path: h.path.clone(),
@@ -2965,6 +2988,7 @@ mod tests {
size, size,
modified, modified,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: String::new(), uid: String::new(),
path: String::new(), path: String::new(),
@@ -2992,6 +3016,8 @@ mod tests {
let mut folder = entry("d", true, 0, 0); let mut folder = entry("d", true, 0, 0);
assert_eq!(badge_for(&folder), None); assert_eq!(badge_for(&folder), None);
folder.pinned = true;
assert_eq!(badge_for(&folder).unwrap().1, "badge-pinned");
folder.issue = Some(SyncIssue::Missing); folder.issue = Some(SyncIssue::Missing);
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue"); assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
} }
@@ -622,6 +622,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
size: 0, size: 0,
modified: item.modified, modified: item.modified,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: item.uid.clone(), uid: item.uid.clone(),
path: item.path.clone(), path: item.path.clone(),
+1 -1
View File
@@ -1538,7 +1538,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(gettext("No files kept offline")) .title(gettext("No files kept offline"))
.subtitle(gettext( .subtitle(gettext(
"Right-click a file and choose “Make available offline”.", "Right-click a file or folder and choose “Available offline”.",
)) ))
.build(); .build();
ui.status.pins_group.add(&row); ui.status.pins_group.add(&row);
+1
View File
@@ -524,6 +524,7 @@ mod tests {
size: 0, size: 0,
modified, modified,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: name.into(), uid: name.into(),
path: String::new(), path: String::new(),
+13 -4
View File
@@ -384,11 +384,20 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
} else { } else {
parent.to_string() parent.to_string()
}); });
// Pinning is per file: the context menu and the selection bar offer it for // Kept through a pinned folder above it, the entry has no pin of its own
// files only, so the pane must not offer it for folders either. // to remove, so the switch shows that state without offering to change it.
d.pin_row.set_active(entry.pinned); d.pin_row.set_active(entry.pinned);
d.pin_row.set_visible(!entry.is_dir); d.pin_row.set_subtitle(&if entry.pinned_by_folder {
d.pin_row.set_sensitive(*ui.mounted.borrow()); gettext("Kept on this computer because a folder it is in is available offline.")
} else if entry.is_dir {
gettext(
"Keep a copy of everything in this folder on this computer, even without a connection.",
)
} else {
gettext("Keep a copy on this computer, even without a connection.")
});
d.pin_row
.set_sensitive(*ui.mounted.borrow() && !entry.pinned_by_folder);
d.open_button.set_label(&if entry.is_dir { d.open_button.set_label(&if entry.is_dir {
gettext("Open folder") gettext("Open folder")
} else { } else {
+12
View File
@@ -61,6 +61,18 @@ impl ActionMenu {
self self
} }
/// A greyed-out check item showing `active`, for a state the user cannot
/// change from here.
pub(crate) fn locked_toggle(&mut self, label: &str, active: bool) -> &mut Self {
let name = self.next_action();
let action = gio::SimpleAction::new_stateful(&name, None, &active.to_variant());
action.set_enabled(false);
self.group.add_action(&action);
self.section
.append(Some(label), Some(&format!("menu.{name}")));
self
}
/// Close the current section; the next item starts a new one below a /// Close the current section; the next item starts a new one below a
/// separator. An empty section is skipped, so callers can end a group of /// separator. An empty section is skipped, so callers can end a group of
/// conditional items unconditionally. /// conditional items unconditionally.
+5 -2
View File
@@ -77,13 +77,16 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
} }
/// The sync badge of a Drive file, as in the app's browser: pinned, cached, /// The sync badge of a Drive file, as in the app's browser: pinned, cached,
/// or online only. Folders and local files have none. /// or online only. A folder has one only when kept offline; local files have
/// none.
pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> { pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> {
let Hit::Drive(drive) = hit else { let Hit::Drive(drive) = hit else {
return None; return None;
}; };
if drive.is_dir { if drive.is_dir {
return None; return drive
.pinned
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
} }
Some(if drive.pinned { Some(if drive.pinned {
("pdfs-offline-symbolic", "badge-pinned") ("pdfs-offline-symbolic", "badge-pinned")
+60
View File
@@ -100,6 +100,8 @@ a live account since.
| Entry | Title | | Entry | Title |
|---|---| |---|---|
| [B208](#b208--a-file-landing-while-its-folder-is-pinned-is-kept-but-not-available-offline) | A file landing while its folder is pinned is kept but not available offline |
| [B207](#b207--a-move-between-locations-goes-ahead-under-a-file-opened-just-after-its-create-landed) | A move between locations goes ahead under a file opened just after its create landed |
| [B206](#b206--an-upgrade-leaves-the-old-daemon-running-and-nothing-says-so) | An upgrade leaves the old daemon running, and nothing says so | | [B206](#b206--an-upgrade-leaves-the-old-daemon-running-and-nothing-says-so) | An upgrade leaves the old daemon running, and nothing says so |
| [B205](#b205--a-photo-upload-the-sdk-refuses-says-youre-offline) | A photo upload the SDK refuses says "You're offline" | | [B205](#b205--a-photo-upload-the-sdk-refuses-says-youre-offline) | A photo upload the SDK refuses says "You're offline" |
| [B204](#b204--a-write-over-a-partial-edit-whose-base-was-never-read-fails-with-eio) | A write over a partial edit whose base was never read fails with `EIO` | | [B204](#b204--a-write-over-a-partial-edit-whose-base-was-never-read-fails-with-eio) | A write over a partial edit whose base was never read fails with `EIO` |
@@ -309,6 +311,64 @@ a live account since.
Newest first. Newest first.
### B208 — A file landing while its folder is pinned is kept but not available offline
**Status:** Fixed, not verified live
**Found:** 2026-10-11, CI simulation run of `3e4886f`.
`sim::daemon::tests::a_pinned_folder_keeps_every_file_under_it_until_it_is_unpinned` waited
30 s for `Docs/sub/b.txt` to read as cached and gave up. It failed about once in 25 runs under load
and never alone.
**Where:** `Core::pin_subtree` in `crates/pdfs-fuse/src/lib.rs`
**Repro:** Pin a folder while a create in it lands, after the drain has repointed the tree at
the real uid and before it has read the node back. The file's cached blob must be gone, as after
`pdfs cache clear`, so the pin downloads it.
**Cause.** The pin walks the folder first and downloads each file under the modification time
and size it read then. The landing's read-back then sets the time Drive gave the file, and keeps
the uploaded bytes under it. The pin's download ended after that and stored the blob again under
the old time. The cache records one time per blob, so the file read as stale: shown as kept
offline, but downloaded again on the next read, and unreadable offline.
**Fix.** After each download the pin compares the file's time and size with the tree's. If they
moved, it fetches the file again under the new ones, at most three times.
**Test:** `sim::daemon::tests::a_folder_pinned_while_a_create_in_it_lands_keeps_that_file` holds
the read-back and the pin's download with `FakeClient::hold_next_download`, new for this, and
lets them go in that order.
---
### B207 — A move between locations goes ahead under a file opened just after its create landed
**Status:** Fixed, not verified live
**Found:** 2026-10-11, account acceptance run of 3.3.0, case "move refuses a file still being
written" on the on-demand → mirror pairing. `pdfs move` moved the folder while `open.txt` in it
was open for writing, and the write landed after the move.
**Where:** `Core::has_pending_work_under` in `crates/pdfs-fuse/src/relocate.rs`; `retire_create`
in `crates/pdfs-fuse/src/drain.rs`
**Repro:** Create a file in a mount, and open it for writing right after its create lands, before
the drain has repointed the tree at the real uid. Then `pdfs move` its folder to another
location. The account run did this by chance: it opened the file about a second after the
create landed.
**Cause.** `retire_create` takes the create out of the queue and re-keys its `nodes` row to the
real uid first, then repoints the in-memory tree and the open write handles. A file opened in
between gets a write handle that holds the placeholder uid. `has_pending_work_under` placed each
open handle by looking its uid up in the `nodes` table, which no longer has the placeholder, so it
found nothing open under the folder. The queue was already empty, and the move went ahead.
**Fix.** Open handles are placed in the in-memory tree by inode, which does not change when a
create lands.
**Test:** `sim::daemon::tests::a_move_out_of_an_on_demand_folder_refuses_a_file_still_open_for_writing`
holds the drain at that point with `FakeClient::hold_next_landing_of_any`. The simulation can now
sync local folders: the fake Drive lists and registers devices, and the simulated daemons no longer
read the developer's own `config.json`.
---
### B206 — An upgrade leaves the old daemon running, and nothing says so ### B206 — An upgrade leaves the old daemon running, and nothing says so
**Status:** Fixed, not verified live **Status:** Fixed, not verified live
+29
View File
@@ -11,6 +11,34 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
## [3.4.0] — 2026-10-12
Schema: **42**, unchanged.
### Added
- **A binary cache for the Nix flake.** Every release and every build of `main` is uploaded to
`proton-drive-linux.cachix.org`, so NixOS installs download the client instead of compiling
it. See [Install → Binary cache](INSTALL.md#binary-cache).
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
- **Folders can be made available offline in the app.** The context menu, the details pane and
the selection bar now offer it for folders too. Every file in the folder and its subfolders
downloads in the background with its progress in the tray and the app, and pinned folders carry
the offline badge. A file kept through its folder shows as available offline but cannot be
made online only on its own. `pdfs pin` on a folder now returns at once instead of timing out
on a large folder, and pinned files download to disk instead of into memory.
### Fixed
- **Removing the package left the drive mounted, and deletes there still reached Proton Drive.**
The service kept running from the removed program, so deleting `~/ProtonDrive` afterwards moved
its files to the Proton Drive trash. The .deb, .rpm and Arch packages now stop the service of
every logged-in user before they are removed. Upgrades are not affected.
- **`pdfs move` between two locations could move a folder with a file open for writing in it**,
when the file had been opened just after it first reached Proton Drive. The write then landed
after the move. Such a move is refused now, as it always was meant to be (B207).
- **The Fedora package installed KDE packages on GNOME.** Its recommendation of KWallet is now
only for Plasma, the GNOME keyring only for desktops other than Plasma, and the AppIndicator
extension only for GNOME.
## [3.3.0] — 2026-10-11 ## [3.3.0] — 2026-10-11
Schema: **42**, unchanged. Schema: **42**, unchanged.
@@ -1758,6 +1786,7 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem. by a new POSIX compliance suite for the filesystem.
[3.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.0
[3.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.3.0 [3.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.3.0
[3.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.1 [3.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.1
+3 -3
View File
@@ -65,9 +65,9 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. | | `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
| `pdfs unpin PATH` | Release it back to online only. | | `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
| `pdfs pins` | List files kept available offline. | | `pdfs pins` | List files and folders kept available offline. |
## Synced folders ## Synced folders
+2 -1
View File
@@ -154,7 +154,8 @@ Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.y
`packaging/PKGBUILD` agree; `packaging/PKGBUILD` agree;
2. runs the quality gates; 2. runs the quality gates;
3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`, and 3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`, and
builds the Nix flake at the tag, which is what a flake input pinned to `vX.Y.Z` gets; builds the Nix flake at the tag, which is what a flake input pinned to `vX.Y.Z` gets, and
pushes it to the `proton-drive-linux` Cachix cache (secret `CACHIX_AUTH_TOKEN`);
4. publishes them as a GitHub release, with the version's section of `docs/CHANGELOG.md` as the 4. publishes them as a GitHub release, with the version's section of `docs/CHANGELOG.md` as the
notes; notes;
5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them 5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them
+29 -2
View File
@@ -112,6 +112,26 @@ inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.3.0";
Each release tag is built with Nix before the release is published. Each release tag is built with Nix before the release is published.
#### Binary cache
CI uploads every build of `main` and of each release tag to a Cachix binary cache, so Nix can
download the client instead of compiling it. A system build does not read a flake input's cache
settings, so add the cache to your NixOS configuration once:
```nix
nix.settings = {
substituters = [ "https://proton-drive-linux.cachix.org" ];
trusted-public-keys = [
"proton-drive-linux.cachix.org-1:w1+3pXVXyTlqkFGn3dWpUhlaJtyGhSm3X0mFwfa++AA="
];
};
```
The cache holds the build against the `nixpkgs` this flake locks. Setting
`inputs.proton-drive-linux.inputs.nixpkgs.follows = "nixpkgs"` builds against yours instead, which
the cache does not have, so Nix compiles it. `nix run` and `nix shell` ask whether to use the
cache the first time; answer yes, or run `cachix use proton-drive-linux`.
To try it without changing your system, run the app straight from the flake: To try it without changing your system, run the app straight from the flake:
```bash ```bash
@@ -282,8 +302,15 @@ pdfs logout # removes the session from the
``` ```
Then remove the package (`sudo apt remove proton-drive-linux`, `sudo dnf remove Then remove the package (`sudo apt remove proton-drive-linux`, `sudo dnf remove
proton-drive-linux`, or `sudo pacman -R proton-drive-for-linux`), and, if you want no trace left, proton-drive-linux`, or `sudo pacman -R proton-drive-for-linux`). Removing the package also stops
the local data: the service of every logged-in user, in case a step above was skipped.
> [!CAUTION]
> Do not delete `~/ProtonDrive` while it is mounted. It is your Proton Drive, not a copy: every
> file deleted there is moved to the Proton Drive trash. Once the service is stopped the folder is
> an empty mount point, and removing it is safe.
If you want no trace left, remove the local data as well:
```bash ```bash
rm -rf ~/.config/proton-drive-linux ~/.local/state/proton-drive-linux ~/.cache/proton-drive-linux rm -rf ~/.config/proton-drive-linux ~/.local/state/proton-drive-linux ~/.cache/proton-drive-linux
+1
View File
@@ -10,6 +10,7 @@
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service | | [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug | | [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
| [Recovering on a new computer](RECOVERY.md) | What survives a lost machine and how to restore your synced folders | | [Recovering on a new computer](RECOVERY.md) | What survives a lost machine and how to restore your synced folders |
| [Security model](SECURITY_MODEL.md) | What protects your files, what is kept on this computer, what the client connects to |
| [Changelog](CHANGELOG.md) | What changed in each release | | [Changelog](CHANGELOG.md) | What changed in each release |
## For contributors ## For contributors
+83
View File
@@ -0,0 +1,83 @@
# Security model
This page explains what protects your files and account when you use this client, what it keeps
on your computer, and what it does not protect against. It is a summary for users. The details,
with references to the code, are in the
[threat model](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext). To
report a vulnerability, see [SECURITY.md](../SECURITY.md).
This is an unofficial hobby project, not a Proton product. It has had no independent security
audit. The source is open under the MIT license, so you can read and build all of it yourself.
## Encryption
Proton Drive is end-to-end encrypted, and this client does not change that:
- **Files are encrypted on this computer before they are uploaded,** and decrypted on this
computer after they are downloaded. Proton never receives your keys or the content of your
files.
- **The cryptography comes from the SDK, not from this client.** The client uses
[`proton-sdk` and `proton-drive-rs`](https://github.com/narrrl/proton-sdk-rs), a Rust port of
Proton's official C# and TypeScript Drive SDKs. OpenPGP is done by the
[rPGP](https://github.com/rpgp/rpgp) library. The client itself does not implement any
encryption.
- **Your password is not sent to Proton.** Sign-in uses Proton's SRP protocol, which proves that
you know the password without sending it.
## What is kept on this computer
| What | Where | Protected by |
|---|---|---|
| Session tokens and the key passphrases | The system keyring (Secret Service: GNOME Keyring, KWallet, KeePassXC) | The keyring's own encryption and your login |
| Your password | Nowhere | — |
| Cached files, thumbnails and changes waiting to upload | `~/.cache/proton-drive-linux/content/` | File modes only: **not encrypted** |
| File and folder names, the folder tree, the activity log | `~/.local/state/proton-drive-linux/cache.db` | File modes only: **not encrypted** |
| The SDK's metadata cache | `~/.local/state/proton-drive-linux/sdk_cache.db` | Encrypted with a key derived from your mailbox password |
| Settings | `~/.config/proton-drive-linux/config.json` | No secrets are stored here |
The cache holds decrypted content so that files open fast and work offline. **The client assumes
that your disk is encrypted** (LUKS, or an encrypted home directory). On an unencrypted disk,
someone who has the powered-off computer can read the cached files and the names in your Drive.
Accounts with a separate mailbox password (two-password mode) are locked by default. Their key
passphrases are kept in the kernel keyring for your login session only, and you unlock again
after each login. Storing them in the system keyring is an opt-in.
## Who can use the client
- The state, cache and config directories must belong to you and have mode `0700`. The daemon
checks this at every start and refuses to run otherwise.
- The control socket that the app, tray and `pdfs` use has mode `0600`. Only your user account
can connect to it.
- Any program that runs as your user can read, change and delete files in `~/ProtonDrive`, the same
as in any other folder you own. It is your Proton Drive, not a copy: a deleted file is moved to
the Proton Drive trash, where you can restore it.
## Network connections
The client connects only to:
- **Proton**, for the Drive and account API, and for the CAPTCHA page when Proton asks for one.
- **OpenFreeMap**, for the street map of Places in Photos, only if you turn it on in Preferences. It is off by default, because the tile server learns which parts of the
world you look at. The default map is drawn from data shipped with the app.
There is no telemetry, no analytics and no update check. The SDK can report timings of its own
operations, and the client only writes them to its local log.
## What this client does not protect against
- **Malware running as your user.** It can read the cache, connect to the control socket, and use
your Drive through the mount, as it can with any data you have access to.
- **Swap.** Keys and decrypted data are in ordinary memory and can be written to swap. Use
encrypted swap.
- **The kernel page cache.** File content read through the mount is cached by the kernel like any
other file.
## Packages
- The `.rpm` is signed with GPG.
- The `.deb` and the `.tar.gz` are built by GitHub Actions from the tagged source, and are not
signed.
- The AUR packages build from the tagged source or from `main`, or install the release binaries
checked against their SHA-256 sums.
- The Nix flake builds from source. The binary cache on Cachix holds builds made by CI.
+8 -5
View File
@@ -44,7 +44,7 @@ The app uses these words consistently, and so does this guide:
| **My files** | The root of your Drive | | **My files** | The root of your Drive |
| **Proton Drive folder** | Where My files appears on disk, `~/ProtonDrive` by default | | **Proton Drive folder** | Where My files appears on disk, `~/ProtonDrive` by default |
| **Online only** | A file or folder whose content downloads when opened | | **Online only** | A file or folder whose content downloads when opened |
| **Available offline** | A single file kept on this computer | | **Available offline** | A file, or a folder with everything in it, kept on this computer |
| **Synced folder** | A local folder with a full copy on this computer, backed up to Proton Drive | | **Synced folder** | A local folder with a full copy on this computer, backed up to Proton Drive |
| **Proton Drive service** | The background service (`proton-drive.service`) | | **Proton Drive service** | The background service (`proton-drive.service`) |
@@ -75,11 +75,14 @@ try to create them get a clean error, not a hang or `EIO`.
## Keeping files on this computer ## Keeping files on this computer
Turn on **Make available offline** for a file in My files, or run `pdfs pin <path>`. The file is Turn on **Available offline** for a file or folder in My files, or run `pdfs pin <path>`. A file
downloaded completely and never evicted from the cache. `pdfs pins` lists these files, and is downloaded completely and never evicted from the cache. For a folder, every file in it and in
**Preferences → Storage → Available offline** shows them in the app. its subfolders downloads in the background, and the tray and the app show the progress.
`pdfs pins` lists what is kept, and **Preferences → Storage → Available offline** shows it in the
app.
Turning it off (`pdfs unpin <path>`) frees the space. The file stays on Proton Drive. Turning it off (`pdfs unpin <path>`) frees the space. The files stay on Proton Drive. A file inside
a folder kept offline stays with its folder: turn the folder off instead.
## Syncing local folders ## Syncing local folders
+10
View File
@@ -1,6 +1,16 @@
{ {
description = "Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app and tray"; description = "Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app and tray";
# CI pushes every build of `main` and of each release tag here, so installing
# downloads the client instead of compiling it. Nix asks before it uses a
# flake's caches unless the user trusts them (docs/INSTALL.md).
nixConfig = {
extra-substituters = [ "https://proton-drive-linux.cachix.org" ];
extra-trusted-public-keys = [
"proton-drive-linux.cachix.org-1:w1+3pXVXyTlqkFGn3dWpUhlaJtyGhSm3X0mFwfa++AA="
];
};
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = outputs =
+3 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=3.3.0 pkgver=3.4.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')
@@ -26,6 +26,8 @@ optdepends=('gnome-keyring: credential storage via Secret Service'
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)' 'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
'foot: terminal for pdfs-prompt --fzf started from a keybinding') 'foot: terminal for pdfs-prompt --fzf started from a keybinding')
options=('!lto' '!debug') options=('!lto' '!debug')
# Stops the running daemon before removal, so the mount stops mirroring deletes.
install=proton-drive.install
# Built in place from the checkout; nothing to fetch or checksum. # Built in place from the checkout; nothing to fetch or checksum.
source=() source=()
+2 -1
View File
@@ -13,7 +13,8 @@ The three packages conflict with each other and with the in-tree
`packaging/PKGBUILD` package (`proton-drive-linux`), and every one of them `packaging/PKGBUILD` package (`proton-drive-linux`), and every one of them
`provides` `proton-drive-for-linux`, `proton-drive-linux`, and `pdfs`. `provides` `proton-drive-for-linux`, `proton-drive-linux`, and `pdfs`.
Each directory tracks only `PKGBUILD`, `.SRCINFO`, `.gitignore`, and `LICENSE` Each directory tracks only `PKGBUILD`, `.SRCINFO`, `.gitignore`, `LICENSE` and
`proton-drive.install` (a link to `packaging/proton-drive.install`, copied as a file on push)
(the 0BSD license of the packaging sources, per (the 0BSD license of the packaging sources, per
[RFC40](https://rfc.archlinux.page/0040-license-package-sources/)); the [RFC40](https://rfc.archlinux.page/0040-license-package-sources/)); the
`.gitignore` ignores everything else so downloaded sources, `src/`, `pkg/`, and `.gitignore` ignores everything else so downloaded sources, `src/`, `pkg/`, and
@@ -1,8 +1,9 @@
pkgbase = proton-drive-for-linux-bin pkgbase = proton-drive-for-linux-bin
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries) pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
pkgver = 3.2.1 pkgver = 3.3.0
pkgrel = 1 pkgrel = 1
url = https://proton-drive.narl.io url = https://proton-drive.narl.io
install = proton-drive.install
arch = x86_64 arch = x86_64
license = MIT license = MIT
depends = fuse3 depends = fuse3
@@ -21,21 +22,21 @@ pkgbase = proton-drive-for-linux-bin
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.2.1 provides = proton-drive-for-linux=3.3.0
provides = proton-drive-linux=3.2.1 provides = proton-drive-linux=3.3.0
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !strip options = !strip
options = !debug options = !debug
source = proton-drive-linux-3.2.1-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.2.1/proton-drive-linux-3.2.1-x86_64.tar.gz source = proton-drive-linux-3.3.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.3.0/proton-drive-linux-3.3.0-x86_64.tar.gz
source = io.narl.proton-drive-linux-3.2.1.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.1/packaging/io.narl.proton-drive-linux.desktop source = io.narl.proton-drive-linux-3.3.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.3.0/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray-3.2.1.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.1/packaging/io.narl.proton-drive-linux-tray.desktop source = io.narl.proton-drive-linux-tray-3.3.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.3.0/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux-3.2.1.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.1/packaging/io.narl.proton-drive-linux.svg source = io.narl.proton-drive-linux-3.3.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.3.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive-3.2.1.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.1/packaging/proton-drive.service source = proton-drive-3.3.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.3.0/packaging/proton-drive.service
source = LICENSE-3.2.1::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.1/LICENSE source = LICENSE-3.3.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.3.0/LICENSE
sha256sums = b031f15296a1947d4083f944ad0cd12a18e676cdb9746055d7673b3b3832c850 sha256sums = 2e4a8ee660f1d1d683a03afd7d3c1262be87cebeb53893cae55f5e1b44199a04
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123 sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0 sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828 sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
@@ -5,3 +5,4 @@
!.SRCINFO !.SRCINFO
!LICENSE !LICENSE
!PKGBUILD !PKGBUILD
!proton-drive.install
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-bin pkgname=proton-drive-for-linux-bin
pkgver=3.2.1 pkgver=3.3.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
arch=('x86_64') arch=('x86_64')
@@ -22,6 +22,8 @@ provides=("${pkgname%-bin}=$pkgver" "proton-drive-linux=$pkgver" 'pdfs')
conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-git') conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-git')
# Upstream ships the binaries already built on Ubuntu 24.04; do not rewrite them. # Upstream ships the binaries already built on Ubuntu 24.04; do not rewrite them.
options=('!strip' '!debug') options=('!strip' '!debug')
# Stops the running daemon before removal, so the mount stops mirroring deletes.
install=proton-drive.install
_tag="v$pkgver" _tag="v$pkgver"
_raw="https://raw.githubusercontent.com/narrrl/proton-drive-linux/$_tag" _raw="https://raw.githubusercontent.com/narrrl/proton-drive-linux/$_tag"
# The release tarball holds the four binaries only, so the desktop integration # The release tarball holds the four binaries only, so the desktop integration
@@ -34,7 +36,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg" "io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service" "proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
"LICENSE-$pkgver::$_raw/LICENSE") "LICENSE-$pkgver::$_raw/LICENSE")
sha256sums=('b031f15296a1947d4083f944ad0cd12a18e676cdb9746055d7673b3b3832c850' sha256sums=('2e4a8ee660f1d1d683a03afd7d3c1262be87cebeb53893cae55f5e1b44199a04'
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123' 'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0' '1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828' 'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
@@ -0,0 +1 @@
../../proton-drive.install
@@ -1,8 +1,9 @@
pkgbase = proton-drive-for-linux-git pkgbase = proton-drive-for-linux-git
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git) pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
pkgver = 3.2.1.r0.g170c3f0 pkgver = 3.3.0.r0.g160626b
pkgrel = 1 pkgrel = 1
url = https://proton-drive.narl.io url = https://proton-drive.narl.io
install = proton-drive.install
arch = x86_64 arch = x86_64
license = MIT license = MIT
makedepends = cargo makedepends = cargo
@@ -24,8 +25,8 @@ pkgbase = proton-drive-for-linux-git
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.2.1.r0.g170c3f0 provides = proton-drive-for-linux=3.3.0.r0.g160626b
provides = proton-drive-linux=3.2.1.r0.g170c3f0 provides = proton-drive-linux=3.3.0.r0.g160626b
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
@@ -5,3 +5,4 @@
!.SRCINFO !.SRCINFO
!LICENSE !LICENSE
!PKGBUILD !PKGBUILD
!proton-drive.install
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-git pkgname=proton-drive-for-linux-git
pkgver=3.2.1.r0.g170c3f0 pkgver=3.3.0.r0.g160626b
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
arch=('x86_64') arch=('x86_64')
@@ -23,6 +23,8 @@ provides=("${pkgname%-git}=$pkgver" "proton-drive-linux=$pkgver" 'pdfs')
conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-bin') conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-bin')
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off. # LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
options=('!lto') options=('!lto')
# Stops the running daemon before removal, so the mount stops mirroring deletes.
install=proton-drive.install
source=("$pkgname::git+$_repo.git") source=("$pkgname::git+$_repo.git")
sha256sums=('SKIP') sha256sums=('SKIP')
@@ -0,0 +1 @@
../../proton-drive.install
@@ -1,8 +1,9 @@
pkgbase = proton-drive-for-linux pkgbase = proton-drive-for-linux
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
pkgver = 3.2.1 pkgver = 3.3.0
pkgrel = 1 pkgrel = 1
url = https://proton-drive.narl.io url = https://proton-drive.narl.io
install = proton-drive.install
arch = x86_64 arch = x86_64
license = MIT license = MIT
makedepends = cargo makedepends = cargo
@@ -23,13 +24,13 @@ pkgbase = proton-drive-for-linux
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-linux=3.2.1 provides = proton-drive-linux=3.3.0
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux-bin conflicts = proton-drive-for-linux-bin
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !lto options = !lto
source = proton-drive-for-linux-3.2.1.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.2.1.tar.gz source = proton-drive-for-linux-3.3.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.3.0.tar.gz
sha256sums = 275fefa2feacbd8319b3986205433729d6f5241b5bedfa4651b47bea9700b525 sha256sums = ef58e96e64697917168f111958aaadf01c8eaac9fb482d0f6e70dfdc8ed4f167
pkgname = proton-drive-for-linux pkgname = proton-drive-for-linux
@@ -5,3 +5,4 @@
!.SRCINFO !.SRCINFO
!LICENSE !LICENSE
!PKGBUILD !PKGBUILD
!proton-drive.install
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux pkgname=proton-drive-for-linux
pkgver=3.2.1 pkgver=3.3.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray" pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
arch=('x86_64') arch=('x86_64')
@@ -23,8 +23,10 @@ provides=("proton-drive-linux=$pkgver" 'pdfs')
conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-linux-git') conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-linux-git')
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off. # LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
options=('!lto') options=('!lto')
# Stops the running daemon before removal, so the mount stops mirroring deletes.
install=proton-drive.install
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('275fefa2feacbd8319b3986205433729d6f5241b5bedfa4651b47bea9700b525') sha256sums=('ef58e96e64697917168f111958aaadf01c8eaac9fb482d0f6e70dfdc8ed4f167')
_srcname="proton-drive-linux-$pkgver" _srcname="proton-drive-linux-$pkgver"
@@ -0,0 +1 @@
../../proton-drive.install
+4 -2
View File
@@ -21,9 +21,11 @@ for pkg in "${packages[@]}"; do
# A fresh clone every time: the AUR repository, not this checkout, is what the # A fresh clone every time: the AUR repository, not this checkout, is what the
# commit history has to build on. # commit history has to build on.
git -c init.defaultBranch=master clone -q "ssh://aur@aur.archlinux.org/$pkg.git" "$workdir/$pkg" git -c init.defaultBranch=master clone -q "ssh://aur@aur.archlinux.org/$pkg.git" "$workdir/$pkg"
install -m644 "$pkg/PKGBUILD" "$pkg/.SRCINFO" "$pkg/.gitignore" "$pkg/LICENSE" "$workdir/$pkg/" # proton-drive.install is a link into packaging/; install copies its content.
install -m644 "$pkg/PKGBUILD" "$pkg/.SRCINFO" "$pkg/.gitignore" "$pkg/LICENSE" \
"$pkg/proton-drive.install" "$workdir/$pkg/"
git -C "$workdir/$pkg" add -f PKGBUILD .SRCINFO .gitignore LICENSE git -C "$workdir/$pkg" add -f PKGBUILD .SRCINFO .gitignore LICENSE proton-drive.install
if git -C "$workdir/$pkg" diff --cached --quiet; then if git -C "$workdir/$pkg" diff --cached --quiet; then
echo "$pkg: no changes" echo "$pkg: no changes"
continue continue
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# Removing the package does not stop a running daemon: it keeps serving the
# mount from the deleted binary, so files deleted under the mount afterwards,
# `rm -rf ~/ProtonDrive` included, are still deleted on Proton Drive. Stop every
# logged-in user's service first; anything still queued stays on disk for the
# next install. Same loop as packaging/proton-drive.install.
set -e
case "$1" in
remove) ;;
*) exit 0 ;;
esac
for dir in /run/user/*; do
user=$(id -nu "${dir##*/}" 2>/dev/null) || continue
if runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
systemctl --user --quiet is-active proton-drive.service 2>/dev/null; then
echo "Stopping Proton Drive for $user, which unmounts its drive."
runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
systemctl --user stop proton-drive.service 2>/dev/null || :
fi
done
+25 -4
View File
@@ -37,11 +37,13 @@ Requires: webkitgtk6.0
Requires: perl-Image-ExifTool Requires: perl-Image-ExifTool
Requires: xdg-utils Requires: xdg-utils
# DE-specific; do not Require a single desktop environment. # DE-specific; do not Require a single desktop environment. dnf installs weak
Recommends: gnome-keyring # dependencies by default, so each is conditional on its own desktop: a plain
# `Recommends: kwallet` pulled KDE Frameworks onto GNOME installs.
Recommends: (gnome-keyring or plasma-workspace)
Recommends: iso-codes Recommends: iso-codes
Recommends: gnome-shell-extension-appindicator Recommends: (gnome-shell-extension-appindicator if gnome-shell)
Recommends: kwallet Recommends: (kwallet if plasma-workspace)
# Only for `pdfs-prompt --fzf`; every other front end works without them. # Only for `pdfs-prompt --fzf`; every other front end works without them.
Suggests: fzf Suggests: fzf
@@ -90,6 +92,25 @@ install -D -m0644 %{git_dir}/packaging/proton-drive.service \
%{buildroot}/usr/lib/systemd/user/proton-drive.service %{buildroot}/usr/lib/systemd/user/proton-drive.service
%{git_dir}/po/build.sh %{buildroot}%{_datadir}/locale %{git_dir}/po/build.sh %{buildroot}%{_datadir}/locale
# Removing the package does not stop a running daemon: it keeps serving the
# mount from the deleted binary, so files deleted under the mount afterwards,
# `rm -rf ~/ProtonDrive` included, are still deleted on Proton Drive. Stop every
# logged-in user's service first; anything still queued stays on disk for the
# next install. $1 is 0 on an erase and 1 on an upgrade. Same loop as
# packaging/proton-drive.install.
%preun
if [ "$1" -eq 0 ]; then
for dir in /run/user/*; do
user=$(id -nu "${dir##*/}" 2>/dev/null) || continue
if runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
systemctl --user --quiet is-active proton-drive.service 2>/dev/null; then
echo "Stopping Proton Drive for $user, which unmounts its drive."
runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
systemctl --user stop proton-drive.service 2>/dev/null || :
fi
done
fi
%files %files
%license LICENSE %license LICENSE
%{_bindir}/pdfs %{_bindir}/pdfs
+19
View File
@@ -0,0 +1,19 @@
# pacman install script, shared by packaging/PKGBUILD and the three AUR packages
# (each links it). The same loop is in packaging/deb/prerm and the RPM %preun.
# Removing the package does not stop a running daemon: it keeps serving the
# mount from the deleted binary, so files deleted under the mount afterwards,
# `rm -rf ~/ProtonDrive` included, are still deleted on Proton Drive. Stop every
# logged-in user's service first; anything still queued stays on disk for the
# next install. Upgrades call pre_upgrade, not this.
pre_remove() {
for dir in /run/user/*; do
user=$(id -nu "${dir##*/}" 2>/dev/null) || continue
if runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
systemctl --user --quiet is-active proton-drive.service 2>/dev/null; then
echo "Stopping Proton Drive for $user, which unmounts its drive."
runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
systemctl --user stop proton-drive.service 2>/dev/null || :
fi
done
}
+344 -308
View File
File diff suppressed because it is too large. Load diff
+341 -308
View File
File diff suppressed because it is too large. Load diff
+344 -309
View File
File diff suppressed because it is too large. Load diff
+346 -310
View File
File diff suppressed because it is too large. Load diff
+344 -308
View File
File diff suppressed because it is too large. Load diff
+343 -308
View File
File diff suppressed because it is too large. Load diff
+339 -308
View File
File diff suppressed because it is too large. Load diff
+345 -308
View File
File diff suppressed because it is too large. Load diff
+343 -308
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -4388,6 +4388,21 @@ def test_move_refuses_a_mirror_copy_drive_lacks(mc: MoveContext) -> None:
# Sync skips symlinks, so Drive never gets this one: removing the local # Sync skips symlinks, so Drive never gets this one: removing the local
# copy after a move would lose it. # copy after a move would lose it.
os.symlink("data.txt", source.root / name / "link") os.symlink("data.txt", source.root / name / "link")
try:
_move_keeps_what_drive_lacks(mc, source, dest, name, files)
finally:
# The link stays local-only wherever it ended up, and a mirror folder
# holding one refuses the switch to on-demand that the next mode
# pairing makes.
for location in (source, dest):
link = location.root / name / "link"
if os.path.lexists(link):
os.unlink(link)
def _move_keeps_what_drive_lacks(
mc: MoveContext, source: Location, dest: Location, name: str, files: dict[Path, bytes]
) -> None:
source.settle() source.settle()
if dest.mode == "mirror" and os.stat(source.folder).st_dev == os.stat(dest.folder).st_dev: if dest.mode == "mirror" and os.stat(source.folder).st_dev == os.stat(dest.folder).st_dev:
# Between two mirrors on one filesystem the local copy is renamed # Between two mirrors on one filesystem the local copy is renamed