Merge pull request #47 from narrrl/feat/server-copy

Release 3.6.0: server copy, link settings, folder size, sync fixes (#46)
This commit is contained in:
nvrl authored and GitHub committed 2026-10-26 22:47:47 +01:00
commit 2e3b7748e6
45 files changed
+6904 -3517

No files matched your search

Generated
+8 -8
View File
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pdfs-cli"
version = "3.5.0"
version = "3.6.0"
dependencies = [
"anyhow",
"clap",
@@ -3490,7 +3490,7 @@ dependencies = [
[[package]]
name = "pdfs-core"
version = "3.5.0"
version = "3.6.0"
dependencies = [
"async-trait",
"base64 0.23.1",
@@ -3517,7 +3517,7 @@ dependencies = [
[[package]]
name = "pdfs-fuse"
version = "3.5.0"
version = "3.6.0"
dependencies = [
"async-trait",
"base64 0.23.1",
@@ -3539,7 +3539,7 @@ dependencies = [
[[package]]
name = "pdfs-gui"
version = "3.5.0"
version = "3.6.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3779,9 +3779,9 @@ dependencies = [
[[package]]
name = "proton-drive-rs"
version = "0.9.2"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "266b40b9a6d2b4176a48c230d68c6fab7203cad59abf8a44aea0153e1250185a"
checksum = "8f70318394b2815b009c52f804d75747086db93a338ceb6eae62541f2b25d5bf"
dependencies = [
"async-trait",
"base64 0.23.1",
@@ -3806,9 +3806,9 @@ dependencies = [
[[package]]
name = "proton-sdk"
version = "0.9.2"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e2b607b0a22dfc34d971794d619084d39fd06c49b58b23229855330c4a7cc1"
checksum = "4196392032f56b3bbaac0bb1f24eea62fa7e2a0af72004de8d08e518d14720fb"
dependencies = [
"aes-gcm 0.11.1",
"async-trait",
+3 -3
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "3.5.0"
version = "3.6.0"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies]
proton-sdk = { version = "0.9.2" }
proton-drive-rs = { version = "0.9.2" }
proton-sdk = { version = "0.10.0" }
proton-drive-rs = { version = "0.10.0" }
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
+154 -3
View File
@@ -13,7 +13,7 @@ use pdfs_core::auth;
use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
ConflictKeep, ErrorKind, LinkChange, MountKind, RefreshScope, Request as CtlRequest,
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
daemon_outdated, pending_summary,
};
@@ -356,6 +356,26 @@ enum Command {
#[arg(required = true, num_args = 2.., value_name = "PATH")]
paths: Vec<PathBuf>,
},
/// Copy files or folders into another folder in My files via the running
/// daemon.
///
/// The copy happens on Proton Drive, so nothing is downloaded or uploaded,
/// unlike `cp` inside the mount. A name already taken in the destination
/// gets a " (1)" suffix. The last path is the destination folder.
Copy {
/// Files/folders to copy, then the destination folder: inside My files,
/// or relative to the mountpoint.
#[arg(required = true, num_args = 2.., value_name = "PATH")]
paths: Vec<PathBuf>,
},
/// Show how much a folder holds on Proton Drive.
///
/// Proton Drive adds it up, so nothing is downloaded. The size is the
/// encrypted bytes on storage and includes items in the trash.
Du {
/// Folder path, inside My files or relative to the mountpoint.
path: PathBuf,
},
/// Trash a file or folder via the running daemon.
Rm {
/// File/folder path, inside My files or an on-demand synced folder, or
@@ -689,6 +709,13 @@ enum SyncCmd {
/// Optional synced-folder id; omit to reconcile every folder.
id: Option<i64>,
},
/// Forget what a synced folder synced before and compare it with Proton
/// Drive afresh: what one side has alone is copied to the other, and a file
/// that differs keeps both versions. Nothing is deleted.
Reset {
/// Synced-folder id (from `sync list`).
id: i64,
},
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
Pause {
/// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`.
@@ -777,6 +804,29 @@ enum PublicLinkCmd {
#[arg(long)]
expires: Option<i64>,
},
/// Change a node's public link. The URL stays the same; an option not
/// given is kept.
Update {
/// File/folder path, inside the mountpoint or relative to it.
path: PathBuf,
/// Public-link id, from `pdfs members`.
id: String,
/// New role: viewer or editor.
#[arg(long)]
role: Option<String>,
/// New custom password protecting the link.
#[arg(long, conflicts_with = "no_password")]
password: Option<String>,
/// Remove the custom password.
#[arg(long)]
no_password: bool,
/// New expiry, Unix epoch seconds.
#[arg(long, conflicts_with = "no_expiry")]
expires: Option<i64>,
/// Remove the expiry.
#[arg(long)]
no_expiry: bool,
},
/// Remove a node's public link.
Remove {
/// File/folder path, inside the mountpoint or relative to it.
@@ -919,6 +969,11 @@ fn main() -> Result<()> {
let new_parent = paths.pop().expect("clap requires at least two paths");
cmd_move(paths, new_parent)
}
Command::Copy { mut paths } => {
let new_parent = paths.pop().expect("clap requires at least two paths");
cmd_copy(paths, new_parent)
}
Command::Du { path } => cmd_du(path),
Command::Rm { path } => cmd_rm(path),
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
Command::Upload { sources, parent } => cmd_upload(sources, parent),
@@ -1088,7 +1143,14 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
// A pass in flight says what it is doing, indented under it.
if let Some(p) = &f.progress {
match p.phase {
SyncPhase::Scanning if p.total == 0 => println!(" scanning…"),
SyncPhase::Scanning if p.total == 0 && p.done == 0 => {
println!(" scanning…")
}
// A first pass has no estimate yet; the count alone
// shows a long first scan is moving.
SyncPhase::Scanning if p.total == 0 => {
println!(" scanning: {} checked", p.done)
}
SyncPhase::Scanning => {
println!(
" scanning: {} of {}",
@@ -1117,6 +1179,7 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
})?)?
}
SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?,
SyncCmd::Reset { id } => ok_or_bail(control_request(CtlRequest::ResetSyncFolder { id })?)?,
SyncCmd::Pause {
folder: Some(id), ..
} => ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
@@ -1512,7 +1575,11 @@ fn cmd_members(path: PathBuf) -> Result<()> {
Some(l) => {
let url = l.url.as_deref().unwrap_or("(url hidden)");
let pw = if l.has_password { " +password" } else { "" };
println!("public link ({}{pw}): {url} [{}]", l.role, l.id);
let expiry = l
.expires
.map(|at| format!(", expires {}", format_epoch_utc(at)))
.unwrap_or_default();
println!("public link ({}{pw}{expiry}): {url} [{}]", l.role, l.id);
}
None => println!("public link: none"),
}
@@ -1564,6 +1631,27 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
PublicLinkCmd::Update {
path,
id,
role,
password,
no_password,
expires,
no_expiry,
} => match control_request(CtlRequest::UpdatePublicLink {
path: path_arg(&path)?,
id,
role,
password: link_change(password, no_password),
expires: link_change(expires, no_expiry),
})? {
CtlResponse::PublicLink { link } => {
println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
PublicLinkCmd::Remove { path, id } => {
ok_or_bail(control_request(CtlRequest::RemovePublicLink {
path: path_arg(&path)?,
@@ -1574,6 +1662,15 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
Ok(())
}
/// A `--x VALUE` / `--no-x` flag pair as one link option change.
fn link_change<T>(value: Option<T>, remove: bool) -> LinkChange<T> {
match (value, remove) {
(Some(value), _) => LinkChange::Set(value),
(None, true) => LinkChange::Remove,
(None, false) => LinkChange::Keep,
}
}
fn cmd_shared() -> Result<()> {
match control_request(CtlRequest::ListSharedByMe)? {
CtlResponse::SharedByMe { items } if items.is_empty() => {
@@ -1765,6 +1862,7 @@ fn activity_verb(kind: pdfs_core::control::ActivityKind) -> &'static str {
Sync => "sync",
Rename => "rename",
Move => "move",
Copy => "copy",
CreateFolder => "mkdir",
Trash => "trash",
Restore => "restore",
@@ -2920,6 +3018,52 @@ fn cmd_move(sources: Vec<PathBuf>, new_parent: PathBuf) -> Result<()> {
Ok(())
}
fn cmd_copy(sources: Vec<PathBuf>, new_parent: PathBuf) -> Result<()> {
let new_parent = path_arg(&new_parent)?;
let mut failed = 0;
for source in &sources {
let outcome = path_arg(source).and_then(|path| {
match control_request(CtlRequest::Copy {
path,
new_parent: new_parent.clone(),
})? {
CtlResponse::Ok { message } => Ok(message),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
});
match outcome {
Ok(message) => println!("{message}"),
Err(e) if sources.len() == 1 => return Err(e),
Err(e) => {
eprintln!("{}: {e:#}", source.display());
failed += 1;
}
}
}
if failed > 0 {
bail!("{failed} of {} could not be copied", sources.len());
}
Ok(())
}
fn cmd_du(path: PathBuf) -> Result<()> {
match control_request(CtlRequest::FolderSize {
path: path_arg(&path)?,
})? {
CtlResponse::FolderSize { size, items } => {
let noun = if items == 1 { "item" } else { "items" };
println!(
"{} in {items} {noun} (on Proton Drive, trash included)",
human_bytes(size)
);
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_rm(path: PathBuf) -> Result<()> {
match control_request(CtlRequest::Delete {
path: path_arg(&path)?,
@@ -3956,6 +4100,13 @@ mod login_wait_tests {
assert_eq!(slept, 0);
}
#[test]
fn a_link_flag_pair_keeps_removes_or_sets() {
assert_eq!(link_change::<i64>(None, false), LinkChange::Keep);
assert_eq!(link_change::<i64>(None, true), LinkChange::Remove);
assert_eq!(link_change(Some(5), false), LinkChange::Set(5));
}
#[test]
fn epoch_seconds_format_as_utc_calendar_dates() {
assert_eq!(format_epoch_utc(0), "1970-01-01");
+107 -2
View File
@@ -344,6 +344,17 @@ pub enum Request {
/// or a mirror synced folder), and the move is still made on Proton Drive
/// without transferring content. Replies with [`Response::Ok`].
Move { path: String, new_parent: String },
/// Copy a file or folder into a folder on Proton Drive, without
/// downloading or uploading it. Both `path` and `new_parent` are
/// mountpoint-relative, or absolute paths inside My files. A name already
/// taken in `new_parent` gets the next free ` (n)` suffix. Replies with
/// [`Response::Ok`].
Copy { path: String, new_parent: String },
/// Ask Proton Drive how much the folder at mountpoint-relative `path`
/// holds. Replies with [`Response::FolderSize`].
FolderSize { path: String },
/// The by-uid twin of [`Request::FolderSize`].
FolderSizeByUid { uid: String },
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
/// [`Response::Ok`].
Delete { path: String },
@@ -469,6 +480,11 @@ pub enum Request {
/// Pause or resume one mirror folder's reconcile, independent of the
/// global pause. Persisted until resumed. Replies with [`Response::Ok`].
SetSyncFolderPaused { id: i64, paused: bool },
/// Forget what a mirror folder synced before, so its next pass compares
/// both sides afresh: what is on one side only is copied to the other, and
/// a file that differs keeps both versions. Nothing is deleted. Replies
/// with [`Response::Ok`].
ResetSyncFolder { id: i64 },
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
/// Replies with [`Response::Ok`].
SyncNow { id: Option<i64> },
@@ -596,6 +612,24 @@ pub enum Request {
RemovePublicLink { path: String, id: String },
/// The by-uid twin of [`Request::RemovePublicLink`].
RemovePublicLinkByUid { uid: String, id: String },
/// Change the public link `id` on the node at `path`, keeping its URL.
/// `role` is "viewer" or "editor", or `None` to keep the current one.
/// Replies with [`Response::PublicLink`].
UpdatePublicLink {
path: String,
id: String,
role: Option<String>,
password: LinkChange<String>,
expires: LinkChange<i64>,
},
/// The by-uid twin of [`Request::UpdatePublicLink`].
UpdatePublicLinkByUid {
uid: String,
id: String,
role: Option<String>,
password: LinkChange<String>,
expires: LinkChange<i64>,
},
/// Stop sharing the node at `path`: remove its public link, every pending
/// invitation and every member. Each removal is attempted even if an
/// earlier one fails. Replies with [`Response::Ok`], or an error naming
@@ -1075,6 +1109,18 @@ pub struct PublicLinkInfo {
pub created: i64,
}
/// What [`Request::UpdatePublicLink`] does with one option of a link: its
/// custom password or its expiry.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum LinkChange<T> {
/// Leave the option as it is.
Keep,
/// Remove the option: no custom password, or no expiry.
Remove,
/// Set the option to this value.
Set(T),
}
/// One invitation addressed to me (in [`Response::Invitations`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct InvitationInfo {
@@ -1169,6 +1215,7 @@ pub enum ActivityKind {
Sync,
Rename,
Move,
Copy,
CreateFolder,
Trash,
Restore,
@@ -2185,9 +2232,14 @@ pub enum Response {
entries: Vec<ShareEntry>,
link: Option<PublicLinkInfo>,
},
/// A freshly created public link, carrying the shareable URL (reply to
/// [`Request::CreatePublicLink`]).
/// A freshly created or changed public link, carrying the shareable URL
/// (reply to [`Request::CreatePublicLink`] and
/// [`Request::UpdatePublicLink`]).
PublicLink { link: PublicLinkInfo },
/// What a folder holds on Proton Drive (reply to [`Request::FolderSize`]):
/// the bytes on storage and the number of items below it, folders and
/// trashed items included.
FolderSize { size: u64, items: u64 },
/// Invitations addressed to me (reply to [`Request::ListInvitations`]).
Invitations { items: Vec<InvitationInfo> },
/// Saved public links (reply to [`Request::ListBookmarks`]).
@@ -2687,6 +2739,10 @@ mod tests {
path: "a/b.txt".into(),
new_parent: "d".into(),
},
Request::Copy {
path: "a/b.txt".into(),
new_parent: "d".into(),
},
Request::Delete {
path: "a/b.txt".into(),
},
@@ -3158,6 +3214,7 @@ mod tests {
id: 3,
paused: true,
},
Request::ResetSyncFolder { id: 3 },
Request::SetBandwidthLimits {
upload: 1_000_000,
download: 0,
@@ -3247,6 +3304,24 @@ mod tests {
uid: "vol~link".into(),
id: "url-1".into(),
},
Request::UpdatePublicLink {
path: "a/b".into(),
id: "url-1".into(),
role: Some("editor".into()),
password: LinkChange::Set("pw".into()),
expires: LinkChange::Remove,
},
Request::UpdatePublicLinkByUid {
uid: "vol~link".into(),
id: "url-1".into(),
role: None,
password: LinkChange::Keep,
expires: LinkChange::Set(1_700_000_000),
},
Request::FolderSize { path: "a".into() },
Request::FolderSizeByUid {
uid: "vol~link".into(),
},
Request::ListSharedByMe,
Request::ListActivity { limit: 100 },
Request::ListSharedWithMe,
@@ -3621,6 +3696,16 @@ mod tests {
},
"RemovePublicLinkByUid",
),
(
Request::UpdatePublicLinkByUid {
uid: "vol~link".into(),
id: "link".into(),
role: None,
password: LinkChange::Keep,
expires: LinkChange::Keep,
},
"UpdatePublicLinkByUid",
),
(
Request::StopSharingByUid {
uid: "vol~link".into(),
@@ -3646,6 +3731,7 @@ mod tests {
| Request::RemoveShareEntryByUid { uid, .. }
| Request::CreatePublicLinkByUid { uid, .. }
| Request::RemovePublicLinkByUid { uid, .. }
| Request::UpdatePublicLinkByUid { uid, .. }
| Request::StopSharingByUid { uid } => uid,
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
};
@@ -3654,6 +3740,25 @@ mod tests {
}
}
#[test]
fn a_link_update_names_what_it_keeps_removes_and_sets() {
let request = Request::UpdatePublicLink {
path: "a/b".into(),
id: "url-1".into(),
role: None,
password: LinkChange::Keep,
expires: LinkChange::Set(1_700_000_000),
};
assert_eq!(
serde_json::to_string(&request).unwrap(),
r#"{"UpdatePublicLink":{"path":"a/b","id":"url-1","role":null,"password":"Keep","expires":{"Set":1700000000}}}"#
);
assert_eq!(
serde_json::to_string(&LinkChange::<String>::Remove).unwrap(),
r#""Remove""#
);
}
#[test]
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#;
+76
View File
@@ -29,6 +29,17 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
/// Answer a [`CtlRequest::FolderSize`].
fn folder_size_reply(size: CoreResult<proton_drive_rs::FolderSize>) -> CtlResponse {
match size {
Ok(size) => CtlResponse::FolderSize {
size: size.size,
items: size.descendants,
},
Err(e) => CtlResponse::error(e),
}
}
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
/// into an error that names what is still shared.
fn stop_sharing_reply(
@@ -377,6 +388,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
| R::RemoveSyncFolder { .. }
| R::SetSyncFolderMode { .. }
| R::SetSyncFolderPaused { .. }
| R::ResetSyncFolder { .. }
| R::RestoreSyncFolders { .. }
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
R::RetryPendingOp { .. } => &[Topic::Queue],
@@ -392,6 +404,8 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
| R::CreatePublicLinkByUid { .. }
| R::RemovePublicLink { .. }
| R::RemovePublicLinkByUid { .. }
| R::UpdatePublicLink { .. }
| R::UpdatePublicLinkByUid { .. }
| R::StopSharing { .. }
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
R::LeaveShared { .. }
@@ -1063,6 +1077,36 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
}
}
Ok(CtlRequest::Copy { path, new_parent }) => {
match (
rel_to_mount(mountpoint, &path),
rel_to_mount(mountpoint, &new_parent),
) {
(Ok(rel), Ok(parent_rel)) => match core.copy_to(&rel, &parent_rel) {
Ok(name) => {
let dest = if new_parent.is_empty() {
"My files".to_string()
} else {
new_parent.clone()
};
core.log_activity(ActivityKind::Copy, &name, format!("to {dest}"), true);
CtlResponse::Ok {
message: format!("copied to {name}"),
}
}
Err(e) => {
core.log_activity(ActivityKind::Copy, &path, &e, false);
CtlResponse::error(e)
}
},
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
}
}
Ok(CtlRequest::FolderSize { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => folder_size_reply(core.folder_size(&rel)),
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::FolderSizeByUid { uid }) => folder_size_reply(core.folder_size_by_uid(&uid)),
Ok(CtlRequest::Delete { path }) => match route_node(core, mountpoint, &path) {
Ok((core, rel)) => match core.delete(&rel) {
Ok(name) => {
@@ -1389,6 +1433,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::ResetSyncFolder { id }) => match core.reset_sync_folder(id) {
Ok(message) => {
core.log_activity(ActivityKind::Sync, &message, "", true);
CtlResponse::Ok { message }
}
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::SyncNow { id }) => {
core.sync_now(id);
CtlResponse::Ok {
@@ -1630,6 +1681,31 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
CtlResponse::error(e)
}
},
Ok(CtlRequest::UpdatePublicLink {
path,
id,
role,
password,
expires,
}) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => {
match core.update_public_link(&rel, &id, role.as_deref(), password, expires) {
Ok(link) => CtlResponse::PublicLink { link },
Err(e) => CtlResponse::error(e),
}
}
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::UpdatePublicLinkByUid {
uid,
id,
role,
password,
expires,
}) => match core.update_public_link_by_uid(&uid, &id, role.as_deref(), password, expires) {
Ok(link) => CtlResponse::PublicLink { link },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.remove_public_link(&rel, &id) {
Ok(()) => {
+44
View File
@@ -1080,6 +1080,50 @@ impl Core {
}
}
/// Forget what a mirror folder synced before, so its next pass compares the
/// two sides as on the first sync: what one side has alone is copied to the
/// other, and a file that differs is kept twice as a conflict. Nothing is
/// deleted on either side.
///
/// This is the way out for a folder whose passes refuse to run because its
/// whole local copy, or its whole copy on Drive, is gone (#46). The baseline
/// is what makes that read as "delete everything", and it is what this
/// drops. A queued switch to on-demand is dropped with it: the push pass
/// that switch runs reads an unrecorded local file as new and would upload
/// a second copy of every file Drive already has.
pub(crate) fn reset_sync_folder(&self, id: i64) -> CoreResult<String> {
let lock = self.sync_lock(id);
let Some(_guard) = lock.try_lock() else {
return Err(CoreError::conflict(
"this folder is syncing; try again when the pass ends",
));
};
let folder = self
.db
.sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
if folder.mode != "mirror" {
return Err(CoreError::invalid(
"an online-only folder keeps nothing on this computer to sync from scratch",
));
}
self.db
.sync_entries_clear(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
if folder.pending_mode.as_deref() == Some("ondemand") {
self.db
.sync_folder_set_pending_mode(id, None)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
}
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
info!(id, path = %folder.local_path, "sync folder reset");
Ok(format!(
"{} will sync from scratch",
base_name(&folder.local_path)
))
}
/// Spawn a secondary FUSE session rooted at `root` over `local` from a
/// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave
/// one, which would fail the fresh mount with EBUSY).
+36 -2
View File
@@ -28,8 +28,8 @@ use proton_drive_rs::proton_sdk::account::Quota;
use proton_drive_rs::proton_sdk::error::Result;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
use proton_drive_rs::{
Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient,
RevisionReader, Thumbnail,
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
ProtonDriveClient, RevisionReader, Thumbnail,
};
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
@@ -138,6 +138,22 @@ pub(crate) trait DriveApi: Send + Sync {
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>;
/// See [`ProtonDriveClient::copy_node`].
async fn copy_node(
&self,
uid: &NodeUid,
new_parent: &NodeUid,
name: Option<&str>,
) -> Result<NodeUid>;
/// See [`ProtonDriveClient::get_available_name`].
async fn get_available_name(
&self,
parent_uid: &NodeUid,
name: &str,
target: AvailableNameTarget,
) -> Result<String>;
fn move_nodes_streaming<'a>(
&'a self,
items: Vec<NodeMoveItem>,
@@ -448,6 +464,24 @@ impl DriveApi for ProtonDriveClient {
ProtonDriveClient::move_node(self, uid, new_parent).await
}
async fn copy_node(
&self,
uid: &NodeUid,
new_parent: &NodeUid,
name: Option<&str>,
) -> Result<NodeUid> {
ProtonDriveClient::copy_node(self, uid, new_parent, name).await
}
async fn get_available_name(
&self,
parent_uid: &NodeUid,
name: &str,
target: AvailableNameTarget,
) -> Result<String> {
ProtonDriveClient::get_available_name(self, parent_uid, name, target).await
}
fn move_nodes_streaming<'a>(
&'a self,
items: Vec<NodeMoveItem>,
+109 -64
View File
@@ -430,58 +430,10 @@ impl Filesystem for ProtonFs {
reply.error(error);
return;
}
let fh = fh.0;
// Stage the bytes straight into the scratch file (no base download): only
// the untouched remainder is pulled from the remote, and only at commit.
let file = {
let st = self.core.state();
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
Some(aw) => aw.file.clone(),
None => {
reply.error(Errno::EBADF);
return;
}
}
};
if let Err(e) = file.write_all_at(data, offset) {
error!(ino = ino.0, fh, error = %e, "scratch write failed");
// A full local disk is not a broken file: answer `ENOSPC` so the
// application can say so (and so `cp`/editors take their own
// out-of-space path), and free what the cache is holding — the
// scratch file the next write goes to shares that filesystem.
if e.raw_os_error() == Some(libc::ENOSPC) {
self.core.cache.emergency_evict();
reply.error(Errno::ENOSPC);
} else {
reply.error(Errno::EIO);
}
return;
match self.stage_write(ino.0, fh.0, offset, data) {
Ok(()) => reply.written(data.len() as u32),
Err(error) => reply.error(error),
}
let new_len = {
let mut st = self.core.state();
let Some(aw) = st
.handles
.get(&fh)
.copied()
.and_then(|i| st.active_writes.get_mut(&i))
else {
reply.error(Errno::EBADF);
return;
};
aw.record_write(offset, offset + data.len() as u64);
let len = aw.len;
st.set_size(ino.0, len);
len
};
debug!(
ino = ino.0,
fh,
offset,
len = data.len(),
new_len,
"staged write"
);
reply.written(data.len() as u32);
}
#[allow(clippy::too_many_arguments)]
@@ -719,7 +671,7 @@ impl Filesystem for ProtonFs {
reply: ReplyEmpty,
) {
let ino = self.tree(ino);
let (handle, unlinked_uid) = {
let (handle, unlinked_uid, releasing) = {
let mut st = self.core.state();
let unlinked_uid = release_unlinked_entry(&mut st, ino.0);
let ino = st.handles.remove(&fh.0);
@@ -732,7 +684,11 @@ impl Filesystem for ProtonFs {
None
}
});
(h, unlinked_uid)
let releasing = ino.filter(|_| h.is_some());
if let Some(ino) = releasing {
st.releasing.insert(ino);
}
(h, unlinked_uid, releasing)
};
// Deliberately **not** handed to a worker, unlike the other slow
// handlers here. The kernel does not wait for a `release` reply before
@@ -749,6 +705,9 @@ impl Filesystem for ProtonFs {
// finish it (audit F1). What is left is a rename, two fsyncs, a database
// insert, and at most a bounded copy out of the local cache.
self.finish_release(handle, unlinked_uid, reply);
if let Some(ino) = releasing {
self.core.state().releasing.remove(&ino);
}
}
fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
@@ -1016,20 +975,59 @@ impl Filesystem for ProtonFs {
fn copy_file_range(
&self,
_req: &Request,
_ino_in: INodeNo,
ino_in: INodeNo,
_fh_in: FileHandle,
_offset_in: u64,
_ino_out: INodeNo,
_fh_out: FileHandle,
_offset_out: u64,
_len: u64,
_flags: CopyFileRangeFlags,
offset_in: u64,
ino_out: INodeNo,
fh_out: FileHandle,
offset_out: u64,
len: u64,
flags: CopyFileRangeFlags,
reply: ReplyWrite,
) {
// Also must stay ENOSYS: there is no server-side copy, and this is the
// errno that sends coreutils (and the kernel) down the read/write path
// instead of failing the copy outright.
reply.error(Errno::ENOSYS);
// Copied here when the source bytes are on this machine, as `write`
// would stage them, minus a FUSE round trip per page: `cp` of a cached
// 256 MiB file ran at about 100 MiB/s through read and write. Anything
// that would have to be downloaded gets `EOPNOTSUPP`, which makes the
// kernel copy that one call through read and write itself; `ENOSYS`
// would turn the op off for the whole mount. There is no server-side
// copy into a file `cp` has already created, so the copy still uploads.
//
// On the dispatch loop, like `write`, so it keeps its order with the
// reads and writes of the same handle. [`COPY_RANGE_MAX`] bounds how
// long one call holds the loop.
let (ino_in, ino_out) = (self.tree(ino_in), self.tree(ino_out));
if !flags.is_empty() || ino_in == ino_out {
reply.error(Errno::EOPNOTSUPP);
return;
}
if let Err(error) = self.core.require_writable(ino_out.0) {
reply.error(error);
return;
}
let bytes = match self
.core
.read_local(ino_in.0, offset_in, len.min(COPY_RANGE_MAX))
{
Ok(Some(bytes)) => bytes,
Ok(None) => {
debug!(ino_in = ino_in.0, offset_in, "copy range not local");
reply.error(Errno::EOPNOTSUPP);
return;
}
Err(error) => {
reply.error(error);
return;
}
};
if bytes.is_empty() {
reply.written(0);
return;
}
match self.stage_write(ino_out.0, fh_out.0, offset_out, &bytes) {
Ok(()) => reply.written(bytes.len() as u32),
Err(error) => reply.error(error),
}
}
}
@@ -1057,6 +1055,11 @@ pub struct ProtonFs {
/// worker busy indefinitely.
const OPEN_BASE_ATTEMPTS: u32 = 3;
/// The most one `copy_file_range` call copies; the caller loops for the rest.
/// A local copy of this much takes milliseconds, which is how long it holds
/// the dispatch loop.
const COPY_RANGE_MAX: u64 = 16 * 1024 * 1024;
/// One directory's entries as `readdir` reports them: `.` and `..` first, then
/// the interned children in `children` order.
///
@@ -1641,6 +1644,48 @@ impl ProtonFs {
}
/// The body of [`Filesystem::create`], run off the dispatch loop.
/// Stage `data` at `offset` of the write handle `fh` on `ino`, straight into
/// its scratch file (no base download): only the untouched remainder is
/// pulled from the remote, and only at commit.
fn stage_write(&self, ino: u64, fh: u64, offset: u64, data: &[u8]) -> Result<(), Errno> {
let file = {
let st = self.core.state();
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
Some(aw) => aw.file.clone(),
None => return Err(Errno::EBADF),
}
};
if let Err(e) = file.write_all_at(data, offset) {
error!(ino, fh, error = %e, "scratch write failed");
// A full local disk is not a broken file: answer `ENOSPC` so the
// application can say so (and so `cp`/editors take their own
// out-of-space path), and free what the cache is holding — the
// scratch file the next write goes to shares that filesystem.
if e.raw_os_error() == Some(libc::ENOSPC) {
self.core.cache.emergency_evict();
return Err(Errno::ENOSPC);
}
return Err(Errno::EIO);
}
let new_len = {
let mut st = self.core.state();
let Some(aw) = st
.handles
.get(&fh)
.copied()
.and_then(|i| st.active_writes.get_mut(&i))
else {
return Err(Errno::EBADF);
};
aw.record_write(offset, offset + data.len() as u64);
let len = aw.len;
st.set_size(ino, len);
len
};
debug!(ino, fh, offset, len = data.len(), new_len, "staged write");
Ok(())
}
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
+129 -2
View File
@@ -74,8 +74,8 @@ use proton_drive_rs::proton_sdk::api::ResponseCode;
use proton_drive_rs::proton_sdk::error::ProtonError;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, ShareId, VolumeId};
use proton_drive_rs::{
DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, NodeMoveItem, ProtonDriveClient,
ProtonPhotosClient, SharedWithMeItem, ThumbnailType,
AvailableNameTarget, DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, NodeMoveItem,
ProtonDriveClient, ProtonPhotosClient, SharedWithMeItem, ThumbnailType,
};
mod albums;
@@ -5206,6 +5206,133 @@ impl Core {
Ok(name)
}
/// Copy a file or folder into `new_parent_rel` on Proton Drive, which copies
/// the content there: nothing is downloaded or uploaded. Both paths are
/// mountpoint-relative. A name already taken in the folder gets the next
/// free ` (n)` suffix. Returns the copy's name.
///
/// Drive copies what it holds, so the copy is refused offline and while the
/// mount still owes Drive part of it: a node or folder only queued locally,
/// a file with a queued upload or unsaved writes, or — for a folder, whose
/// queued changes could sit anywhere below it — any queued op at all.
fn copy_to(&self, rel: &Path, new_parent_rel: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?;
let (pino, new_parent_uid) = self
.resolve_path(new_parent_rel)
.map_err(|e| self.errno_error(e, "resolve new parent"))?;
self.ensure_children(pino)
.map_err(|e| self.errno_error(e, "enumerate"))?;
self.require_uid_writable(&new_parent_uid)
.map_err(|error| self.errno_error(error, "copy access"))?;
let (name, is_folder, unsaved) = {
let st = self.state();
let entry = st
.entries
.get(&ino)
.ok_or_else(|| CoreError::not_found(format!("{} is gone", rel.display())))?;
// Unsaved writes, or a release still queueing them, to the file or,
// for a folder, to any file below it.
let unsaved = st
.active_writes
.iter()
.filter(|(_, w)| w.dirty)
.map(|(&open, _)| open)
.chain(st.releasing.iter().copied())
.any(|open| relocate::ino_is_under(&st, open, ino));
(entry.node.name.clone(), entry.node.is_folder(), unsaved)
};
// Unlike a move, a copy cannot wait in the queue: it needs the node as
// Drive has it, so it goes now or not at all, local-first or not.
if !self.is_online() {
return Err(CoreError::offline());
}
let owed = unsaved
|| is_local_uid(&uid)
|| is_local_uid(&new_parent_uid)
|| self.db.has_any_op(&uid.to_string()).unwrap_or(true)
|| (is_folder
&& self
.db
.pending_op_counts()
.map(|counts| counts.uploads + counts.changes > 0)
.unwrap_or(true));
if owed {
return Err(CoreError::conflict(format!(
"{name} has changes that have not reached Proton Drive yet; \
copy it once they have synced"
)));
}
let target = if is_folder {
AvailableNameTarget::Folder
} else {
AvailableNameTarget::File
};
let copy_name = self
.rt
.block_on(
self.drive
.get_available_name(&new_parent_uid, &name, target),
)
.map_err(|e| CoreError::from_api(&e, "copy"))?;
let new_uid = self
.rt
.block_on(
self.drive
.copy_node(&uid, &new_parent_uid, Some(&copy_name)),
)
.map_err(|e| CoreError::from_api(&e, "copy"))?;
let node = self
.fetch_minted_node(&new_uid, &copy_name)
.ok_or_else(|| minted_but_unlisted(&copy_name))?;
let mut st = self.state();
let copy_ino = st.intern(pino, node);
if let Some(kids) = st.children.get_mut(&pino)
&& !kids.contains(&copy_ino)
{
kids.push(copy_ino);
}
drop(st);
self.flush_access_changes();
Ok(copy_name)
}
/// What the folder at `rel` holds on Proton Drive: bytes on storage and the
/// number of items below it. Drive adds it up, so nothing is listed here;
/// trashed items count, and the bytes are encrypted ones.
pub(crate) fn folder_size(&self, rel: &Path) -> CoreResult<proton_drive_rs::FolderSize> {
let (ino, uid) = self.resolve(rel)?;
let is_folder = self
.state()
.entries
.get(&ino)
.is_some_and(|entry| entry.node.is_folder());
if !is_folder {
return Err(CoreError::invalid(format!(
"{} is not a folder",
rel.display()
)));
}
self.folder_size_for_uid(&uid)
}
/// [`Core::folder_size`] for a folder addressed through any daemon location.
pub(crate) fn folder_size_by_uid(&self, uid: &str) -> CoreResult<proton_drive_rs::FolderSize> {
let uid = self.resolve_anywhere(uid)?;
self.folder_size_for_uid(&uid)
}
fn folder_size_for_uid(&self, uid: &NodeUid) -> CoreResult<proton_drive_rs::FolderSize> {
if is_local_uid(uid) {
return Err(CoreError::conflict("the folder is not on Proton Drive yet"));
}
if !self.is_online() {
return Err(CoreError::offline());
}
self.rt
.block_on(self.client.get_folder_size(uid))
.map_err(|e| CoreError::from_api(&e, "folder size"))
}
/// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node,
/// evicts any cached content, and invalidates the parent listing.
fn delete(&self, rel: &Path) -> CoreResult<String> {
+4
View File
@@ -450,6 +450,10 @@ impl Core {
}
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
// The reply is gone once the app has shown it; the log keeps why.
for reason in &skipped {
info!(reason, "restore skipped a folder");
}
let mut message = format!("restored {restored} folder(s)");
if !skipped.is_empty() {
message.push_str(&format!("; skipped: {}", skipped.join(", ")));
+86
View File
@@ -1101,6 +1101,92 @@ impl Core {
})
}
/// Bytes `[offset, offset + size)` of file `ino` as `read` would serve
/// them, but only from this machine: an open write handle, a queued write,
/// or the caches. `Ok(None)` the moment any of it would have to be
/// downloaded, and for a size still provisional (B100), which only the
/// network settles. Short or empty past the end of the file.
///
/// `copy_file_range` answers with this and leaves anything else to the
/// kernel's read/write copy.
pub(super) fn read_local(
&self,
ino: u64,
offset: u64,
size: u64,
) -> Result<Option<Vec<u8>>, Errno> {
let handle = self.state().active_writes.get(&ino).map(|h| {
(
h.file.clone(),
h.len,
h.uid.clone(),
h.base_mtime,
h.base_size,
h.written.clone(),
)
});
if let Some((file, len, uid, base_mtime, base_size, written)) = handle {
return self.serve_open_read_local(
&file, len, &uid, base_mtime, base_size, &written, offset, size,
);
}
let (uid, mtime, fsize, provisional) = {
let st = self.state();
match st.entries.get(&ino) {
Some(e) if e.node.is_file() => (
e.uid.clone(),
e.node.modification_time,
node_size(&e.node),
matches!(
&e.node.kind,
NodeKind::File {
claimed_size: None,
..
}
),
),
Some(_) => return Err(Errno::EISDIR),
None => return Err(Errno::ENOENT),
}
};
let uid = match is_local_uid(&uid) {
true => self.landed_stand_in_uid(&uid).unwrap_or(uid),
false => uid,
};
if let Some(pending) = self.pending.lock().get(&uid).cloned() {
let m = &pending.meta;
let file = File::open(&pending.path).map_err(|e| {
error!(%uid, path = %pending.path.display(), error = %e, "open staged blob failed");
Errno::EIO
})?;
let mut written = Intervals::default();
for &(s, e) in &m.authored {
written.add(s, e);
}
return self.serve_open_read_local(
&Arc::new(file),
m.len,
&uid,
m.base_mtime,
m.base_size,
&written,
offset,
size,
);
}
// Created here and never written: an empty file.
if is_local_uid(&uid) {
return Ok(Some(Vec::new()));
}
if provisional {
return Ok(None);
}
if offset >= fsize || size == 0 {
return Ok(Some(Vec::new()));
}
Ok(self.cached_range(&uid, mtime, fsize, offset, size.min(fsize - offset)))
}
/// The largest amount [`Core::fill_gaps_cached`] will copy out of the local
/// caches while a `release(2)` is in progress.
///
+2 -1
View File
@@ -133,7 +133,7 @@ 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 {
pub(crate) fn ino_is_under(st: &State, mut ino: u64, root: u64) -> bool {
for _ in 0..MAX_TREE_DEPTH {
if ino == root {
return true;
@@ -456,6 +456,7 @@ impl Core {
writing = st
.active_writes
.keys()
.chain(&st.releasing)
.any(|&open| ino_is_under(st, open, ino));
});
Ok(writing)
+85 -1
View File
@@ -9,7 +9,8 @@
use std::path::Path;
use pdfs_core::control::{
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
BookmarkInfo, DirEntry, InvitationInfo, LinkChange, PublicLinkInfo, ShareEntry, ShareEntryKind,
SharedItem,
};
use pdfs_core::{CoreError, CoreResult};
use tracing::debug;
@@ -367,6 +368,89 @@ impl Core {
Ok(public_link_info(link))
}
/// Change the public link `id` on the node at `rel`, keeping its URL.
pub(crate) fn update_public_link(
&self,
rel: &Path,
id: &str,
role: Option<&str>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let (_ino, uid) = self.resolve(rel)?;
self.update_public_link_for_uid(&uid, id, role, password, expires)
}
/// [`Core::update_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn update_public_link_by_uid(
&self,
uid: &str,
id: &str,
role: Option<&str>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
self.update_public_link_for_uid(&uid, id, role, password, expires)
}
/// The SDK sets all three options on every update, so each `Keep` is
/// filled in from the link as it stands.
fn update_public_link_for_uid(
&self,
uid: &NodeUid,
id: &str,
role: Option<&str>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let link = self
.rt
.block_on(self.client.get_public_link(uid))
.map_err(|e| CoreError::from_api(&e, "get public link"))?
.filter(|l| l.public_link_id == id)
.ok_or_else(|| CoreError::not_found("public link not found"))?;
let role = match role {
Some(role) => role_from_str(role)?,
None => link.role,
};
let unchanged = role == link.role
&& matches!(password, LinkChange::Keep)
&& matches!(expires, LinkChange::Keep);
if unchanged {
return Ok(public_link_info(link));
}
let password = match password {
LinkChange::Keep if link.has_custom_password => {
// Sending `None` here would quietly remove the password.
Some(link.custom_password.clone().ok_or_else(|| {
CoreError::conflict(
"the link's password could not be read; set a new one or remove it",
)
})?)
}
LinkChange::Keep | LinkChange::Remove => None,
LinkChange::Set(password) if password.is_empty() => {
return Err(CoreError::invalid("the new password is empty"));
}
LinkChange::Set(password) => Some(password),
};
let expires = match expires {
LinkChange::Keep => link.expiration_time,
LinkChange::Remove => None,
LinkChange::Set(at) => Some(at),
};
let link = self
.rt
.block_on(
self.client
.update_public_link(uid, role, password.as_deref(), expires),
)
.map_err(|e| CoreError::from_api(&e, "update public link"))?;
Ok(public_link_info(link))
}
/// Remove the public link `id` from the node at `rel`.
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
+323
View File
@@ -2214,6 +2214,75 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes() {
let drive = FakeDrive::new();
let dir = scratch("pdfs-copy");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mount = &daemon.mountpoint;
std::fs::create_dir_all(mount.join("src/inner")).unwrap();
std::fs::create_dir_all(mount.join("dest")).unwrap();
std::fs::write(mount.join("src/a.txt"), b"alpha").unwrap();
std::fs::write(mount.join("src/inner/b.txt"), b"beta").unwrap();
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().get("src/inner/b.txt").cloned() == file(b"beta")
&& daemon.pending().is_ok_and(|ops| ops.is_empty())
}));
let path = |rel: &str| mount.join(rel).to_string_lossy().into_owned();
let copy = |from: &str, to: &str| {
daemon.request(&Request::Copy {
path: path(from),
new_parent: path(to),
})
};
let uploads = drive.tree().len();
copy("src/a.txt", "dest").unwrap();
// A name already taken gets a suffix rather than an error.
copy("src/a.txt", "dest").unwrap();
copy("src", "dest").unwrap();
assert_eq!(std::fs::read(mount.join("dest/a.txt")).unwrap(), b"alpha");
assert_eq!(
std::fs::read(mount.join("dest/a (1).txt")).unwrap(),
b"alpha"
);
assert_eq!(
std::fs::read(mount.join("dest/src/inner/b.txt")).unwrap(),
b"beta"
);
let tree = drive.tree();
assert_eq!(tree.get("dest/src/a.txt").cloned(), file(b"alpha"));
assert_eq!(
tree.len(),
uploads + 6,
"{:?}",
tree.keys().collect::<Vec<_>>()
);
// Drive would copy what it has, not the bytes still queued.
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
daemon.request(&paused(true)).unwrap();
std::fs::write(mount.join("src/a.txt"), b"alpha, edited").unwrap();
match copy("src/a.txt", "dest") {
Err(e) => assert!(e.to_string().contains("not reached Proton Drive"), "{e}"),
Ok(answer) => panic!("the copy went ahead: {answer:?}"),
}
match copy("src", "dest") {
Err(e) => assert!(e.to_string().contains("not reached Proton Drive"), "{e}"),
Ok(answer) => panic!("the folder copy went ahead: {answer:?}"),
}
daemon.request(&paused(false)).unwrap();
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_pdfs_rename_after_a_queued_rename_is_not_undone() {
@@ -2364,6 +2433,152 @@ mod tests {
eprintln!("wifi: syscalls {wifi_syscalls:?}, drain {wifi_drain:?}");
}
/// `copy_file_range` inside the mount is answered from the bytes the daemon
/// holds, and left to the kernel for bytes it would have to download. Every
/// source kind has to come out byte for byte: cached, queued, open with
/// unsaved writes, and on Drive only.
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_copy_inside_the_mount_has_the_source_bytes() {
use std::os::fd::AsRawFd as _;
use std::os::unix::fs::FileExt as _;
fn copy_range(from: &std::fs::File, at: u64, to: &std::fs::File, to_at: u64, len: usize) {
let (mut at, mut to_at) = (at as i64, to_at as i64);
let mut left = len;
while left > 0 {
// SAFETY: both descriptors are open; the offsets are ours.
let n = unsafe {
libc::copy_file_range(
from.as_raw_fd(),
&mut at,
to.as_raw_fd(),
&mut to_at,
left,
0,
)
};
assert!(n > 0, "copy_file_range: {}", io::Error::last_os_error());
left -= n as usize;
}
}
let pattern = |len: usize, seed: u8| -> Vec<u8> {
(0..len).map(|i| (i % 253) as u8 ^ seed).collect()
};
let drive = FakeDrive::new();
// On Drive before the mount knows it, so nothing of it is cached.
let remote = pattern(9 << 20, 1);
drive.device().write("remote.bin", &remote);
let dir = scratch("copy-range");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mount = &daemon.mountpoint;
// Downloaded through the kernel's own copy, then served from the cache.
std::fs::copy(mount.join("remote.bin"), mount.join("remote-1.bin")).unwrap();
assert!(std::fs::read(mount.join("remote-1.bin")).unwrap() == remote);
std::fs::copy(mount.join("remote.bin"), mount.join("remote-2.bin")).unwrap();
assert!(std::fs::read(mount.join("remote-2.bin")).unwrap() == remote);
// Queued and not on Drive yet: the staged blob is the content.
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
daemon.request(&paused(true)).unwrap();
let queued = pattern(20 << 20, 2);
std::fs::write(mount.join("queued.bin"), &queued).unwrap();
std::fs::copy(mount.join("queued.bin"), mount.join("queued-copy.bin")).unwrap();
assert!(std::fs::read(mount.join("queued-copy.bin")).unwrap() == queued);
// Open with writes not saved yet: the copy sees them, as a read would.
let open = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(mount.join("queued.bin"))
.unwrap();
open.write_all_at(b"UNSAVED", 100).unwrap();
let target = std::fs::File::create(mount.join("unsaved-copy.bin")).unwrap();
copy_range(&open, 96, &target, 0, 16);
drop(target);
drop(open);
let mut expected = queued[96..112].to_vec();
expected[4..11].copy_from_slice(b"UNSAVED");
assert_eq!(
std::fs::read(mount.join("unsaved-copy.bin")).unwrap(),
expected
);
// A range into the middle of a file keeps the bytes around it.
std::fs::write(mount.join("middle.bin"), b"0123456789").unwrap();
let source = std::fs::File::open(mount.join("remote.bin")).unwrap();
let target = std::fs::OpenOptions::new()
.write(true)
.open(mount.join("middle.bin"))
.unwrap();
copy_range(&source, 5, &target, 3, 4);
drop(target);
let mut expected = b"0123456789".to_vec();
expected[3..7].copy_from_slice(&remote[5..9]);
assert_eq!(std::fs::read(mount.join("middle.bin")).unwrap(), expected);
daemon.request(&paused(false)).unwrap();
let mut edited = queued.clone();
edited[100..107].copy_from_slice(b"UNSAVED");
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
assert!(wait_until(Duration::from_secs(120), || {
let tree = drive.tree();
daemon.pending().is_ok_and(|ops| ops.is_empty())
&& tree.get("queued.bin").cloned() == file(&edited)
&& tree.get("queued-copy.bin").cloned() == file(&queued)
&& tree.get("remote-2.bin").cloned() == file(&remote)
&& tree.get("middle.bin").cloned() == file(&expected)
}));
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// How long `cp` inside the mount takes for a file the daemon already
/// holds, cold and from the page cache. It decides whether the daemon
/// should answer `copy_file_range` itself rather than leave the kernel to
/// copy through read and write. Runs when `PDFS_SIM_MEASURE` is set.
#[test]
#[ignore = "mounts FUSE: run with `PDFS_SIM_MEASURE=1 cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_copy_inside_the_mount_measures_its_speed() {
if std::env::var_os("PDFS_SIM_MEASURE").is_none() {
eprintln!("skipped: set PDFS_SIM_MEASURE to measure the copy");
return;
}
const SIZE: usize = 256 << 20;
let drive = FakeDrive::new();
let dir = scratch("copy-speed");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mount = &daemon.mountpoint;
let bytes: Vec<u8> = (0..SIZE).map(|i| (i % 251) as u8).collect();
std::fs::write(mount.join("big.bin"), &bytes).unwrap();
assert!(wait_until(Duration::from_secs(120), || {
daemon.pending().is_ok_and(|ops| ops.is_empty())
}));
for (round, name) in ["copy-1.bin", "copy-2.bin", "copy-3.bin"]
.into_iter()
.enumerate()
{
let start = Instant::now();
let status = std::process::Command::new("cp")
.arg(mount.join("big.bin"))
.arg(mount.join(name))
.status()
.unwrap();
let took = start.elapsed();
assert!(status.success());
let mib_per_s = (SIZE >> 20) as f64 / took.as_secs_f64();
eprintln!("cp round {round}: {took:?}, {mib_per_s:.0} MiB/s");
assert!(std::fs::read(mount.join(name)).unwrap() == bytes);
}
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 half_of_a_wide_folder_unlinked_at_once_leaves_the_other_half() {
@@ -2658,4 +2873,112 @@ mod tests {
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// The synced folder `id` as the daemon lists it.
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
match daemon.request(&Request::ListSyncFolders) {
Ok(Response::SyncFolders { items }) => items.into_iter().find(|f| f.id == id),
_ => None,
}
}
/// A mirror folder holding `a.txt` and `sub/b.txt`, synced to Drive, whose
/// local copy is then deleted in full while the folder is paused, so no
/// pass sees half of it. Waits for the pass that refuses to run (#46).
fn folder_whose_local_copy_is_gone(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 {
let id = sync_folder(daemon, local, "mirror");
std::fs::write(local.join("a.txt"), b"alpha").unwrap();
std::fs::create_dir(local.join("sub")).unwrap();
std::fs::write(local.join("sub/b.txt"), b"beta").unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
let on_drive = |name: &str, content: &[u8]| {
drive.tree().iter().any(|(path, entry)| {
path.ends_with(name) && *entry == Entry::File(Arc::new(content.to_vec()))
})
};
assert!(
wait_until(Duration::from_secs(30), || on_drive("a.txt", b"alpha")
&& on_drive("sub/b.txt", b"beta")
&& listed_folder(daemon, id).is_some_and(|f| f.state == "idle")),
"the folder synced: {:?} {:?}",
listed_folder(daemon, id),
drive.tree().keys().collect::<Vec<_>>()
);
daemon
.request(&Request::SetSyncFolderPaused { id, paused: true })
.unwrap();
std::fs::remove_file(local.join("a.txt")).unwrap();
std::fs::remove_dir_all(local.join("sub")).unwrap();
daemon
.request(&Request::SetSyncFolderPaused { id, paused: false })
.unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || listed_folder(daemon, id)
.is_some_and(|f| f.state == "error")),
"the pass refused to trash everything: {:?}",
listed_folder(daemon, id)
);
assert!(
on_drive("a.txt", b"alpha") && on_drive("sub/b.txt", b"beta"),
"nothing was trashed on Drive: {:?}",
drive.tree().keys().collect::<Vec<_>>()
);
id
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_folder_whose_local_copy_is_gone_downloads_again_after_a_reset() {
let drive = FakeDrive::new();
let dir = scratch("reset-gone-local-copy");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("docs");
let id = folder_whose_local_copy_is_gone(&daemon, &drive, &local);
daemon.request(&Request::ResetSyncFolder { id }).unwrap();
let has = |rel: &str, content: &[u8]| {
std::fs::read(local.join(rel)).is_ok_and(|bytes| bytes == content)
};
assert!(
wait_until(Duration::from_secs(30), || has("a.txt", b"alpha")
&& has("sub/b.txt", b"beta")
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")),
"the reset downloaded the folder again: {:?}",
listed_folder(&daemon, id)
);
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_whose_local_copy_is_gone_still_goes_on_demand() {
let drive = FakeDrive::new();
let dir = scratch("ondemand-gone-local-copy");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("docs");
let id = folder_whose_local_copy_is_gone(&daemon, &drive, &local);
daemon
.request(&Request::SetSyncFolderMode {
id,
mode: "ondemand".into(),
})
.unwrap();
assert!(
wait_until(Duration::from_secs(30), || listed_folder(&daemon, id)
.is_some_and(|f| f.mode == "ondemand" && f.pending_mode.is_none())
&& is_mounted(&local)),
"the queued switch landed: {:?}",
listed_folder(&daemon, id)
);
assert_eq!(std::fs::read(local.join("a.txt")).unwrap(), b"alpha");
assert_eq!(std::fs::read(local.join("sub/b.txt")).unwrap(), b"beta");
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
}
+90 -2
View File
@@ -61,8 +61,8 @@ use proton_drive_rs::proton_sdk::ids::{
DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, VolumeId,
};
use proton_drive_rs::{
Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem,
RevisionState, Thumbnail,
AvailableNameTarget, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node,
NodeKind, NodeMoveItem, RevisionState, Thumbnail,
};
use sha1::{Digest as _, Sha1};
@@ -185,6 +185,39 @@ impl Server {
}
}
/// Copy `uid` and, for a folder, everything live below it under `parent` as
/// `name`, the way Drive's copy endpoint does. Returns the copy's uid.
fn copy_subtree(&mut self, uid: &NodeUid, parent: &NodeUid, name: String) -> NodeUid {
let source = self
.current(uid)
.cloned()
.expect("the caller checked the source");
let copy = self.mint_uid();
let now = now_secs();
self.put(
&copy,
Some(Remote {
parent: Some(parent.clone()),
name,
created: now,
..source
}),
);
let children: Vec<(NodeUid, String)> = self
.nodes
.keys()
.filter_map(|child| {
let remote = self.current(child)?;
(remote.parent.as_ref() == Some(uid) && !remote.trashed)
.then(|| (child.clone(), remote.name.clone()))
})
.collect();
for (child, child_name) in children {
self.copy_subtree(&child, &copy, child_name);
}
copy
}
fn mint_uid(&mut self) -> NodeUid {
self.next_link += 1;
uid(&format!("L{}", self.next_link))
@@ -1490,6 +1523,61 @@ impl DriveApi for FakeClient {
.await
}
async fn copy_node(
&self,
uid: &NodeUid,
new_parent: &NodeUid,
name: Option<&str>,
) -> Result<NodeUid> {
self.request(true, |server| {
if !server.is_folder(new_parent) {
return Err(api(ResponseCode::DoesNotExist, "parent folder not found"));
}
let source = server
.current(uid)
.filter(|remote| !remote.trashed)
.cloned()
.ok_or_else(|| api(ResponseCode::DoesNotExist, "node not found"))?;
if server.is_below(new_parent, uid) {
return Err(api(
ResponseCode::InvalidRequirements,
"cannot copy a folder into itself",
));
}
let name = name.map_or(source.name.clone(), str::to_owned);
if server.live_child_named(new_parent, &name).is_some() {
return Err(api(
ResponseCode::AlreadyExists,
"a file or folder with that name already exists",
));
}
Ok(server.copy_subtree(uid, new_parent, name))
})
.await
}
async fn get_available_name(
&self,
parent_uid: &NodeUid,
name: &str,
target: AvailableNameTarget,
) -> Result<String> {
self.request(false, |server| {
let (stem, extension) = match (target, name.rsplit_once('.')) {
(AvailableNameTarget::File, Some((stem, ext))) if !stem.is_empty() => {
(stem, format!(".{ext}"))
}
_ => (name, String::new()),
};
let free = std::iter::once(name.to_owned())
.chain((1..).map(|n| format!("{stem} ({n}){extension}")))
.find(|candidate| server.live_child_named(parent_uid, candidate).is_none())
.expect("an unbounded sequence has a free name");
Ok(free)
})
.await
}
fn move_nodes_streaming<'a>(
&'a self,
items: Vec<NodeMoveItem>,
+6
View File
@@ -266,6 +266,11 @@ pub(crate) struct State {
pub(crate) next_ino: u64,
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
pub(crate) active_writes: HashMap<u64, WriteHandle>,
/// Inodes whose last write handle `release` has taken out of
/// [`State::active_writes`] and is still queueing. `close(2)` returns
/// before that, and in between the edit is in neither the handles nor the
/// queue, so a check for changes not on Drive yet looks here too.
pub(crate) releasing: HashSet<u64>,
/// Maps file handle id (fh) to inode (ino). Read-only opens use fh 0 and
/// have no entry here.
pub(crate) handles: HashMap<u64, u64>,
@@ -428,6 +433,7 @@ impl State {
roots: HashSet::new(),
next_ino: FALLBACK_INO,
active_writes: HashMap::new(),
releasing: HashSet::new(),
handles: HashMap::new(),
next_fh: 1,
dir_snapshots: HashMap::new(),
+34 -2
View File
@@ -369,7 +369,20 @@ impl Core {
// guard on every pass — wedging the folder's sync permanently — the
// moment a rule covers the whole tree.
let tracked = tracked_baseline(&baseline, rules, &unscanned);
guard_local_wipe(&tracked, &local)?;
// A local copy that is gone in its entirety is not trashed on Drive,
// for the reason `guard_local_wipe` gives. A full pass stops there, but
// this one exists for a switch to on-demand, which needs nothing local
// and leaves Drive as it is: refusing the pass would only keep the
// folder out of `idle`, wedging the switch for good (#46). So the pass
// forgets those paths instead of trashing them, and the mount then
// shows everything Drive still has.
let local_gone = match guard_local_wipe(&tracked, &local) {
Ok(()) => false,
Err(reason) => {
warn!(id = folder_id, %reason, "sync: going on-demand without trashing anything on Drive");
true
}
};
// The remote folder uids come from the baseline instead of a walk: every
// directory this device has synced recorded its uid there when it was
@@ -579,8 +592,27 @@ impl Core {
// showing the folder's remote contents — otherwise everything they deleted
// comes back the moment the switch lands. Shallowest first, skipping anything
// under a folder already trashed (its children went with it).
let deletions = local_deletions(&tracked, &local);
if local_gone {
// Forgotten instead of trashed. A row left behind would read to
// the switch as a deletion not on Drive yet and withdraw it, and
// to a later full pass as a reason to stop again. Forgotten, a full
// pass downloads the paths again.
let mut forgotten: HashSet<&str> = HashSet::new();
for rel in &deletions {
let mut parent = parent_rel(rel);
while !parent.is_empty() && !forgotten.contains(parent) {
parent = parent_rel(parent);
}
if parent.is_empty() {
let _ = self.db.sync_entries_remove_subtree(folder_id, rel);
forgotten.insert(rel);
}
}
return Ok(outcome);
}
let mut trashed: Vec<String> = Vec::new();
for rel in &local_deletions(&tracked, &local) {
for rel in &deletions {
if trashed
.iter()
.any(|dir| rel.starts_with(&format!("{dir}/")))
+4 -2
View File
@@ -192,7 +192,8 @@ pub(super) fn guard_local_wipe<B, L>(
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
return Err(format!(
"every one of the {} synced paths is missing locally; refusing to trash \
them on Drive. Check that the folder is mounted and readable.",
them on Drive. Check that the folder is mounted and readable; to download \
them again, sync the folder from scratch.",
baseline.len()
));
}
@@ -213,7 +214,8 @@ pub(super) fn guard_remote_wipe<B, R>(
if !baseline.is_empty() && baseline.keys().all(|rel| !remote.contains_key(rel)) {
return Err(format!(
"every one of the {} synced paths is missing on Proton Drive; refusing to \
delete them here. If they were deleted on purpose, remove the folder from sync.",
delete them here. If they were deleted on purpose, remove the folder from sync; \
to upload them again, sync the folder from scratch.",
baseline.len()
));
}
+5 -5
View File
@@ -75,11 +75,11 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState};
use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic,
TransferDirection, TransferItem, send,
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, LinkChange, MappingProgress,
PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope,
Request, Response, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry,
ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress,
ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
};
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
@@ -1043,6 +1043,7 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
ActivityKind::Sync => "pdfs-sync-symbolic",
ActivityKind::Rename => "document-edit-symbolic",
ActivityKind::Move => "go-jump-symbolic",
ActivityKind::Copy => "edit-copy-symbolic",
ActivityKind::CreateFolder => "folder-new-symbolic",
ActivityKind::Trash => "user-trash-symbolic",
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
@@ -179,6 +179,10 @@ pub(crate) fn activity_title(kind: ActivityKind, ok: bool, name: &str) -> String
(ActivityKind::Move, true) => gettext_f("Moved {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Move, false) => gettext_f("Couldn't move {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Copy, true) => gettext_f("Copied {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Copy, false) => gettext_f("Couldn't copy {name}", &args),
// Translators: {name} is a folder name.
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
// Translators: {name} is a folder name.
+200 -46
View File
@@ -904,6 +904,12 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
menu.item(&gettext("Move To…"), move || {
prompt_move(&ui_c, vec![entry_c.clone()])
});
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Make a Copy"), move || make_copy(&ui_c, &entry_c));
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Copy To…"), move || {
prompt_copy(&ui_c, vec![entry_c.clone()])
});
if !entry.is_dir {
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Versions…"), move || {
@@ -938,6 +944,10 @@ pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMe
menu.item(&gettext("Move To…"), move || {
prompt_move(&ui_c, batch.clone())
});
let (ui_c, batch) = (ui.clone(), entries.clone());
menu.item(&gettext("Copy To…"), move || {
prompt_copy(&ui_c, batch.clone())
});
menu.labelled_section(&ngettext_f(
"{n} selected",
"{n} selected",
@@ -1940,17 +1950,63 @@ pub(crate) fn prompt_rename(ui: &Rc<Ui>, entry: &DirEntry) {
/// still made on Proton Drive, so nothing is downloaded or uploaded again; it
/// is sent with absolute paths, which is how the daemon tells locations apart.
pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
prompt_destination(ui, entries, PickerKind::Move);
}
/// Ask where to copy `entries`, then copy them there on Proton Drive. Only
/// My files is offered: a copy is made on the server, inside the mount.
pub(crate) fn prompt_copy(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
prompt_destination(ui, entries, PickerKind::Copy);
}
/// Copy one entry into the folder it is in. The copy gets a free name, such
/// as "report (1).pdf".
pub(crate) fn make_copy(ui: &Rc<Ui>, entry: &DirEntry) {
let source = entry_rel(ui, entry);
let parent = source
.rfind('/')
.map(|i| source[..i].to_string())
.unwrap_or_default();
run_bulk_copy(ui, vec![source], parent);
}
/// What the folder picker chooses a destination for.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum PickerKind {
Move,
Copy,
}
fn prompt_destination(ui: &Rc<Ui>, entries: Vec<DirEntry>, kind: PickerKind) {
if entries.is_empty() {
return;
}
let sources: Vec<String> = entries.iter().map(|e| entry_rel(ui, e)).collect();
let heading = match entries.as_slice() {
[one] => gettext_f("Move “{name}”", &[("name", &one.name)]),
many => ngettext_f("Move {n} item", "Move {n} items", many.len() as u64, &[]),
let (heading, body, confirm) = match (kind, entries.as_slice()) {
(PickerKind::Move, [one]) => (
gettext_f("Move “{name}”", &[("name", &one.name)]),
gettext("Choose the folder to move into."),
gettext("Move Here"),
),
(PickerKind::Move, many) => (
ngettext_f("Move {n} item", "Move {n} items", many.len() as u64, &[]),
gettext("Choose the folder to move into."),
gettext("Move Here"),
),
(PickerKind::Copy, [one]) => (
gettext_f("Copy “{name}”", &[("name", &one.name)]),
gettext("Choose the folder to copy into."),
gettext("Copy Here"),
),
(PickerKind::Copy, many) => (
ngettext_f("Copy {n} item", "Copy {n} items", many.len() as u64, &[]),
gettext("Choose the folder to copy into."),
gettext("Copy Here"),
),
};
let dialog = adw::AlertDialog::builder()
.heading(heading)
.body(gettext("Choose the folder to move into."))
.body(body)
.build();
let up = gtk4::Button::builder()
@@ -1981,7 +2037,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
body.append(&scroll);
dialog.set_extra_child(Some(&body));
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("move", &gettext("Move Here"));
dialog.add_response("move", &confirm);
dialog.set_response_appearance("move", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("move"));
dialog.set_close_response("cancel");
@@ -1994,6 +2050,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
list,
up: up.clone(),
sources: sources.clone(),
kind,
folder: RefCell::new(MoveTarget::Locations),
generation: Cell::new(0),
});
@@ -2013,6 +2070,10 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
}
match picker_done.folder.borrow().clone() {
MoveTarget::Locations => {}
MoveTarget::Drive(folder) if kind == PickerKind::Copy => {
run_bulk_copy(&ui, sources.clone(), folder)
}
MoveTarget::Synced { .. } if kind == PickerKind::Copy => {}
MoveTarget::Drive(folder) => run_bulk_move(&ui, sources.clone(), folder),
MoveTarget::Synced { path, .. } => {
let absolute = sources
@@ -2088,8 +2149,9 @@ struct MovePicker {
location: gtk4::Label,
list: gtk4::ListBox,
up: gtk4::Button,
/// What is being moved, mountpoint-relative.
/// What is being moved or copied, mountpoint-relative.
sources: Vec<String>,
kind: PickerKind,
/// The folder on show, and the destination if the user confirms now.
folder: RefCell<MoveTarget>,
/// Drops a listing that arrives after the user went elsewhere.
@@ -2110,11 +2172,13 @@ impl MovePicker {
self.location.set_tooltip_text(Some(&tooltip));
self.up
.set_sensitive(!matches!(folder, MoveTarget::Locations));
let allowed = match &folder {
MoveTarget::Locations => false,
MoveTarget::Drive(rel) => move_target_allowed(&self.sources, rel),
let allowed = match (&folder, self.kind) {
(MoveTarget::Locations, _) => false,
(MoveTarget::Drive(rel), PickerKind::Move) => move_target_allowed(&self.sources, rel),
(MoveTarget::Drive(rel), PickerKind::Copy) => copy_target_allowed(&self.sources, rel),
// What is moved is in My files, so it cannot be in a synced folder.
MoveTarget::Synced { .. } => true,
(MoveTarget::Synced { .. }, PickerKind::Move) => true,
(MoveTarget::Synced { .. }, PickerKind::Copy) => false,
};
self.dialog.set_response_enabled("move", allowed);
*self.folder.borrow_mut() = folder.clone();
@@ -2180,6 +2244,10 @@ impl MovePicker {
/// My files and every synced folder, as rows of the top level.
async fn locations(&self) -> Option<Vec<PickerRow>> {
let mut rows = vec![(gettext("My files"), None, MoveTarget::Drive(String::new()))];
// A copy is made on the server, so it can only land in My files.
if self.kind == PickerKind::Copy {
return Some(rows);
}
let rx = spawn_request(self.ui.dirs.control_socket(), Request::ListSyncFolders);
let Ok(Ok(Response::SyncFolders { mut items })) = rx.recv().await else {
// My files alone is still somewhere to move to.
@@ -2286,11 +2354,16 @@ pub(crate) fn move_target_allowed(sources: &[String], target: &str) -> bool {
.map(|i| path[..i].to_string())
.unwrap_or_default()
};
let inside = sources
.iter()
.any(|src| target == src || target.starts_with(&format!("{src}/")));
let all_here = sources.iter().all(|src| parent(src) == target);
!inside && !all_here
copy_target_allowed(sources, target) && !all_here
}
/// Whether `target` is somewhere `sources` can be copied to: anywhere but into
/// one of themselves. The folder they are in is fine; the copy gets a free name.
pub(crate) fn copy_target_allowed(sources: &[String], target: &str) -> bool {
!sources
.iter()
.any(|src| target == src || target.starts_with(&format!("{src}/")))
}
/// Move each of `sources` into `target`, one request at a time, then report
@@ -2309,38 +2382,14 @@ pub(crate) fn run_bulk_move(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
clear_selection(ui);
let ui = ui.clone();
glib::spawn_future_local(async move {
let mut done = 0usize;
let mut failure: Option<(String, ErrorKind)> = None;
for path in sources {
let rx = spawn_request(
socket.clone(),
Request::Move {
path,
new_parent: target.clone(),
},
);
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => done += 1,
Ok(Ok(Response::Error { message, kind })) => {
failure.get_or_insert((message, kind));
}
_ => {
failure.get_or_insert_with(|| {
(
gettext("The Proton Drive service didn't respond."),
ErrorKind::Internal,
)
});
}
}
}
let (done, failure) = send_each(&socket, sources, |path| Request::Move {
path,
new_parent: target.clone(),
})
.await;
ui.busy_end();
reload_listing(&ui);
let place = match target.rsplit('/').next() {
// Translators: a folder name in quotes, filled in as {place} in "Moved to {place}".
Some(name) if !name.is_empty() => gettext_f("“{name}”", &[("name", name)]),
_ => "Proton Drive".to_string(),
};
let place = quoted_place(&target);
match (done, failure) {
(0, Some((message, kind))) => {
toast_failure(&ui, &gettext("Couldn't move"), &message, kind)
@@ -2373,6 +2422,101 @@ pub(crate) fn run_bulk_move(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
});
}
/// Copy each of `sources` into `target` on Proton Drive, one request at a
/// time, then report once for the lot.
pub(crate) fn run_bulk_copy(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
if !*ui.mounted.borrow() {
toast_error(
ui,
&gettext("Couldn't copy"),
&gettext("Proton Drive isn't connected."),
);
return;
}
let socket = ui.dirs.control_socket();
ui.busy_begin();
clear_selection(ui);
let ui = ui.clone();
glib::spawn_future_local(async move {
let (done, failure) = send_each(&socket, sources, |path| Request::Copy {
path,
new_parent: target.clone(),
})
.await;
ui.busy_end();
reload_listing(&ui);
let place = quoted_place(&target);
match (done, failure) {
(0, Some((message, kind))) => {
toast_failure(&ui, &gettext("Couldn't copy"), &message, kind)
}
(0, None) => {}
(n, Some((message, _))) => toast_error(
&ui,
// Translators: {place} is a quoted folder name or "Proton Drive".
&ngettext_f(
"Copied {n} item to {place}, but not all",
"Copied {n} items to {place}, but not all",
n as u64,
&[("place", &place)],
),
&message,
),
// Translators: {place} is a quoted folder name or "Proton Drive".
(1, None) => toast(&ui, &gettext_f("Copied to {place}", &[("place", &place)])),
(n, None) => toast(
&ui,
// Translators: {place} is a quoted folder name or "Proton Drive".
&ngettext_f(
"Copied {n} item to {place}",
"Copied {n} items to {place}",
n as u64,
&[("place", &place)],
),
),
}
});
}
/// Send one request per source, in order, and count how many succeeded. The
/// first failure is kept for the report.
async fn send_each(
socket: &Path,
sources: Vec<String>,
request: impl Fn(String) -> Request,
) -> (usize, Option<(String, ErrorKind)>) {
let mut done = 0usize;
let mut failure: Option<(String, ErrorKind)> = None;
for path in sources {
let rx = spawn_request(socket.to_path_buf(), request(path));
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => done += 1,
Ok(Ok(Response::Error { message, kind })) => {
failure.get_or_insert((message, kind));
}
_ => {
failure.get_or_insert_with(|| {
(
gettext("The Proton Drive service didn't respond."),
ErrorKind::Internal,
)
});
}
}
}
(done, failure)
}
/// The destination folder for a report: its name in quotes, or "Proton Drive"
/// for My files.
fn quoted_place(target: &str) -> String {
match target.rsplit('/').next() {
// Translators: a folder name in quotes, filled in as {place} in "Moved to {place}".
Some(name) if !name.is_empty() => gettext_f("“{name}”", &[("name", name)]),
_ => "Proton Drive".to_string(),
}
}
/// Move one entry to Trash, through the batch path so it gets the same Undo.
pub(crate) fn trash_entry(ui: &Rc<Ui>, entry: &DirEntry) {
run_bulk_delete(ui, vec![entry.clone()]);
@@ -2978,8 +3122,8 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
#[cfg(test)]
mod tests {
use super::{
MoveTarget, badge_for, hit_location, listing_summary, move_target_allowed,
show_thumbnail_build_progress, sort_entries,
MoveTarget, badge_for, copy_target_allowed, hit_location, listing_summary,
move_target_allowed, show_thumbnail_build_progress, sort_entries,
};
use pdfs_core::config::{FileSort, FilesView};
use pdfs_core::control::{DirEntry, SyncIssue, ThumbnailBuildStatus};
@@ -3067,6 +3211,16 @@ mod tests {
assert!(move_target_allowed(&mixed, ""));
}
#[test]
fn copy_refuses_its_own_subtree_but_not_the_folder_it_is_in() {
let sources = ["Docs/Work".to_string()];
assert!(!copy_target_allowed(&sources, "Docs/Work"));
assert!(!copy_target_allowed(&sources, "Docs/Work/Old"));
assert!(copy_target_allowed(&sources, "Docs"));
assert!(copy_target_allowed(&sources, ""));
assert!(copy_target_allowed(&sources, "Docs/Workshop"));
}
#[test]
fn listing_summary_matches_dolphin_order_and_wording() {
assert_eq!(listing_summary(0, 0, 0), "0 folders, 0 files");
+42 -3
View File
@@ -629,9 +629,17 @@ pub(crate) fn sync_state_label(state: &str) -> String {
/// — so both read "12 of 40" rather than a percentage that could go backwards.
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
match p.phase {
// Before the first pass finishes there is no estimate, so the count would
// be "checked 12 of 12" — worse than saying nothing.
SyncPhase::Scanning if p.total == 0 => gettext("checking for changes…"),
SyncPhase::Scanning if p.total == 0 && p.done == 0 => gettext("checking for changes…"),
// Before the first pass finishes there is no estimate, so "12 of 12" would
// say nothing. The count alone still shows a first scan of a large folder
// moving, which can take many minutes before anything downloads.
// Translators: {n} counts the folders and files checked so far.
SyncPhase::Scanning if p.total == 0 => ngettext_f(
"checking for changes — {n} item checked",
"checking for changes — {n} items checked",
p.done as u64,
&[],
),
// Translators: {done} and {total} count folders and files checked so far.
SyncPhase::Scanning => gettext_f(
"checking for changes — {done} of {total}",
@@ -1296,6 +1304,37 @@ mod tests {
);
}
fn scanning(done: usize, total: usize) -> SyncProgress {
SyncProgress {
phase: SyncPhase::Scanning,
done,
total,
current: String::new(),
}
}
#[test]
fn a_first_scan_counts_what_it_checked() {
// A first sync of a large folder scans for many minutes before anything
// downloads; without a count it reads as stuck (#46).
assert_eq!(
sync_progress_label(&scanning(0, 0)),
"checking for changes…"
);
assert_eq!(
sync_progress_label(&scanning(1, 0)),
"checking for changes — 1 item checked"
);
assert_eq!(
sync_progress_label(&scanning(1234, 0)),
"checking for changes — 1234 items checked"
);
assert_eq!(
sync_progress_label(&scanning(12, 40)),
"checking for changes — 12 of 40"
);
}
#[test]
fn the_restore_picker_names_the_computer_it_restores_from() {
assert!(restore_picker_body(Some("laptop")).contains("“laptop”"));
+51 -1
View File
@@ -1506,8 +1506,13 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
} else {
gettext("Pause Syncing")
};
let ui_pause = ui.clone();
menu.item(&label, move || set_folder_paused(&ui_pause, id, !paused));
let ui = ui.clone();
menu.item(&label, move || set_folder_paused(&ui, id, !paused));
let path = spec.local_path.clone();
menu.item(&gettext("Sync From Scratch…"), move || {
confirm_reset_sync_folder(&ui, id, &path)
});
menu.section();
}
let ui = ui.clone();
@@ -1520,6 +1525,51 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
});
}
/// Ask before syncing a folder from scratch. Nothing is deleted, but files
/// that differ come back as conflict copies, so it is not a click to make by
/// accident.
fn confirm_reset_sync_folder(ui: &Rc<Ui>, id: i64, path: &str) {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Sync From Scratch?"))
// Translators: {path} is a local folder path.
.body(gettext_f("This computer forgets what it synced in {path} before and compares the folder with Proton Drive again. Nothing is deleted: a file on one side only is copied to the other, and a file that differs keeps both versions.", &[("path", path)]))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("reset", &gettext("Sync From Scratch"));
dialog.set_response_appearance("reset", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let win = ui_window(ui);
let ui = ui.clone();
dialog.connect_response(None, move |_, response| {
if response == "reset" {
reset_sync_folder(&ui, id);
}
});
dialog.present(win.as_ref());
}
/// Forget a folder's sync records, then repaint so its row shows the new pass.
fn reset_sync_folder(ui: &Rc<Ui>, id: i64) {
let rx = spawn_request(ui.dirs.control_socket(), Request::ResetSyncFolder { id });
let ui = ui.clone();
glib::spawn_future_local(async move {
let failed = gettext("Couldn't sync the folder from scratch");
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("Syncing folder…")),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
refresh_locations(&ui);
});
}
/// Ask before making a synced folder online only: the switch frees disk space
/// by removing the local copies, which is not something to do by accident.
fn confirm_online_only(ui: &Rc<Ui>, id: i64, path: &str) {
+50 -7
View File
@@ -368,12 +368,19 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
} else {
gettext("File")
});
d.size_row.set_subtitle(&if entry.is_dir {
"—".to_string()
// A folder's size is Proton Drive's count, which takes a request; it lands
// in `load_details_extras`.
if entry.is_dir {
d.size_row.set_title(&gettext("Size on Proton Drive"));
d.size_row.set_subtitle("—");
d.size_row.set_tooltip_text(Some(&gettext(
"Counted by Proton Drive, including items in the trash.",
)));
} else {
human_bytes(entry.size)
});
d.size_row.set_visible(!entry.is_dir);
d.size_row.set_title(&pgettext("column", "Size"));
d.size_row.set_subtitle(&human_bytes(entry.size));
d.size_row.set_tooltip_text(None);
}
d.modified_row
.set_subtitle(&dates::short_date(entry.modified));
let rel = entry_rel(ui, entry);
@@ -426,8 +433,8 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
d.pages.set_visible_child_name("details");
}
/// Fill the Sharing and Versions rows, which need the network. The answers
/// land only if the pane still shows the same entry.
/// Fill the Sharing and Versions rows, and a folder's size, which need the
/// network. The answers land only if the pane still shows the same entry.
fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
let by_uid = entry.path.is_empty() && !entry.uid.is_empty();
let rel = entry_rel(ui, entry);
@@ -453,6 +460,24 @@ fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
});
if entry.is_dir {
let req = if by_uid {
Request::FolderSizeByUid {
uid: entry.uid.clone(),
}
} else {
Request::FolderSize { path: rel }
};
let rx = spawn_request(ui.dirs.control_socket(), req);
let (ui_c, uid) = (ui.clone(), entry.uid.clone());
glib::spawn_future_local(async move {
let text = match rx.recv().await {
Ok(Ok(Response::FolderSize { size, items })) => folder_size_summary(size, items),
_ => gettext("Couldn't load the size"),
};
if details_showing(&ui_c, &uid) {
ui_c.details.details.size_row.set_subtitle(&text);
}
});
return;
}
let req = if by_uid {
@@ -499,6 +524,18 @@ pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
}
}
/// "1.2 GiB · 340 items": what a folder holds, as Proton Drive counts it.
pub(crate) fn folder_size_summary(size: u64, items: u64) -> String {
// Translators: {size} is a size such as "1.2 GiB"; {n} counts the files
// and folders inside a folder.
ngettext_f(
"{size} · {n} item",
"{size} · {n} items",
items,
&[("size", &human_bytes(size))],
)
}
/// "3 versions · latest 2 Sep 2026".
pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
let latest = items.iter().map(|r| r.created).max();
@@ -547,6 +584,12 @@ pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
mod tests {
use super::*;
#[test]
fn folder_size_summary_reads_size_and_item_count() {
assert_eq!(folder_size_summary(0, 0), "0 bytes · 0 items");
assert_eq!(folder_size_summary(2048, 1), "2.0 KiB · 1 item");
}
#[test]
fn sharing_summary_names_people_and_link() {
assert_eq!(sharing_summary(0, false), "Only you");
+233 -19
View File
@@ -51,6 +51,48 @@ pub(crate) fn link_expiry_at(idx: u32, now: i64) -> Option<i64> {
.map(|days| now + days * 86_400)
}
/// The labels of the Expires dropdown on an existing link. When the link has
/// an expiry, the first entry is that expiry, so the dropdown shows what is
/// set and choosing it again changes nothing.
pub(crate) fn link_expiry_choices(current: Option<i64>, now: i64) -> Vec<String> {
let current = current.map(|at| capitalize(&link_expiry_label(at, now)));
current
.into_iter()
.chain(LINK_EXPIRY.iter().map(|(name, _)| gettext(name)))
.collect()
}
/// What choosing entry `idx` of [`link_expiry_choices`] does to the expiry.
pub(crate) fn link_expiry_change(idx: u32, current: Option<i64>, now: i64) -> LinkChange<i64> {
let idx = match (current, idx) {
(Some(_), 0) => return LinkChange::Keep,
(Some(_), idx) => idx - 1,
(None, idx) => idx,
};
match link_expiry_at(idx, now) {
Some(at) => LinkChange::Set(at),
None if current.is_some() => LinkChange::Remove,
None => LinkChange::Keep,
}
}
/// A row with a title and a dropdown of `labels` as its suffix.
fn dropdown_row(title: &str, labels: &[String], selected: u32) -> (adw::ActionRow, gtk4::DropDown) {
let drop = gtk4::DropDown::builder()
.model(&string_list(labels))
.selected(selected)
.valign(gtk4::Align::Center)
.build();
let row = adw::ActionRow::builder().title(title).build();
row.add_suffix(&drop);
(row, drop)
}
/// The roles a public link can grant, in dropdown order.
fn link_roles() -> [String; 2] {
[pgettext("role", "Viewer"), pgettext("role", "Editor")]
}
/// A loose shape check for an email address: one `@` with something before
/// it and a dotted domain after it. The server does the real validation; this
/// only catches typos before the Invite button is pressed.
@@ -237,6 +279,31 @@ impl ShareTarget {
}
}
fn update_link(
&self,
id: String,
role: Option<String>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> Request {
match self {
ShareTarget::Path(path) => Request::UpdatePublicLink {
path: path.clone(),
id,
role,
password,
expires,
},
ShareTarget::Uid(uid) => Request::UpdatePublicLinkByUid {
uid: uid.clone(),
id,
role,
password,
expires,
},
}
}
fn remove_link(&self, id: String) -> Request {
match self {
ShareTarget::Path(path) => Request::RemovePublicLink {
@@ -585,8 +652,8 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
*state.people_rows.borrow_mut() = rows;
}
/// Rebuild the public-link section: an existing link (copy / remove) or a
/// create control.
/// Rebuild the public-link section: an existing link (copy / remove, and its
/// settings) or a create control.
pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) {
for row in state.link_rows.borrow_mut().drain(..) {
state.link_group.remove(&row);
@@ -670,30 +737,19 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
state.link_group.add(&row);
rows.push(row.upcast());
let settings = link_settings_row(state, link);
state.link_group.add(&settings);
rows.push(settings.upcast());
}
None => {
let role_model = string_list(&[pgettext("role", "Viewer"), pgettext("role", "Editor")]);
let role_drop = gtk4::DropDown::builder()
.model(&role_model)
.selected(0)
.valign(gtk4::Align::Center)
.build();
let role_row = adw::ActionRow::builder()
.title(gettext("Link role"))
.build();
role_row.add_suffix(&role_drop);
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), 0);
let pw_row = adw::PasswordEntryRow::builder()
.title(gettext("Password (optional)"))
.build();
let expiry_names: Vec<String> =
LINK_EXPIRY.iter().map(|(name, _)| gettext(name)).collect();
let expiry_drop = gtk4::DropDown::builder()
.model(&string_list(&expiry_names))
.selected(0)
.valign(gtk4::Align::Center)
.build();
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
expiry_row.add_suffix(&expiry_drop);
let (expiry_row, expiry_drop) = dropdown_row(&gettext("Expires"), &expiry_names, 0);
let create = group_button(&gettext("Create Public Link"));
// Enter in the password field creates the link.
@@ -734,6 +790,142 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
*state.link_rows.borrow_mut() = rows;
}
/// The settings of an existing link, collapsed under one row. Each control
/// applies at once, the way a member's role does. While a change is on its way
/// the whole row is insensitive: the daemon fills in what a change keeps from
/// the link as it stands, so two changes in flight could undo each other.
fn link_settings_row(state: &Rc<ShareDialog>, link: &PublicLinkInfo) -> adw::ExpanderRow {
let now = glib::real_time() / 1_000_000;
let expander = adw::ExpanderRow::builder()
.title(gettext("Link settings"))
.subtitle(gettext(
"Change who can edit, the password or the expiry. The link stays the same.",
))
.build();
let selected_role = u32::from(link.role == "editor");
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), selected_role);
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
role_drop.connect_selected_notify(move |d| {
let Some(state) = state_w.upgrade() else {
return;
};
let role = if d.selected() == 1 {
"editor"
} else {
"viewer"
};
let request = state.target.update_link(
id.clone(),
Some(role.into()),
LinkChange::Keep,
LinkChange::Keep,
);
share_dialog_update_link(&state, request, &expander_w);
});
expander.add_row(&role_row);
let current = link.expires;
let (expiry_row, expiry_drop) =
dropdown_row(&gettext("Expires"), &link_expiry_choices(current, now), 0);
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
expiry_drop.connect_selected_notify(move |d| {
let Some(state) = state_w.upgrade() else {
return;
};
let expires = link_expiry_change(d.selected(), current, glib::real_time() / 1_000_000);
if expires == LinkChange::Keep {
return;
}
let request = state
.target
.update_link(id.clone(), None, LinkChange::Keep, expires);
share_dialog_update_link(&state, request, &expander_w);
});
expander.add_row(&expiry_row);
let pw_row = adw::PasswordEntryRow::builder()
.title(if link.has_password {
gettext("New password")
} else {
gettext("Add a password")
})
.show_apply_button(true)
.build();
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
pw_row.connect_apply(move |row| {
let Some(state) = state_w.upgrade() else {
return;
};
let password = row.text().to_string();
if password.is_empty() {
return;
}
let request = state.target.update_link(
id.clone(),
None,
LinkChange::Set(password),
LinkChange::Keep,
);
share_dialog_update_link(&state, request, &expander_w);
});
expander.add_row(&pw_row);
if link.has_password {
let remove = gtk4::Button::builder()
.label(gettext("Remove"))
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let row = adw::ActionRow::builder()
.title(gettext("Remove the password"))
.subtitle(gettext(
"Anyone with the link can open it without a password.",
))
.build();
row.add_suffix(&remove);
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
remove.connect_clicked(move |_| {
let Some(state) = state_w.upgrade() else {
return;
};
let request =
state
.target
.update_link(id.clone(), None, LinkChange::Remove, LinkChange::Keep);
share_dialog_update_link(&state, request, &expander_w);
});
expander.add_row(&row);
}
expander
}
/// Send a link change, then reload the dialog: on success to show the link as
/// it now is, on failure to put the controls back to what the link still is.
fn share_dialog_update_link(
state: &Rc<ShareDialog>,
request: Request,
settings: &adw::ExpanderRow,
) {
settings.set_sensitive(false);
let rx = spawn_request(state.ui.dirs.control_socket(), request);
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::PublicLink { .. })) => toast(&state.ui, &gettext("Link updated")),
Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, &gettext("Couldn't change the link"), &message)
}
_ => toast_error(
&state.ui,
&gettext("Couldn't change the link"),
&gettext("The Proton Drive service didn't respond."),
),
}
share_dialog_reload(&state);
});
}
/// Create a public link, then reload the dialog so the copy/remove controls
/// replace the create form (and the freshly minted URL is shown).
pub(crate) fn share_dialog_create_link(
@@ -863,6 +1055,28 @@ mod tests {
assert_eq!(link_expiry_at(99, 0), None);
}
#[test]
fn choosing_an_expiry_keeps_removes_or_sets_it() {
let now = 1_000;
// No expiry yet: the choices are just Never and the lifetimes.
assert_eq!(link_expiry_choices(None, now).len(), LINK_EXPIRY.len());
assert_eq!(link_expiry_change(0, None, now), LinkChange::Keep);
assert_eq!(
link_expiry_change(2, None, now),
LinkChange::Set(now + 7 * 86_400)
);
// An expiry set: it comes first, then Never and the lifetimes.
let at = Some(2_000_000_000);
assert_eq!(link_expiry_choices(at, now).len(), LINK_EXPIRY.len() + 1);
assert_eq!(link_expiry_change(0, at, now), LinkChange::Keep);
assert_eq!(link_expiry_change(1, at, now), LinkChange::Remove);
assert_eq!(
link_expiry_change(2, at, now),
LinkChange::Set(now + 86_400)
);
}
#[test]
fn past_expiry_reads_expired() {
assert_eq!(link_expiry_label(10, 20), "expired");
+63
View File
@@ -100,6 +100,8 @@ a live account since.
| Entry | Title |
|---|---|
| [B256](#b256--pdfs-copy-right-after-a-save-copies-the-old-version) | `pdfs copy` right after a save copies the old version |
| [B255](#b255--a-synced-folder-whose-files-are-all-gone-here-is-stuck-for-good) | A synced folder whose files are all gone here is stuck for good |
| [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives |
| [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked |
| [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile |
@@ -357,6 +359,60 @@ a live account since.
Newest first.
### B256 — `pdfs copy` right after a save copies the old version
**Status:** Fixed, not verified live
**Found:** 2026-10-26, the simulation `a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes`
failing on most runs. The feature was not released yet.
**Where:** `release` in `crates/pdfs-fuse/src/filesystem.rs`; `copy_to` in
`crates/pdfs-fuse/src/lib.rs`; `has_pending_work_under` in `crates/pdfs-fuse/src/relocate.rs`
**Repro:** Save a file in the mount and copy it, or its folder, with `pdfs copy` straight away.
The copy goes ahead and Drive copies the version before the save.
**Cause.** `close(2)` returns before the kernel sends `release`. `release` takes the write handle
out of the open writes and then queues the revision, which takes a few milliseconds. A copy
checked in between found the edit in neither place. A folder copy looked only at the queue, so
it also missed a file still open with unsaved writes.
**Fix.** `release` keeps the inode in `State::releasing` until the revision is queued. A copy
refuses a file or folder with unsaved writes, or a release still queueing, at it or below it.
A move checks the same set.
**Test:** The simulation above passed 6 of 6 runs after the fix.
---
### B255 — A synced folder whose files are all gone here is stuck for good
**Status:** Fixed, not verified live
**Found:** 2026-10-26, issue #46: a folder restored from another computer never downloaded.
**Where:** `push_pass` in `crates/pdfs-fuse/src/sync.rs`; `reset_sync_folder` in
`crates/pdfs-fuse/src/devices.rs`
**Repro:** Sync a folder in mirror mode, then delete all of its files on this computer while
the folder is paused. Resume it. Every pass stops with `every one of the N synced paths is
missing locally`, the folder stays in error, and a switch to online only waits for good.
**Cause.** The wipe guard is right to stop a pass that would trash the whole folder on Drive.
But nothing got the folder out of that state. A switch to on-demand waits for a pass that ends
`idle`, and its push pass hit the same guard. The baseline that made the empty folder read as
"delete everything" could only be cleared by removing the folder from sync. How the reporter's
folder lost its files is not known yet.
**Fix.** The push pass for a switch to on-demand forgets the baseline rows of a local copy that
is gone in full, instead of trashing them on Drive, so the switch goes ahead and the mount shows
what Drive has. **Sync From Scratch…** in the app and `pdfs sync reset ID` clear a mirror
folder's baseline, so the next pass compares both sides as on the first sync: one-sided files
are copied across, files that differ keep both versions, and nothing is deleted. Restore now
logs why it skipped a folder.
**Test:** The simulations `a_folder_whose_local_copy_is_gone_still_goes_on_demand` (which
failed in exactly the reported state before the fix) and
`a_folder_whose_local_copy_is_gone_downloads_again_after_a_reset`.
---
### B254 — An edit in the same second as a new upload never arrives
**Status:** Fixed, not verified live
@@ -5877,6 +5933,13 @@ Only the three namespace ops were strictly required (the other four silence
themselves once the kernel caches `ENOSYS`), but implementing all seven keeps the
refusal explicit and documented at the call site rather than inherited.
`copy_file_range` has since been implemented: the daemon copies a range whose bytes are on this
machine (an open write handle, a queued write, the cache) into the destination's scratch file,
and answers `EOPNOTSUPP` for anything it would have to download. Unlike `ENOSYS`, that errno makes
the kernel copy that one call through read and write and keeps asking the daemon next time.
A warm `cp` of a 256 MiB file inside the mount went from about 100 MiB/s to about 450 MiB/s
(sim, debug build; `a_copy_inside_the_mount_measures_its_speed`).
**Verified:** full live acceptance run after the fix — 19 pass / 1 skip, the
capability diff identical to the pre-fix run, and **0** `Not Implemented` lines
in the journal across the whole run (was 17, 10 of them `link`).
+44
View File
@@ -11,6 +11,10 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased]
## [3.6.0] — 2026-10-26
Schema: **44**, unchanged.
### Added
- **`pdfs diagnose` names the key that keeps My Files closed.** When you are signed in and the
service does not answer, a new **Keys** section asks Proton which account and address keys
@@ -23,8 +27,30 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
the app asks GitHub once a day and shows a banner with a **Download** button when a newer
version is out. It is off by default, and not offered for packages that a package manager
updates (COPR, the AUR, Nix) (#42).
- **Copy files and folders on Proton Drive**, without downloading or uploading them. In My files,
right-click an item and choose **Make a Copy** to copy it next to itself, or **Copy To…** to pick
another folder; **Copy To…** also works on a selection. From a terminal:
`pdfs copy ~/ProtonDrive/report.pdf ~/ProtonDrive/Archive`. A folder is copied with everything
in it, and a name already taken gets a " (1)" suffix. A file or folder with changes that have
not synced yet is refused until they have.
- **Change a public link without getting a new one.** In **Share…**, open **Link settings** under
the link to switch between Viewer and Editor, set, change or remove the password, and change or
remove the expiry. The address stays the same, so people who already have the link keep it.
From a terminal: `pdfs public-link update PATH ID [--role] [--password | --no-password]
[--expires EPOCH | --no-expiry]`. `pdfs members` now also shows when a link expires.
- **Folder sizes.** **Details** shows how much a folder holds and how many items are in it, as
Proton Drive counts it, including items in the trash. From a terminal: `pdfs du PATH`.
- **Sync a folder from scratch.** In a synced folder's menu on **Sync → Folders**, **Sync From
Scratch…** forgets what was synced before and compares the folder with Proton Drive again. A
file on one side only is copied to the other, and a file that differs keeps both versions.
Nothing is deleted. From the terminal: `pdfs sync reset ID`.
### Changed
- **Copying a file inside the mount is faster** when the computer already has the file, for
example with `cp` or a file manager: the copy is made on this computer, about four times
faster for a 256 MiB file, instead of going through the mount page by page. A file that is
only on Proton Drive is copied as before. The copy is then uploaded as usual.
- **Reorganised documentation, now also on the
[wiki](https://github.com/narrrl/proton-drive-linux/wiki).** A new
[Getting started](https://github.com/narrrl/proton-drive-linux/wiki/Getting-Started) page and
@@ -33,6 +59,24 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
Installation puts the COPR repository first for Fedora and explains updating and uninstalling
for each kind of package. Each release publishes `docs/` to the wiki.
### Fixed
- **A synced folder whose files were all gone from this computer no longer gets stuck** (#46).
The sync still refuses to delete everything on Proton Drive, but a switch to online only now
goes ahead and shows what Proton Drive has, instead of waiting for good. To download the
files again instead, sync the folder from scratch. Restoring folders also logs why it
skipped a folder.
- **The first sync of a folder shows how far its scan got** (#46). Before anything downloads,
the first pass reads every item on Proton Drive, which takes many minutes for a large folder.
The folder showed only "checking for changes…" all that time, and `pdfs sync list` only
`scanning…`, which looked stuck. Both now count the items checked so far.
- **Saved public links show the name of what they point at**, in the app and in `pdfs bookmarks`,
instead of "Shared link". For a link with a custom password, the address no longer has the custom
password stuck to its end, so it matches the link the web shows and the page asks for the
password.
## [3.5.0] — 2026-10-16
Schema: **44**. Queued changes get an index by kind and name, and each synced file remembers the
+5 -1
View File
@@ -58,6 +58,8 @@ browser, complete it, then press Enter.
| `pdfs mkdir PARENT NAME` | Create a folder. |
| `pdfs rename PATH NEW_NAME` | Rename a file or folder. |
| `pdfs move PATH... NEW_PARENT` | Move one or more files or folders into another folder. The two may be in different locations (My files, an on-demand or a mirrored synced folder); the move happens on Proton Drive, without downloading or uploading anything. |
| `pdfs copy PATH... NEW_PARENT` | Copy one or more files or folders into another folder in My files. The copy happens on Proton Drive, without downloading or uploading anything; a name already taken gets a " (1)" suffix. Changes that have not synced yet must sync first. |
| `pdfs du PATH` | Show how much a folder holds on Proton Drive and how many items are in it. The size includes items in the trash. |
| `pdfs rm PATH` | Move to the Trash. |
| `pdfs upload SOURCES... [-t FOLDER]` | Upload local files and folders (recursively) in the background. Follow with `pdfs transfers`. |
| `pdfs refresh [TARGET] [--full]` | Forget a cached listing so the next read fetches it again. `TARGET` is a folder, `trash` or `photos`; `--full` with `photos` re-reads every photo's metadata. |
@@ -82,6 +84,7 @@ commands are for scripts and for cases where the mount is not available.
| `pdfs sync rm ID [--delete-remote]` | Stop syncing. Local files stay; `--delete-remote` also removes the copy on Proton Drive. |
| `pdfs sync mode ID mirror\|ondemand` | Switch between a full local copy (`mirror`) and online only (`ondemand`). |
| `pdfs sync now [ID]` | Start a sync pass now, for all folders or one. |
| `pdfs sync reset ID` | Forget what a synced folder synced before and compare it with Proton Drive again. A file on one side only is copied to the other, and a file that differs keeps both versions. Nothing is deleted. |
| `pdfs sync pause [--for 1h] [--folder ID]` | Pause uploads and synced folders. Reading files keeps working. `--for` ends the pause by itself (`30m`, `1h`, `2d`). |
| `pdfs sync resume [--folder ID]` | Resume after a pause. |
| `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. |
@@ -133,10 +136,11 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description |
|---|---|
| `pdfs share PATH EMAILS... [--role viewer\|editor\|admin] [--message TEXT]` | Invite people to a file or folder. |
| `pdfs members PATH` | List members, pending invitations and the public link. |
| `pdfs members PATH` | List members, pending invitations and the public link, with the link's id and expiry. |
| `pdfs share-role PATH ID KIND ROLE` | Change a member's or invitation's role. |
| `pdfs unshare PATH [ID KIND]` | Stop sharing, or remove one member or invitation. |
| `pdfs public-link create PATH [--role] [--password] [--expires EPOCH]` | Create a public link. `--expires` takes Unix seconds. |
| `pdfs public-link update PATH ID [--role] [--password TEXT \| --no-password] [--expires EPOCH \| --no-expiry]` | Change a public link's role, password or expiry. The URL stays the same, and an option you leave out is kept. |
| `pdfs public-link remove PATH ID` | Remove a public link. |
| `pdfs shared` | List what you share. |
| `pdfs shared-with-me [UID]` | List what others share with you, or the contents of one shared folder. |
+8 -1
View File
@@ -59,7 +59,14 @@ folder off instead.
The **My files** page shows the same files without going through the folder. It has a grid and
a list view, sorting, thumbnails (including camera RAW previews), multi-select, and drag and drop
to upload. **Details** (Alt+Enter) shows the size, dates, sharing and version history of the
selected item.
selected item. For a folder, the size and the number of items in it come from Proton Drive and
include items in the trash; `pdfs du PATH` prints the same.
Right-click an item for more. **Make a Copy** copies it into the same folder, and **Copy To…**
copies it, or a whole selection, into another folder in My files. The copy is made on Proton
Drive, so nothing is downloaded or uploaded, and a name already taken gets a " (1)" suffix. An
item with changes that have not reached Proton Drive yet can be copied once they have. From a
terminal: `pdfs copy PATH... NEW_PARENT`.
## Trash
+1 -1
View File
@@ -102,7 +102,7 @@ That URL follows `main`. To stay on releases, pin the input to a release tag ins
the tag to update:
```nix
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.5.0";
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.6.0";
```
Each release tag is built with Nix before the release is published.
+8 -1
View File
@@ -33,6 +33,12 @@ In **Share…**, create a public link. Anyone with the link can open the item. Y
link with a password and let it expire on a date. From a terminal, use `pdfs public-link create`
and `pdfs public-link remove`.
To change a link later, open **Link settings** under it. You can switch it between Viewer and
Editor, set, change or remove its password, and change or remove its expiry. The address stays
the same, so everyone who has the link keeps it. From a terminal, use `pdfs public-link update`;
`pdfs members` shows the link's id. A link made long ago by an older Proton app may not be
changeable; remove it and create a new one instead.
## See what you share
**Shared by me** lists everything you share. Filter it by links, by people, or by expired links.
@@ -47,6 +53,7 @@ a viewer are read-only.
- **Invitations** wait in a banner at the top of the page until you accept or reject them. From a
terminal: `pdfs invitations list`, then `accept ID` or `reject ID`.
- **Saved public links** have their own view. `pdfs bookmarks` manages them.
- **Saved public links** have their own view, listed by the name of what they point at.
`pdfs bookmarks` manages them.
- **Leave…** a share you no longer need, or run `pdfs leave UID` with the ID from
`pdfs shared-with-me`.
+8
View File
@@ -76,6 +76,14 @@ again when they move into a synced one.
Proton Drive. To delete the copy on Proton Drive as well, tick **Also delete from Proton Drive**,
or add `--delete-remote`.
## Sync a folder from scratch
**Sync From Scratch…** in the folder's menu, or `pdfs sync reset ID`, makes this computer forget
what it synced in the folder before and compare it with Proton Drive again, as on the first sync.
A file on one side only is copied to the other, and a file that differs keeps both versions.
Nothing is deleted. Use it when a folder's files went missing on this computer and the sync
refuses to go on (see [Troubleshooting](TROUBLESHOOTING.md#a-synced-folder-stays-in-error-and-nothing-downloads)).
## Excluding files with `.pdfsignore`
Synced folders skip build output, dependency folders and editor leftovers. Two sets of rules
+16
View File
@@ -232,6 +232,22 @@ Check that:
`pdfs sync now` starts a sync pass at once.
### A synced folder stays in error and nothing downloads
The log says `every one of the N synced paths is missing locally; refusing to trash them on
Drive`. The folder synced before, and now none of its files are on this computer. The sync
stops so that it does not delete them on Proton Drive too. Check that the folder is the right
one and that its disk is mounted. To get the files back, choose **Sync From Scratch…** in the
folder's menu on **Sync → Folders**, or run:
```bash
pdfs sync list # find the folder's ID
pdfs sync reset 1
```
This downloads what Proton Drive has and uploads what only this computer has. Nothing is deleted.
If you deleted the files on purpose, delete them on Proton Drive too, or stop syncing the folder.
### Files named `(sync-conflict …)` appeared
The file changed in two places at once. Nothing was lost. See
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=3.5.0
pkgver=3.6.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64')
+1 -1
View File
@@ -10,7 +10,7 @@
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
%global _lto_cflags %{nil}
%{!?version: %global version 3.5.0}
%{!?version: %global version 3.6.0}
%global forge https://github.com/narrrl/proton-drive-linux
# Files come from the checkout, or from the unpacked tag archive.
%global src %{?git_dir}%{!?git_dir:.}
+540 -370
View File
File diff suppressed because it is too large. Load diff
+538 -370
View File
File diff suppressed because it is too large. Load diff
+539 -370
View File
File diff suppressed because it is too large. Load diff
+540 -370
View File
File diff suppressed because it is too large. Load diff
+540 -370
View File
File diff suppressed because it is too large. Load diff
+540 -370
View File
File diff suppressed because it is too large. Load diff
+542 -378
View File
File diff suppressed because it is too large. Load diff
+545 -370
View File
File diff suppressed because it is too large. Load diff
+539 -370
View File
File diff suppressed because it is too large. Load diff