mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46abfc07c | ||
|
|
fd78f088ec | ||
|
|
005f7ded1f | ||
|
|
3e4886f965 | ||
|
|
aac53e3139 | ||
|
|
c2bf87596f | ||
|
|
69d4feaa07 | ||
|
|
d481dcc15c | ||
|
|
e0442c56b9 | ||
|
|
ee76f4ac60 | ||
|
|
f6c651b06b | ||
|
|
8b7d7ae87f |
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>"]
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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=()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Executable
+22
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+341
-308
File diff suppressed because it is too large.
Load diff
+339
-308
File diff suppressed because it is too large.
Load diff
+343
-308
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user