Compare commits

...
Author SHA1 Message Date
nvrl ad648ab5cc feat(cli,core): shell completions with live values from the service
`pdfs completions <shell>` prints a completion script for bash, zsh, fish,
elvish or PowerShell, built on clap_complete's dynamic engine: on Tab the
shell calls `pdfs` back, which completes commands and options and also the
values the running service knows, each with a description: paths in My
files, synced-folder and queue ids, computers, trash, albums, items shared
with me, invitations, bookmarks, conflict copies, and a file's versions or
a share's members and public link once its path is typed.

A completer that gets no answer from the service within two seconds offers
nothing rather than hanging the prompt. Listings that ask Proton are kept
for 30 s in $XDG_RUNTIME_DIR (0600), so repeated Tabs do not wait again.
Completion is triggered by PDFS_COMPLETE rather than clap's generic
COMPLETE, because the same binary runs the daemon.

Share roles are now checked by the CLI before the request. The package
recipes install the bash, zsh and fish scripts via packaging/completions.sh.
2026-11-12 11:47:29 +01:00
nvrl 2b95708260 chore(release): 3.8.0 2026-11-12 10:22:04 +01:00
nvrl c78bb529bc fix(fuse): bound memory held by thumbnail decodes
Every image upload decodes the whole file to build its thumbnail, and
nothing limited how many decodes ran at once. With a queue of large
photos all 16 drain workers decoded together; 100-megapixel JPEGs held
about 9 GiB, swapped the machine, and the watchdog killed the daemon on
every restart.

Each decode now takes a share of a process-wide 512 MiB budget, sized
from the encoded length and the header's pixel count. Phone photos still
decode side by side; a photo larger than the budget waits and runs alone.

Records B280.
2026-11-12 00:55:19 +01:00
nvrl 8d824af656 fix(fuse,cli): recover the mount after a watchdog kill
A daemon killed by the watchdog left its FUSE mounts dead. A dead mount
answers ENOTCONN, which create_dir_all took for an absent directory, so
every restart failed with EEXIST and retried forever without reaching the
code that clears stale mounts. mount_once now goes through
prepare_mountpoint, which clears a dead mount first.

Startup on a large account (70 s hydrating 48k nodes) plus the supervisor's
first ping one full period later outlasted WatchdogSec=120, so a restart
that did mount was aborted again. The daemon now asks for a 10-minute
watchdog before startup, the supervisor pings on its first tick, and the
unit's own timeout is restored once the control socket answers.

Records B279.
2026-11-12 00:38:50 +01:00
nvrl c77c548d7e Merge pull request #77 from danielebrox/feat/sync-attach-my-files
feat(sync): attach local folders to existing My files folders
2026-11-06 22:58:16 +01:00
nvrl d4a14a17a8 fix(sync): isolate My files attachments
Schema 45 separates attachments from device backups so stopping sync or
changing mode cannot trash My files folders or evict their local data.
Exclude attachments from device profiles and validate device restores.

Verify attached roots before each pass and cover both local views,
concurrent edits, removal guards, and root loss across daemon restarts.

Refs #77
2026-11-05 17:11:57 +01:00
danielebrox 5ca0272f15 feat(sync): attach local folders to existing My files folders 2026-11-02 19:23:49 +01:00
Nils Pukropp 800705ccb0 chore(aur): update packages to 3.7.0 2026-11-02 15:16:31 +00:00
45 changed files with 2386 additions and 111 deletions

No files matched your search

+1
View File
@@ -160,6 +160,7 @@ jobs:
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
po/build.sh deb/usr/share/locale
packaging/completions.sh target/release/pdfs deb/usr/share
# Stops the running daemon before removal, so the mount stops mirroring deletes
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
Generated
+26 -4
View File
@@ -754,6 +754,18 @@ dependencies = [
"strsim",
]
[[package]]
name = "clap_complete"
version = "4.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f"
dependencies = [
"clap",
"clap_lex",
"is_executable",
"shlex",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
@@ -2551,6 +2563,15 @@ version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "is_executable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -3520,10 +3541,11 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pdfs-cli"
version = "3.7.0"
version = "3.8.0"
dependencies = [
"anyhow",
"clap",
"clap_complete",
"pdfs-core",
"pdfs-fuse",
"rpassword",
@@ -3536,7 +3558,7 @@ dependencies = [
[[package]]
name = "pdfs-core"
version = "3.7.0"
version = "3.8.0"
dependencies = [
"async-trait",
"base64 0.23.1",
@@ -3563,7 +3585,7 @@ dependencies = [
[[package]]
name = "pdfs-fuse"
version = "3.7.0"
version = "3.8.0"
dependencies = [
"async-trait",
"base64 0.23.1",
@@ -3585,7 +3607,7 @@ dependencies = [
[[package]]
name = "pdfs-gui"
version = "3.7.0"
version = "3.8.0"
dependencies = [
"anyhow",
"async-channel",
+4 -1
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.7.0"
version = "3.8.0"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
@@ -56,6 +56,9 @@ rusqlite = { version = "0.41", features = ["bundled"] }
keyring-core = "1"
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
clap = { version = "4", features = ["derive"] }
# `unstable-dynamic`: Tab calls `pdfs` back, so arguments complete from the
# running service. Only `pdfs-cli/src/complete.rs` uses it.
clap_complete = { version = "4.6", features = ["unstable-dynamic"] }
rpassword = "7"
async-trait = "0.1"
futures = "0.3"
+1 -1
View File
@@ -125,7 +125,7 @@ The documentation is in [`docs/`](docs/README.md) and on the
| [Getting started](docs/GETTING_STARTED.md) | From installing to your first synced folder |
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
| [FAQ](docs/FAQ.md) | Short answers to common questions |
| [Command-line reference](docs/CLI.md) | Every `pdfs` command and scripting with `--json` |
| [Command-line reference](docs/CLI.md) | Every `pdfs` command, Tab completion and scripting with `--json` |
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
+1
View File
@@ -21,6 +21,7 @@ anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
clap_complete.workspace = true
rpassword.workspace = true
serde_json.workspace = true
zeroize.workspace = true
+731
View File
@@ -0,0 +1,731 @@
//! Shell completion for `pdfs`.
//!
//! Built on clap_complete's dynamic engine: the script a shell loads is a thin
//! stub that calls `pdfs` back on every Tab with the words typed so far, and
//! `pdfs` answers from its own clap definition. That lets an argument complete
//! from the running service as well as from the command tree — `pdfs sync rm
//! <Tab>` offers the synced-folder ids with their paths, `pdfs pin Doc<Tab>`
//! walks My files.
//!
//! Every value that comes from the service is best effort. With no service, a
//! slow one or an error, a completer offers nothing: a Tab that hangs or prints
//! an error into the prompt is worse than one that does nothing.
//!
//! The engine sits behind clap_complete's `unstable-dynamic` feature, so all of
//! this crate's use of it is kept to this module.
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::CommandFactory;
use clap_complete::engine::{CompletionCandidate, PathCompleter, ValueCompleter};
use clap_complete::env::{Bash, CompleteEnv, Elvish, EnvCompleter, Fish, Powershell, Zsh};
use pdfs_core::control::{
AlbumInfo, BookmarkInfo, ConflictInfo, DeviceInfo, DirEntry, InvitationInfo, PendingOpInfo,
PublicLinkInfo, Request, Response, RevisionInfo, ShareEntry, ShareEntryKind, SyncFolderInfo,
};
/// The environment variable that turns a run of `pdfs` into a completion
/// request. Not clap's generic `COMPLETE`: the same binary is the systemd
/// daemon, and an unrelated `COMPLETE` in its environment must not stop it.
pub const VAR: &str = "PDFS_COMPLETE";
/// How long a completer waits for the service. A Tab is interactive, so a
/// listing the service cannot give soon is better skipped. Long enough for the
/// listings that ask Proton (computers take over a second).
const SERVICE_TIMEOUT: Duration = Duration::from_secs(2);
/// How long a listing that asks Proton is reused. Every Tab is a new `pdfs`, so
/// without it `pdfs devices rm <Tab>`, a letter and another Tab would wait for
/// Proton twice. A uid that went away in the meantime only fails the command.
const CACHE_TTL: Duration = Duration::from_secs(30);
/// Answer a completion request and exit, if this run is one. Must run before
/// anything writes to stdout.
pub fn handle_request() {
CompleteEnv::with_factory(crate::Cli::command)
.var(VAR)
.complete();
}
/// The shells `pdfs completions` writes a script for.
#[derive(Clone, Copy, clap::ValueEnum)]
pub enum ShellArg {
Bash,
Zsh,
Fish,
Elvish,
Powershell,
}
impl ShellArg {
fn completer(self) -> &'static dyn EnvCompleter {
match self {
ShellArg::Bash => &Bash,
ShellArg::Zsh => &Zsh,
ShellArg::Fish => &Fish,
ShellArg::Elvish => &Elvish,
ShellArg::Powershell => &Powershell,
}
}
}
/// Write the script that registers `pdfs` completion with `shell`.
///
/// The script calls `pdfs` from `PATH`, not this binary's path, so one written
/// at packaging time keeps working once installed.
pub fn write_script(shell: ShellArg, out: &mut dyn std::io::Write) -> std::io::Result<()> {
shell
.completer()
.write_registration(VAR, "pdfs", "pdfs", "pdfs", out)?;
if let ShellArg::Zsh = shell {
// Installed as `_pdfs` in site-functions, the script is autoloaded as
// that function's body on the first Tab. The body above only defines
// and registers the completer, so this line also runs it, or the first
// Tab would complete nothing. Sourced, `funcstack` names no `_pdfs`.
writeln!(
out,
"if [ \"$funcstack[1]\" = \"_pdfs\" ]; then\n _clap_dynamic_completer_pdfs \"$@\"\nfi"
)?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Talking to the service
/// One request to the service, or `None` if there is no answer worth showing.
fn ask(request: Request) -> Option<Response> {
let socket = pdfs_core::config::AppDirs::new().ok()?.control_socket();
ask_at(&socket, &request)
}
fn ask_at(socket: &Path, request: &Request) -> Option<Response> {
match pdfs_core::control::send_with_timeout(socket, request, SERVICE_TIMEOUT) {
Ok(Response::Error { .. }) | Err(_) => None,
Ok(response) => Some(response),
}
}
/// [`ask`] for a listing that comes from Proton rather than the local
/// database, reusing an answer younger than [`CACHE_TTL`]. `key` names the
/// listing's cache file.
fn ask_cached(key: &str, request: Request) -> Option<Response> {
let file = cache_file(key);
if let Some(hit) = file.as_deref().and_then(read_fresh) {
return Some(hit);
}
let response = ask(request)?;
if let Some(file) = &file {
write_cache(file, &response);
}
Some(response)
}
/// In `$XDG_RUNTIME_DIR`: private to the user, in memory and gone at logout,
/// which suits names from the account. With no runtime dir, nothing is cached.
fn cache_file(key: &str) -> Option<PathBuf> {
let runtime = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
Some(
runtime
.join("proton-drive-linux")
.join(format!("completion-{key}.json")),
)
}
fn read_fresh(file: &Path) -> Option<Response> {
let age = std::fs::metadata(file)
.ok()?
.modified()
.ok()?
.elapsed()
.ok()?;
if age > CACHE_TTL {
return None;
}
serde_json::from_slice(&std::fs::read(file).ok()?).ok()
}
/// Best effort: a cache that cannot be written only costs the next Tab a wait.
fn write_cache(file: &Path, response: &Response) {
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
let (Some(dir), Ok(json)) = (file.parent(), serde_json::to_vec(response)) else {
return;
};
let _ = std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir);
let staging = file.with_extension(format!("{}.tmp", std::process::id()));
let written = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&staging)
.and_then(|mut f| std::io::Write::write_all(&mut f, &json));
if written.is_err() || std::fs::rename(&staging, file).is_err() {
let _ = std::fs::remove_file(&staging);
}
}
fn candidate(value: impl Into<OsString>, help: impl Into<String>) -> CompletionCandidate {
let help = help.into();
let candidate = CompletionCandidate::new(value);
if help.is_empty() {
candidate
} else {
candidate.help(Some(help.into()))
}
}
// ---------------------------------------------------------------------------
// Paths in Proton Drive
/// A path in the mount: an absolute path completes from the local filesystem
/// (through the mount), anything else from the service's listing of My files,
/// which is what the commands resolve a relative path against.
pub fn drive_path(current: &OsStr) -> Vec<CompletionCandidate> {
complete_drive(current, false)
}
/// [`drive_path`] for an argument that must be a folder.
pub fn drive_dir(current: &OsStr) -> Vec<CompletionCandidate> {
complete_drive(current, true)
}
/// `pdfs refresh`: a folder, or one of the two named caches.
pub fn refresh_target(current: &OsStr) -> Vec<CompletionCandidate> {
let typed = current.to_string_lossy();
let mut candidates: Vec<_> = [
("trash", "the trash listing"),
("photos", "the photos timeline"),
]
.into_iter()
.filter(|(name, _)| name.starts_with(typed.as_ref()))
.map(|(name, help)| candidate(name, help))
.collect();
candidates.extend(drive_dir(current));
candidates
}
fn complete_drive(current: &OsStr, dirs_only: bool) -> Vec<CompletionCandidate> {
let Some(word) = current.to_str() else {
return Vec::new();
};
if word.starts_with('/') || word.starts_with('~') {
let local = if dirs_only {
PathCompleter::dir()
} else {
PathCompleter::any()
};
return local.complete(current);
}
let (parent, prefix) = split_drive_word(word);
let listing = Request::ListDir {
path: parent.trim_end_matches('/').to_owned(),
};
match ask(listing) {
Some(Response::Entries { entries }) => {
drive_candidates(parent, prefix, &entries, dirs_only)
}
_ => Vec::new(),
}
}
/// Split a typed drive path into the folder to list (with its trailing `/`)
/// and the start of the name being completed in it.
fn split_drive_word(word: &str) -> (&str, &str) {
match word.rfind('/') {
Some(at) => word.split_at(at + 1),
None => ("", word),
}
}
/// The entries of `parent` whose name starts with `prefix`. A folder ends in
/// `/`, which tells every shell not to add a space after it, so the next Tab
/// goes on into it.
fn drive_candidates(
parent: &str,
prefix: &str,
entries: &[DirEntry],
dirs_only: bool,
) -> Vec<CompletionCandidate> {
entries
.iter()
.filter(|e| e.name.starts_with(prefix) && (e.is_dir || !dirs_only))
.map(|e| {
let slash = if e.is_dir { "/" } else { "" };
CompletionCandidate::new(format!("{parent}{}{slash}", e.name))
.hide(e.name.starts_with('.') && !prefix.starts_with('.'))
})
.collect()
}
// ---------------------------------------------------------------------------
// Ids and uids from the service's listings
pub fn sync_folder_id() -> Vec<CompletionCandidate> {
match ask(Request::ListSyncFolders) {
Some(Response::SyncFolders { items }) => sync_folder_candidates(&items),
_ => Vec::new(),
}
}
fn sync_folder_candidates(items: &[SyncFolderInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|f| candidate(f.id.to_string(), format!("{} ({})", f.local_path, f.mode)))
.collect()
}
pub fn queue_id() -> Vec<CompletionCandidate> {
match ask(Request::ListPendingOps) {
Some(Response::PendingOps { items }) => queue_candidates(&items),
_ => Vec::new(),
}
}
fn queue_candidates(items: &[PendingOpInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|op| candidate(op.id.to_string(), format!("{} {}", op.kind, op.path)))
.collect()
}
pub fn device_uid() -> Vec<CompletionCandidate> {
match ask_cached("devices", Request::ListDevices) {
Some(Response::Devices { items }) => device_candidates(&items),
_ => Vec::new(),
}
}
fn device_candidates(items: &[DeviceInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|d| {
let this = if d.this_device {
" (this computer)"
} else {
""
};
candidate(d.uid.as_str(), format!("{}{this}", d.name))
})
.collect()
}
pub fn trash_uid() -> Vec<CompletionCandidate> {
match ask_cached("trash", Request::ListTrash) {
Some(Response::Entries { entries }) => trash_candidates(&entries),
_ => Vec::new(),
}
}
fn trash_candidates(entries: &[DirEntry]) -> Vec<CompletionCandidate> {
entries
.iter()
.map(|e| {
let help = match e.trashed_from.as_deref() {
Some(from) if !from.is_empty() => format!("{} (from {from})", e.name),
_ => e.name.clone(),
};
candidate(e.uid.as_str(), help)
})
.collect()
}
pub fn album_uid() -> Vec<CompletionCandidate> {
match ask_cached("albums", Request::PhotoAlbums) {
Some(Response::Albums { items, .. }) => album_candidates(&items),
_ => Vec::new(),
}
}
fn album_candidates(items: &[AlbumInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|a| candidate(a.uid.as_str(), a.name.as_str()))
.collect()
}
pub fn shared_uid() -> Vec<CompletionCandidate> {
match ask_cached("shared-with-me", Request::ListSharedWithMe) {
Some(Response::Entries { entries }) => shared_candidates(&entries),
_ => Vec::new(),
}
}
fn shared_candidates(entries: &[DirEntry]) -> Vec<CompletionCandidate> {
entries
.iter()
.map(|e| {
let help = if e.shared_by.is_empty() {
e.name.clone()
} else {
format!("{} (from {})", e.name, e.shared_by)
};
candidate(e.uid.as_str(), help)
})
.collect()
}
pub fn invitation_id() -> Vec<CompletionCandidate> {
match ask_cached("invitations", Request::ListInvitations) {
Some(Response::Invitations { items }) => invitation_candidates(&items),
_ => Vec::new(),
}
}
fn invitation_candidates(items: &[InvitationInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|i| {
let help = match i.name.as_deref() {
Some(name) => format!("{name} from {}", i.inviter_email),
None => format!("from {}", i.inviter_email),
};
candidate(i.id.as_str(), help)
})
.collect()
}
pub fn bookmark_token() -> Vec<CompletionCandidate> {
match ask_cached("bookmarks", Request::ListBookmarks) {
Some(Response::Bookmarks { items }) => bookmark_candidates(&items),
_ => Vec::new(),
}
}
fn bookmark_candidates(items: &[BookmarkInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|b| candidate(b.token.as_str(), b.name.as_deref().unwrap_or(&b.url)))
.collect()
}
pub fn conflict_path() -> Vec<CompletionCandidate> {
match ask(Request::ListConflicts) {
Some(Response::Conflicts { items }) => conflict_candidates(&items),
_ => Vec::new(),
}
}
fn conflict_candidates(items: &[ConflictInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|c| candidate(c.path.as_str(), format!("copy of {}", c.original_path)))
.collect()
}
// ---------------------------------------------------------------------------
// Ids that belong to the path typed before them
/// A version of the file named earlier on the line (`pdfs versions restore
/// <path> <Tab>`).
pub fn version_id(current: &OsStr) -> Vec<CompletionCandidate> {
let Some(path) = typed_path() else {
return Vec::new();
};
match ask(Request::ListRevisions { path }) {
Some(Response::Revisions { items }) => starting_with(current, revision_candidates(&items)),
_ => Vec::new(),
}
}
fn revision_candidates(items: &[RevisionInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|r| {
let current = if r.is_active { ", current" } else { "" };
let size = r.claimed_size.unwrap_or(r.size_on_storage).max(0);
candidate(
r.id.as_str(),
format!(
"{}, {}{current}",
crate::format_epoch_utc(r.created),
crate::human_bytes(size as u64)
),
)
})
.collect()
}
/// A member or invitation on the share of the path typed earlier on the line.
pub fn share_entry_id(current: &OsStr) -> Vec<CompletionCandidate> {
match share_of_typed_path() {
Some((entries, _)) => starting_with(current, share_entry_candidates(&entries)),
None => Vec::new(),
}
}
fn share_entry_candidates(entries: &[ShareEntry]) -> Vec<CompletionCandidate> {
entries
.iter()
.map(|e| {
// The kind is the next argument, so the help names it in the words
// that argument takes.
let kind = match e.kind {
ShareEntryKind::Member => "member",
ShareEntryKind::ProtonInvite => "proton",
ShareEntryKind::ExternalInvite => "external",
};
candidate(e.id.as_str(), format!("{} ({}, {kind})", e.email, e.role))
})
.collect()
}
/// The public link of the path typed earlier on the line.
pub fn public_link_id(current: &OsStr) -> Vec<CompletionCandidate> {
match share_of_typed_path() {
Some((_, Some(link))) => starting_with(current, vec![public_link_candidate(&link)]),
_ => Vec::new(),
}
}
/// Described without its URL: the fragment is the link's password, and a Tab
/// menu is on screen for anyone watching.
fn public_link_candidate(link: &PublicLinkInfo) -> CompletionCandidate {
let help = match link.expires {
Some(expires) => format!(
"{} link, expires {}",
link.role,
crate::format_epoch_utc(expires)
),
None => format!("{} link", link.role),
};
candidate(link.id.as_str(), help)
}
fn share_of_typed_path() -> Option<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
match ask(Request::ListShare {
path: typed_path()?,
})? {
Response::Share { entries, link } => Some((entries, link)),
_ => None,
}
}
/// [`clap_complete::engine::ArgValueCompleter`] candidates are not filtered by
/// the engine, unlike [`clap_complete::engine::ArgValueCandidates`].
fn starting_with(
current: &OsStr,
candidates: Vec<CompletionCandidate>,
) -> Vec<CompletionCandidate> {
let typed = current.to_string_lossy();
candidates
.into_iter()
.filter(|c| c.get_value().to_string_lossy().starts_with(typed.as_ref()))
.collect()
}
/// The `path` argument of the command being completed, as the service takes
/// it. A completer only receives the word under the cursor, so this reads the
/// line from the process arguments, where the shell's stub passes it.
fn typed_path() -> Option<String> {
let args: Vec<OsString> = std::env::args_os().collect();
let index = std::env::var("_CLAP_COMPLETE_INDEX")
.ok()
.and_then(|i| i.parse().ok());
path_in(words_before_cursor(&args, index))
}
/// The words of the line before the one being completed. The stub runs
/// `pdfs -- <words>`; bash, zsh and elvish say which word the cursor is on,
/// fish and PowerShell pass the line cut at the cursor, so it is the last.
fn words_before_cursor(args: &[OsString], index: Option<usize>) -> Vec<OsString> {
let start = args
.iter()
.position(|a| a == "--")
.map_or(args.len(), |at| at + 1);
let mut words = args[start..].to_vec();
let index = index.unwrap_or(words.len().saturating_sub(1));
words.truncate(index);
words
}
/// Parse `words` as far as they go and take the innermost command's `path`.
fn path_in(words: Vec<OsString>) -> Option<String> {
let matches = crate::Cli::command()
.ignore_errors(true)
.try_get_matches_from(words)
.ok()?;
let mut leaf = &matches;
while let Some((_, sub)) = leaf.subcommand() {
leaf = sub;
}
let path = leaf.try_get_one::<PathBuf>("path").ok().flatten()?;
crate::path_arg(path).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn values(candidates: &[CompletionCandidate]) -> Vec<String> {
candidates
.iter()
.map(|c| c.get_value().to_string_lossy().into_owned())
.collect()
}
fn help(candidate: &CompletionCandidate) -> String {
candidate
.get_help()
.map(ToString::to_string)
.unwrap_or_default()
}
fn entry(name: &str, is_dir: bool) -> DirEntry {
serde_json::from_value(serde_json::json!({
"name": name,
"is_dir": is_dir,
"size": 0,
"modified": 0,
"pinned": false,
"uid": format!("vol~{name}"),
}))
.unwrap()
}
fn words(line: &[&str]) -> Vec<OsString> {
line.iter().map(OsString::from).collect()
}
#[test]
fn the_command_definition_is_valid() {
crate::Cli::command().debug_assert();
}
#[test]
fn completions_script_is_written_for_every_shell() {
for shell in [
ShellArg::Bash,
ShellArg::Zsh,
ShellArg::Fish,
ShellArg::Elvish,
ShellArg::Powershell,
] {
let mut script = Vec::new();
write_script(shell, &mut script).unwrap();
let script = String::from_utf8(script).unwrap();
assert!(script.contains(VAR), "{script}");
assert!(script.contains("pdfs"), "{script}");
}
}
#[test]
fn drive_path_splits_parent_and_prefix() {
assert_eq!(split_drive_word(""), ("", ""));
assert_eq!(split_drive_word("Doc"), ("", "Doc"));
assert_eq!(split_drive_word("Documents/"), ("Documents/", ""));
assert_eq!(split_drive_word("Documents/Rep"), ("Documents/", "Rep"));
assert_eq!(split_drive_word("a/b/c"), ("a/b/", "c"));
}
#[test]
fn drive_dirs_end_in_a_slash() {
let entries = [
entry("Reports", true),
entry("Receipt.pdf", false),
entry("Other", true),
];
let all = drive_candidates("Documents/", "Re", &entries, false);
assert_eq!(
values(&all),
["Documents/Reports/", "Documents/Receipt.pdf"]
);
let dirs = drive_candidates("Documents/", "", &entries, true);
assert_eq!(values(&dirs), ["Documents/Reports/", "Documents/Other/"]);
}
#[test]
fn hidden_drive_entries_need_a_typed_dot() {
let entries = [entry(".git", true)];
assert!(drive_candidates("", "", &entries, false)[0].is_hide_set());
assert!(!drive_candidates("", ".", &entries, false)[0].is_hide_set());
}
#[test]
fn sync_folder_ids_complete_with_their_local_path() {
let folders: Vec<SyncFolderInfo> = serde_json::from_value(serde_json::json!([{
"id": 3,
"local_path": "/home/me/Documents",
"remote_uid": "vol~docs",
"mode": "mirror",
"state": "idle",
"last_sync": 0,
}]))
.unwrap();
let candidates = sync_folder_candidates(&folders);
assert_eq!(values(&candidates), ["3"]);
assert_eq!(help(&candidates[0]), "/home/me/Documents (mirror)");
}
#[test]
fn trash_uids_complete_with_their_name() {
let mut from_docs = entry("a.pdf", false);
from_docs.trashed_from = Some("Documents".into());
let candidates = trash_candidates(&[from_docs, entry("b", true)]);
assert_eq!(values(&candidates), ["vol~a.pdf", "vol~b"]);
assert_eq!(help(&candidates[0]), "a.pdf (from Documents)");
assert_eq!(help(&candidates[1]), "b");
}
#[test]
fn completer_without_a_service_offers_nothing() {
let dir = std::env::temp_dir().join(format!("pdfs-complete-{}", std::process::id()));
let socket = dir.join("control.sock");
assert!(ask_at(&socket, &Request::ListSyncFolders).is_none());
}
#[test]
fn cached_listing_is_reused_until_it_is_stale() {
let dir = std::env::temp_dir().join(format!("pdfs-complete-cache-{}", std::process::id()));
let file = dir.join("completion-devices.json");
write_cache(&file, &Response::Devices { items: Vec::new() });
assert!(matches!(read_fresh(&file), Some(Response::Devices { .. })));
let mode = std::os::unix::fs::PermissionsExt::mode(
&std::fs::metadata(&file).unwrap().permissions(),
);
assert_eq!(mode & 0o777, 0o600);
let stale = std::time::SystemTime::now() - CACHE_TTL - Duration::from_secs(1);
std::fs::File::options()
.write(true)
.open(&file)
.unwrap()
.set_modified(stale)
.unwrap();
assert!(read_fresh(&file).is_none());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn words_before_cursor_stop_at_the_cursor() {
let args = words(&[
"pdfs", "--", "pdfs", "versions", "restore", "a.txt", "", "x",
]);
assert_eq!(
words_before_cursor(&args, Some(4)),
words(&["pdfs", "versions", "restore", "a.txt"])
);
// fish: the line arrives cut at the cursor, the current word last.
let fish = words(&["pdfs", "--", "pdfs", "versions", "restore", "a.txt", "ab"]);
assert_eq!(
words_before_cursor(&fish, None),
words(&["pdfs", "versions", "restore", "a.txt"])
);
}
#[test]
fn completing_words_finds_the_path_before_the_id() {
let line = words(&["pdfs", "versions", "restore", "Documents/a.txt"]);
assert_eq!(path_in(line).as_deref(), Some("Documents/a.txt"));
let line = words(&["pdfs", "--json", "share-role", "Shared/x"]);
assert_eq!(path_in(line).as_deref(), Some("Shared/x"));
let line = words(&["pdfs", "public-link", "update", "--role", "editor", "a"]);
assert_eq!(path_in(line).as_deref(), Some("a"));
assert_eq!(path_in(words(&["pdfs", "versions", "restore"])), None);
}
}
+148 -22
View File
@@ -8,7 +8,8 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, anyhow, bail};
use clap::{Parser, Subcommand};
use clap::{Parser, Subcommand, ValueHint};
use clap_complete::engine::{ArgValueCandidates, ArgValueCompleter};
use pdfs_core::auth;
use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs;
@@ -20,6 +21,7 @@ use pdfs_core::control::{
use pdfs_core::db::Db;
use pdfs_core::service;
mod complete;
mod journal;
/// `println!` for this binary, shadowing std's, which panics when stdout is
@@ -165,6 +167,7 @@ enum Command {
/// Mount Proton Drive at the given (or default) path. Blocks until unmounted.
Mount {
/// Mountpoint; defaults to ~/ProtonDrive.
#[arg(value_hint = ValueHint::DirPath)]
mountpoint: Option<PathBuf>,
},
/// Run the auto-mount daemon: wait for login, mount, and keep it mounted
@@ -172,6 +175,7 @@ enum Command {
/// don't invoke it by hand.
Daemon {
/// Mountpoint; defaults to ~/ProtonDrive.
#[arg(value_hint = ValueHint::DirPath)]
mountpoint: Option<PathBuf>,
},
/// Keep a file or folder on this device (download + cache it). A folder's
@@ -179,11 +183,13 @@ enum Command {
/// Needs a running mount.
Pin {
/// File or folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
},
/// Stop keeping a file or folder on this device, evicting its cached content.
Unpin {
/// File or folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
},
/// List files and folders pinned to this device.
@@ -191,6 +197,7 @@ enum Command {
/// List a directory via the running daemon (in-app browser backend).
Ls {
/// Directory path, inside the mountpoint or relative to it. Root if omitted.
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
path: Option<PathBuf>,
},
/// List the photos timeline (newest first) via the running daemon.
@@ -224,7 +231,7 @@ enum Command {
/// so an interrupted import can simply be run again.
ImportGooglePhotos {
/// The export's `.zip` files (e.g. `takeout-*.zip`).
#[arg(required = true)]
#[arg(required = true, value_hint = ValueHint::FilePath)]
archives: Vec<PathBuf>,
/// Report what would be imported without uploading anything.
#[arg(long)]
@@ -277,6 +284,7 @@ enum Command {
/// List the photos in one album (newest capture first).
Album {
/// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String,
/// Max photos to list.
#[arg(long, default_value_t = 50)]
@@ -293,6 +301,7 @@ enum Command {
/// Rename a photo album.
RenameAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String,
/// New name.
name: String,
@@ -300,6 +309,7 @@ enum Command {
/// Delete a photo album. Its photos stay in the timeline.
DeleteAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String,
/// Also delete photos that exist only in this album (saved from a
/// shared album); without it the server refuses to delete such an album.
@@ -309,6 +319,7 @@ enum Command {
/// Add photos to an album.
AddToAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String,
/// Photo node uids (from `pdfs photos`).
#[arg(required = true)]
@@ -317,6 +328,7 @@ enum Command {
/// Take photos out of an album. They stay in the timeline.
RemoveFromAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String,
/// Photo node uids (from `pdfs album <uid>`).
#[arg(required = true)]
@@ -335,13 +347,14 @@ enum Command {
#[arg(long, default_value_t = 50)]
limit: usize,
/// Only search below this folder (under the mountpoint).
#[arg(long = "in", value_name = "FOLDER")]
#[arg(long = "in", value_name = "FOLDER", add = ArgValueCompleter::new(complete::drive_dir))]
scope: Option<PathBuf>,
},
/// Rename a file or folder via the running daemon.
Rename {
/// File/folder path, inside My files or an on-demand synced folder, or
/// relative to the mountpoint.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// New name (a single path component).
new_name: String,
@@ -355,7 +368,7 @@ enum Command {
Move {
/// Files/folders to move, then the destination folder: each inside a
/// location, or relative to the mountpoint.
#[arg(required = true, num_args = 2.., value_name = "PATH")]
#[arg(required = true, num_args = 2.., value_name = "PATH", add = ArgValueCompleter::new(complete::drive_path))]
paths: Vec<PathBuf>,
},
/// Copy files or folders into another folder in My files via the running
@@ -367,7 +380,7 @@ enum Command {
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")]
#[arg(required = true, num_args = 2.., value_name = "PATH", add = ArgValueCompleter::new(complete::drive_path))]
paths: Vec<PathBuf>,
},
/// Show how much a folder holds on Proton Drive.
@@ -376,18 +389,21 @@ enum Command {
/// encrypted bytes on storage and includes items in the trash.
Du {
/// Folder path, inside My files or relative to the mountpoint.
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
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
/// relative to the mountpoint.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
},
/// Create a folder via the running daemon.
Mkdir {
/// Parent folder path, inside My files or an on-demand synced folder,
/// or relative to the mountpoint.
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
parent: PathBuf,
/// New folder name (a single path component).
name: String,
@@ -397,10 +413,10 @@ enum Command {
/// background; watch progress with `pdfs transfers`.
Upload {
/// Local files and/or folders to upload.
#[arg(required = true)]
#[arg(required = true, value_hint = ValueHint::AnyPath)]
sources: Vec<PathBuf>,
/// Destination folder, inside the mountpoint or relative to it.
#[arg(short = 't', long = "to", default_value = ".")]
#[arg(short = 't', long = "to", default_value = ".", add = ArgValueCompleter::new(complete::drive_dir))]
parent: PathBuf,
},
/// Show the daemon's in-flight transfers (active uploads/downloads).
@@ -415,13 +431,13 @@ enum Command {
/// Restore trashed items, by uid, to the folders they were trashed from.
Restore {
/// Node uids (`volume~link`), as printed by `pdfs trash`.
#[arg(required = true)]
#[arg(required = true, add = ArgValueCandidates::new(complete::trash_uid))]
uids: Vec<String>,
},
/// Permanently delete trashed items by uid. This cannot be undone.
DeleteForever {
/// Node uids (`volume~link`), as printed by `pdfs trash`.
#[arg(required = true)]
#[arg(required = true, add = ArgValueCandidates::new(complete::trash_uid))]
uids: Vec<String>,
},
/// Permanently delete everything in the trash. This cannot be undone.
@@ -433,6 +449,7 @@ enum Command {
Refresh {
/// What to refresh: a folder path (inside the mountpoint or relative to
/// it), `trash`, or `photos`. The mount root if omitted.
#[arg(add = ArgValueCompleter::new(complete::refresh_target))]
target: Option<String>,
/// With `photos`: read every photo's metadata again instead of only what
/// changed. Minutes on a large library; for a gallery that disagrees with
@@ -459,12 +476,13 @@ enum Command {
/// Share a file or folder with Proton and/or external email addresses.
Share {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// One or more invitee emails (Proton or external, auto-detected).
#[arg(required = true)]
emails: Vec<String>,
/// Role to grant: viewer, editor or admin.
#[arg(long, default_value = "viewer")]
#[arg(long, default_value = "viewer", value_parser = ["viewer", "editor", "admin"], ignore_case = true)]
role: String,
/// Optional message included in the invitation email.
#[arg(long)]
@@ -473,17 +491,21 @@ enum Command {
/// List a node's members, pending invitations and public link.
Members {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
},
/// Change the role of a member or pending invitation on a node's share.
ShareRole {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Entry id (membership or invitation id), from `pdfs members`.
#[arg(add = ArgValueCompleter::new(complete::share_entry_id))]
id: String,
/// Entry kind: member, proton or external.
kind: ShareKindArg,
/// New role: viewer, editor or admin.
#[arg(value_parser = ["viewer", "editor", "admin"], ignore_case = true)]
role: String,
},
/// Stop sharing a node, or remove one member or pending invitation.
@@ -492,9 +514,10 @@ enum Command {
/// member. With an id and kind (from `pdfs members`), removes that entry.
Unshare {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Entry id (membership or invitation id), from `pdfs members`.
#[arg(requires = "kind")]
#[arg(requires = "kind", add = ArgValueCompleter::new(complete::share_entry_id))]
id: Option<String>,
/// Entry kind: member, proton or external.
kind: Option<ShareKindArg>,
@@ -515,18 +538,22 @@ enum Command {
SharedWithMe {
/// Folder uid to list into, in `volume~link` form (from a previous run).
/// Omit for the top level.
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
uid: Option<String>,
},
/// Download a file shared with me by its uid (from `pdfs shared-with-me`).
SharedGet {
/// Node uid in `volume~link` form.
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
uid: String,
/// Where to write it. Omit to leave it in the cache and print the path.
#[arg(value_hint = ValueHint::AnyPath)]
dest: Option<PathBuf>,
},
/// Leave a shared node by its uid (from `pdfs shared-with-me`).
Leave {
/// Node uid in `volume~link` form.
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
uid: String,
},
/// Manage invitations addressed to me.
@@ -557,6 +584,17 @@ enum Command {
/// Runs without a daemon on purpose: the state worth diagnosing is usually
/// the state where the daemon will not start.
Diagnose,
/// Print the shell completion script for bash, zsh, fish, elvish or
/// PowerShell.
///
/// Besides commands and options, it completes values from the running
/// service: paths in My files, synced-folder and queue ids, computers,
/// trash, albums, shares and versions. Load it with, for example,
/// `pdfs completions fish | source` or `source <(pdfs completions bash)`.
Completions {
/// The shell to write the script for.
shell: complete::ShellArg,
},
}
#[derive(Subcommand)]
@@ -604,6 +642,7 @@ enum VersionCmd {
/// List a file's versions, newest first.
List {
/// File path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
},
/// Make an earlier version the file's current content.
@@ -612,25 +651,32 @@ enum VersionCmd {
/// moment to show up in the mount.
Restore {
/// File path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Version id, from `pdfs versions list`.
#[arg(add = ArgValueCompleter::new(complete::version_id))]
id: String,
},
/// Write an earlier version to a local file, leaving the file untouched.
Save {
/// File path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Version id, from `pdfs versions list`.
#[arg(add = ArgValueCompleter::new(complete::version_id))]
id: String,
/// Where to write it. Must not already exist.
#[arg(value_hint = ValueHint::AnyPath)]
dest: PathBuf,
},
/// Permanently delete an earlier version. The content is unrecoverable, and
/// the version that is currently active cannot be deleted.
Rm {
/// File path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Version id, from `pdfs versions list`.
#[arg(add = ArgValueCompleter::new(complete::version_id))]
id: String,
},
}
@@ -640,13 +686,21 @@ enum DeviceCmd {
/// List registered devices.
List,
/// Rename a device by uid.
Rename { uid: String, name: String },
Rename {
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: String,
name: String,
},
/// Delete (deregister) a device by uid.
Rm { uid: String },
Rm {
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: String,
},
/// Adopt an existing device as this machine's, so a hostname change or a
/// reinstall re-attaches to it instead of registering a duplicate.
Adopt {
/// Device uid (from `devices list`). Omit with `--clear`.
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: Option<String>,
/// Drop the adoption and go back to matching by hostname.
#[arg(long, conflicts_with = "uid")]
@@ -657,6 +711,7 @@ enum DeviceCmd {
/// here. Both computers keep them in step.
Join {
/// Device uid (from `devices list`).
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: String,
/// Skip the confirmation and accept every proposed local path.
#[arg(long)]
@@ -666,6 +721,7 @@ enum DeviceCmd {
/// them here. The other computer stays registered, without those folders.
Migrate {
/// Device uid (from `devices list`).
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: String,
/// Skip the confirmation and accept every proposed local path.
#[arg(long)]
@@ -678,6 +734,7 @@ enum ConflictCmd {
/// Decide which side of a conflict to keep. Whatever is removed goes to Trash.
Resolve {
/// The conflict copy, absolute or relative to the mountpoint.
#[arg(add = ArgValueCandidates::new(complete::conflict_path))]
path: PathBuf,
/// `original` trashes the copy; `copy` trashes the original and gives the
/// copy its name; `both` renames the copy to `--name`.
@@ -694,13 +751,25 @@ enum SyncCmd {
/// Add a local folder to this machine's device and upload its contents.
Add {
/// Local directory to sync.
#[arg(value_hint = ValueHint::DirPath)]
path: PathBuf,
},
/// Sync a real local folder with an existing folder in My files.
/// The first pass preserves local-only files and keeps differing versions.
Attach {
/// Existing local directory; it remains a real local directory.
#[arg(value_hint = ValueHint::DirPath)]
path: PathBuf,
/// Existing folder in My files, relative to the cloud mount or inside it.
#[arg(long, add = ArgValueCompleter::new(complete::drive_dir))]
to: PathBuf,
},
/// List this machine's synced folders.
List,
/// Remove a synced folder by id.
Rm {
/// Synced-folder id (from `sync list`).
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
id: i64,
/// Also delete the folder's copy in Proton Drive.
#[arg(long)]
@@ -709,6 +778,7 @@ enum SyncCmd {
/// Force a sync pass now (all folders, or one by id).
Now {
/// Optional synced-folder id; omit to reconcile every folder.
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
id: Option<i64>,
},
/// Forget what a synced folder synced before and compare it with Proton
@@ -716,6 +786,7 @@ enum SyncCmd {
/// that differs keeps both versions. Nothing is deleted.
Reset {
/// Synced-folder id (from `sync list`).
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
id: i64,
},
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
@@ -725,13 +796,13 @@ enum SyncCmd {
#[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")]
duration: Option<u64>,
/// Pause only this mirror folder (id from `sync list`) until resumed.
#[arg(long)]
#[arg(long, add = ArgValueCandidates::new(complete::sync_folder_id))]
folder: Option<i64>,
},
/// Resume syncing after `pdfs sync pause`.
Resume {
/// Resume only this folder, paused with `sync pause --folder`.
#[arg(long)]
#[arg(long, add = ArgValueCandidates::new(complete::sync_folder_id))]
folder: Option<i64>,
},
/// Cap upload and download speed, or show the caps with no options.
@@ -750,8 +821,10 @@ enum SyncCmd {
/// Save the content of a queued upload (by id from `sync queue`) to a file.
Export {
/// Queue id.
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
id: i64,
/// Where to save it: a new file, or an existing folder to save it in.
#[arg(value_hint = ValueHint::AnyPath)]
dest: PathBuf,
},
/// Drop a queued change (by id from `sync queue`) and undo it here: a new file
@@ -759,6 +832,7 @@ enum SyncCmd {
/// content is lost unless saved with `sync export` first.
Discard {
/// Queue id.
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
id: i64,
/// Do not ask first.
#[arg(long)]
@@ -767,11 +841,13 @@ enum SyncCmd {
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
Retry {
/// Queue id; omit to retry every failed operation.
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
id: Option<i64>,
},
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
Mode {
/// Synced-folder id (from `sync list`).
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
id: i64,
/// `mirror` (full local copy) or `ondemand` (FUSE mount, reclaims disk).
#[arg(value_parser = ["mirror", "ondemand"])]
@@ -785,7 +861,7 @@ enum SyncCmd {
yes: bool,
/// Restore another computer's folders instead, by device uid (from
/// `pdfs devices list`).
#[arg(long)]
#[arg(long, add = ArgValueCandidates::new(complete::device_uid))]
device: Option<String>,
},
}
@@ -795,9 +871,10 @@ enum PublicLinkCmd {
/// Create a public link on a node.
Create {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Role: viewer or editor.
#[arg(long, default_value = "viewer")]
#[arg(long, default_value = "viewer", value_parser = ["viewer", "editor"], ignore_case = true)]
role: String,
/// Optional custom password protecting the link.
#[arg(long)]
@@ -810,11 +887,13 @@ enum PublicLinkCmd {
/// given is kept.
Update {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Public-link id, from `pdfs members`.
#[arg(add = ArgValueCompleter::new(complete::public_link_id))]
id: String,
/// New role: viewer or editor.
#[arg(long)]
#[arg(long, value_parser = ["viewer", "editor"], ignore_case = true)]
role: Option<String>,
/// New custom password protecting the link.
#[arg(long, conflicts_with = "no_password")]
@@ -832,8 +911,10 @@ enum PublicLinkCmd {
/// Remove a node's public link.
Remove {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Public-link id, from `pdfs members`.
#[arg(add = ArgValueCompleter::new(complete::public_link_id))]
id: String,
},
}
@@ -843,9 +924,15 @@ enum InvitationCmd {
/// List invitations addressed to me.
List,
/// Accept an invitation by id.
Accept { id: String },
Accept {
#[arg(add = ArgValueCandidates::new(complete::invitation_id))]
id: String,
},
/// Reject an invitation by id.
Reject { id: String },
Reject {
#[arg(add = ArgValueCandidates::new(complete::invitation_id))]
id: String,
},
}
#[derive(Subcommand)]
@@ -861,7 +948,10 @@ enum BookmarkCmd {
password: Option<String>,
},
/// Remove a saved bookmark by its token.
Rm { token: String },
Rm {
#[arg(add = ArgValueCandidates::new(complete::bookmark_token))]
token: String,
},
}
/// Render a daemon failure for a terminal.
@@ -881,6 +971,9 @@ fn cli_error(kind: ErrorKind, message: &str) -> String {
}
fn main() -> Result<()> {
// First: a completion request answers on stdout and exits, so nothing may
// write there before it.
complete::handle_request();
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
// `pgp` logs a benign WARN when it re-serializes a Proton secret-key
// packet with a longer (but equivalent) length encoding than the
@@ -1021,6 +1114,7 @@ fn main() -> Result<()> {
Command::Activity { limit } => cmd_activity(limit),
Command::Quota => cmd_quota(),
Command::Cache { action } => cmd_cache(action),
Command::Completions { shell } => cmd_completions(shell),
Command::Diagnose => cmd_diagnose(),
}
}
@@ -1116,6 +1210,22 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
.to_string();
ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)?
}
SyncCmd::Attach { path, to } => {
let abs = std::fs::canonicalize(&path)
.with_context(|| format!("resolve {}", path.display()))?;
let local_path = abs
.to_str()
.ok_or_else(|| anyhow!("path is not valid UTF-8"))?
.to_owned();
let remote_path = to
.to_str()
.ok_or_else(|| anyhow!("remote path is not valid UTF-8"))?
.to_owned();
ok_or_bail(control_request(CtlRequest::AttachSyncFolder {
local_path,
remote_path,
})?)?
}
SyncCmd::List => {
let response = control_request(CtlRequest::ListSyncFolders)?;
if emit_json(&response)? {
@@ -2294,6 +2404,9 @@ fn cmd_locations() -> Result<()> {
MountKind::Device { sync_folder_id } => {
format!("Device folder {sync_folder_id}")
}
MountKind::Attached { sync_folder_id } => {
format!("My files mirror {sync_folder_id}")
}
MountKind::Shared { .. } => "Shared folder".to_string(),
};
let mode = match location.pending_mode {
@@ -2492,7 +2605,10 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
// An explicit `--mountpoint` wins; otherwise honor the Settings-page choice
// (config), falling back to the default location.
let mountpoint = mountpoint.unwrap_or_else(|| dirs.resolved_mountpoint(&config));
std::fs::create_dir_all(&mountpoint)
// Hydrating a large account and remounting its folders can outlast the
// unit's watchdog before the supervisor sends its first ping (B279).
pdfs_fuse::stretch_watchdog_for_startup();
pdfs_fuse::prepare_mountpoint(&mountpoint)
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
let username = auth::load().map(|s| s.username).unwrap_or_default();
@@ -3775,6 +3891,15 @@ fn writable_probe(dir: &Path) -> std::io::Result<()> {
result.and(remove)
}
/// Print the completion script for `shell`. Touches no account state, so
/// packaging can run it at build time.
fn cmd_completions(shell: complete::ShellArg) -> Result<()> {
let mut script = Vec::new();
complete::write_script(shell, &mut script)?;
print!("{}", String::from_utf8_lossy(&script));
Ok(())
}
/// Self-check the installation, reporting each finding and exiting non-zero if
/// any check failed.
///
@@ -4268,6 +4393,7 @@ mod diagnose_tests {
#[test]
fn a_synced_folder_in_error_is_reported() {
let folder = |id: i64, state: &str| pdfs_core::control::SyncFolderInfo {
kind: "device".into(),
id,
local_path: format!("/home/u/f{id}"),
remote_uid: "vol~link".into(),
+26 -4
View File
@@ -469,6 +469,13 @@ pub enum Request {
/// Add a local folder to this machine's device, uploading its tree and
/// registering the device on first use. Replies with [`Response::Ok`].
AddSyncFolder { local_path: String },
/// Keep a real local directory in step with an existing folder in My files.
/// First sync unions one-sided files and preserves differing versions.
/// Replies with [`Response::Ok`]; no device backup is created.
AttachSyncFolder {
local_path: String,
remote_path: String,
},
/// List this device's synced folders. Replies with [`Response::SyncFolders`].
ListSyncFolders,
/// Remove a synced folder by id; `delete_remote` also trashes its cloud copy.
@@ -985,14 +992,17 @@ impl SyncIssue {
}
}
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
/// One device backup or attached My files mirror (in [`Response::SyncFolders`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SyncFolderInfo {
/// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`].
pub id: i64,
/// `device` backup or `attached` My files mirror. Older daemons only had backups.
#[serde(default = "default_sync_folder_kind")]
pub kind: String,
/// Absolute local folder path.
pub local_path: String,
/// The uid of the folder's remote root under the device root.
/// The uid of the remote root under the device root or My files.
pub remote_uid: String,
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
pub mode: String,
@@ -1015,6 +1025,10 @@ pub struct SyncFolderInfo {
pub paused: bool,
}
fn default_sync_folder_kind() -> String {
"device".into()
}
/// A folder under this machine's device offered for restore (in
/// [`Response::RestorableFolders`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -2391,9 +2405,17 @@ impl Request {
///
/// Shared by the CLI and GUI so both speak the wire format identically.
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
send_with_timeout(socket, req, req.read_timeout())
}
/// [`send`] with a caller-chosen limit on how long the daemon may take to
/// answer, which also bounds the write. For a caller that must never keep a
/// person waiting, such as shell completion, where a slow answer is worth less
/// than none.
pub fn send_with_timeout(socket: &Path, req: &Request, timeout: Duration) -> Result<Response> {
let mut stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(req.read_timeout()))?;
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout.min(WRITE_TIMEOUT)))?;
let mut line = serde_json::to_vec(req)?;
line.push(b'\n');
stream.write_all(&line)?;
+78 -1
View File
@@ -11,7 +11,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 44;
pub(super) const SCHEMA_VERSION: i64 = 45;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -467,6 +467,29 @@ impl Db {
tx.execute_batch(MIGRATION_V44)?;
}
}
if current < 45 {
let has_folders: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_folder'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_kind: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('sync_folder') WHERE name = 'kind'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_folders && !has_kind {
tx.execute_batch(MIGRATION_V45_FOLDERS)?;
let has_mounts: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'mount'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_mounts {
tx.execute_batch(MIGRATION_V45_MOUNTS)?;
}
}
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -1382,3 +1405,57 @@ CREATE INDEX IF NOT EXISTS pending_op_kind_name ON pending_op(kind, name);
const MIGRATION_V44: &str = "
ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT;
";
/// Schema v45: distinguish device backups from My files attachments. Existing
/// released rows are device backups. Attachments cannot evict their local tree.
const MIGRATION_V45_FOLDERS: &str = "
ALTER TABLE sync_folder ADD COLUMN kind TEXT NOT NULL DEFAULT 'device'
CHECK (kind IN ('device', 'attached'));
CREATE TRIGGER sync_folder_attached_insert
BEFORE INSERT ON sync_folder
WHEN NEW.kind = 'attached' AND (NEW.mode != 'mirror' OR NEW.pending_mode IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'attached folders only support mirror mode'); END;
CREATE TRIGGER sync_folder_attached_update
BEFORE UPDATE ON sync_folder
WHEN NEW.kind = 'attached' AND (NEW.mode != 'mirror' OR NEW.pending_mode IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'attached folders only support mirror mode'); END;
";
const MIGRATION_V45_MOUNTS: &str = "
DROP TRIGGER mount_sync_folder_insert;
DROP INDEX mount_myfiles;
DROP INDEX mount_device;
DROP INDEX mount_shared;
ALTER TABLE mount RENAME TO mount_v44;
CREATE TABLE mount (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('myfiles', 'device', 'attached', 'shared')),
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
share_root_uid TEXT,
local_path TEXT NOT NULL DEFAULT '',
root_uid TEXT NOT NULL DEFAULT '',
root_share_id TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'unknown',
access TEXT NOT NULL DEFAULT 'rw' CHECK (access IN ('rw', 'ro')),
CHECK (
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
OR (kind IN ('device', 'attached') AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
OR (kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
)
);
INSERT INTO mount SELECT * FROM mount_v44;
DROP TABLE mount_v44;
CREATE UNIQUE INDEX mount_myfiles ON mount(kind) WHERE kind = 'myfiles';
CREATE UNIQUE INDEX mount_device ON mount(sync_folder_id) WHERE kind IN ('device', 'attached');
CREATE UNIQUE INDEX mount_shared ON mount(share_root_uid) WHERE kind = 'shared';
CREATE TRIGGER mount_sync_folder_insert
AFTER INSERT ON sync_folder
BEGIN
INSERT INTO mount (kind, sync_folder_id) VALUES (NEW.kind, NEW.id);
END;
CREATE TRIGGER mount_sync_folder_kind_update
AFTER UPDATE OF kind ON sync_folder
BEGIN
UPDATE mount SET kind = NEW.kind WHERE sync_folder_id = NEW.id;
END;
";
+14 -9
View File
@@ -1,6 +1,6 @@
//! Unified mount/location presentation rows.
//!
//! A device row stores only its `sync_folder` identity. Its path, remote root,
//! A device or attached row stores only its `sync_folder` identity. Its path, remote root,
//! mode, state, pending mode, and last-sync timestamp are selected from
//! `sync_folder`, which remains the authoritative sync-engine table.
@@ -72,7 +72,7 @@ impl Db {
)? > 0)
}
/// Every local location, with device fields joined from `sync_folder`.
/// Every local location, with synced-folder fields joined from `sync_folder`.
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
let conn = self.read();
let mut stmt = conn.prepare(
@@ -81,14 +81,14 @@ impl Db {
m.kind,
m.sync_folder_id,
m.share_root_uid,
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END,
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END,
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END,
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END,
CASE WHEN m.kind IN ('device', 'attached') THEN sf.local_path ELSE m.local_path END,
CASE WHEN m.kind IN ('device', 'attached') THEN sf.remote_uid ELSE m.root_uid END,
CASE WHEN m.kind IN ('device', 'attached') THEN sf.remote_share_id ELSE m.root_share_id END,
CASE WHEN m.kind IN ('device', 'attached') THEN sf.mode ELSE m.mode END,
m.access,
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END,
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END,
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END
CASE WHEN m.kind IN ('device', 'attached') THEN sf.state ELSE 'idle' END,
CASE WHEN m.kind IN ('device', 'attached') THEN sf.last_sync ELSE 0 END,
CASE WHEN m.kind IN ('device', 'attached') THEN sf.pending_mode ELSE NULL END
FROM mount AS m
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
ORDER BY CASE m.kind
@@ -138,6 +138,11 @@ impl Db {
Error::Other(format!("mount row {id} has no sync_folder_id"))
})?,
},
"attached" => MountKind::Attached {
sync_folder_id: sync_folder_id.ok_or_else(|| {
Error::Other(format!("mount row {id} has no sync_folder_id"))
})?,
},
"shared" => MountKind::Shared {
share_root_uid: share_root_uid.ok_or_else(|| {
Error::Other(format!("mount row {id} has no share_root_uid"))
+29 -5
View File
@@ -9,6 +9,8 @@ use std::collections::HashMap;
pub struct StoredSyncFolder {
pub id: i64,
/// `device` backup or `attached` My files mirror.
pub kind: String,
pub local_path: String,
pub remote_uid: String,
pub remote_share_id: String,
@@ -59,12 +61,32 @@ impl Db {
local_path: &str,
remote_uid: &str,
remote_share_id: &str,
) -> Result<i64> {
self.sync_folder_insert(local_path, remote_uid, remote_share_id, "device")
}
/// Attach an existing My files folder, without creating a device backup.
pub fn sync_folder_attach(
&self,
local_path: &str,
remote_uid: &str,
remote_share_id: &str,
) -> Result<i64> {
self.sync_folder_insert(local_path, remote_uid, remote_share_id, "attached")
}
fn sync_folder_insert(
&self,
local_path: &str,
remote_uid: &str,
remote_share_id: &str,
kind: &str,
) -> Result<i64> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id)
VALUES (?1, ?2, ?3)",
params![local_path, remote_uid, remote_share_id],
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id, kind)
VALUES (?1, ?2, ?3, ?4)",
params![local_path, remote_uid, remote_share_id, kind],
)?;
Ok(conn.last_insert_rowid())
}
@@ -73,7 +95,7 @@ impl Db {
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync, kind
FROM sync_folder ORDER BY id",
)?;
let rows = stmt.query_map([], |r| {
@@ -86,6 +108,7 @@ impl Db {
pending_mode: r.get(5)?,
state: r.get(6)?,
last_sync: r.get(7)?,
kind: r.get(8)?,
})
})?;
let mut items = Vec::new();
@@ -99,7 +122,7 @@ impl Db {
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
let conn = self.read();
conn.query_row(
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync, kind
FROM sync_folder WHERE id = ?1",
params![id],
|r| {
@@ -112,6 +135,7 @@ impl Db {
pending_mode: r.get(5)?,
state: r.get(6)?,
last_sync: r.get(7)?,
kind: r.get(8)?,
})
},
)
+71 -1
View File
@@ -2076,7 +2076,11 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"DROP TRIGGER mount_sync_folder_insert;
"DROP TRIGGER IF EXISTS mount_sync_folder_kind_update;
DROP TRIGGER mount_sync_folder_insert;
DROP TRIGGER sync_folder_attached_insert;
DROP TRIGGER sync_folder_attached_update;
ALTER TABLE sync_folder DROP COLUMN kind;
DROP TABLE mount;
DROP TABLE share_access;
DROP TABLE album_photos;
@@ -7691,3 +7695,69 @@ fn a_queued_op_stops_existing_once_the_node_is_deleted() {
assert_eq!(blobs, vec!["/staging/journal"]);
assert!(!db.op_exists(id).unwrap());
}
#[test]
fn attachments_have_their_own_location_and_cannot_evict_local_files() {
use crate::mounts::MountKind;
let db = Db::open_in_memory().unwrap();
let id = db
.sync_folder_attach("/work", "vol~work", "myfiles-share")
.unwrap();
let folder = db.sync_folder_get(id).unwrap().unwrap();
assert_eq!(folder.kind, "attached");
assert_eq!(folder.mode, "mirror");
assert_eq!(db.sync_folder_list().unwrap()[0].kind, "attached");
let locations = db.mount_list().unwrap();
assert!(
matches!(locations[0].kind, MountKind::Attached { sync_folder_id } if sync_folder_id == id)
);
assert_eq!(locations[0].local_path, "/work");
assert_eq!(locations[0].root_share_id, "myfiles-share");
assert!(db.sync_folder_set_mode(id, "ondemand").is_err());
assert!(
db.sync_folder_set_pending_mode(id, Some("ondemand"))
.is_err()
);
assert_eq!(db.sync_folder_get(id).unwrap().unwrap().mode, "mirror");
db.sync_folder_remove(id).unwrap();
assert!(db.mount_list().unwrap().is_empty());
}
#[test]
fn migration_v45_keeps_existing_device_folders_and_location_ids() {
let db = Db::open_in_memory().unwrap();
let id = db
.sync_folder_add("/backup", "vol~backup", "share")
.unwrap();
db.sync_folder_set_mode(id, "ondemand").unwrap();
db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap();
let location_id = db.mount_list().unwrap()[0].id;
db.conn
.lock()
.execute_batch(
"DROP TRIGGER sync_folder_attached_insert;
DROP TRIGGER sync_folder_attached_update;
DROP TRIGGER IF EXISTS mount_sync_folder_kind_update;
DROP TRIGGER mount_sync_folder_insert;
ALTER TABLE sync_folder DROP COLUMN kind;
CREATE TRIGGER mount_sync_folder_insert AFTER INSERT ON sync_folder BEGIN
INSERT INTO mount (kind, sync_folder_id) VALUES ('device', NEW.id); END;
UPDATE sync_state SET value = '44' WHERE key = 'schema_version';",
)
.unwrap();
db.migrate().unwrap();
let folder = db.sync_folder_get(id).unwrap().unwrap();
assert_eq!(folder.kind, "device");
assert_eq!(folder.mode, "ondemand");
assert_eq!(folder.pending_mode.as_deref(), Some("mirror"));
assert_eq!(db.mount_list().unwrap()[0].id, location_id);
let attached = db
.sync_folder_attach("/work", "vol~work", "myfiles")
.unwrap();
assert_eq!(
db.sync_folder_get(attached).unwrap().unwrap().kind,
"attached"
);
db.migrate().unwrap();
assert_eq!(db.mount_list().unwrap().len(), 2);
}
+4 -2
View File
@@ -81,6 +81,8 @@ pub enum MountKind {
MyFiles,
/// A folder owned by this machine's Proton Drive device.
Device { sync_folder_id: i64 },
/// A real local mirror of an existing My files folder.
Attached { sync_folder_id: i64 },
/// A future standalone mount rooted at a node shared with this account.
Shared { share_root_uid: String },
}
@@ -88,8 +90,8 @@ pub enum MountKind {
/// One local Proton Drive location as exposed over the control protocol.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MountSpec {
/// Presentation-row id. Device actions use the `sync_folder_id` carried by
/// [`MountKind::Device`], not this id.
/// Presentation-row id. Synced-folder actions use the `sync_folder_id`
/// carried by [`MountKind::Device`] or [`MountKind::Attached`], not this id.
pub id: i64,
pub kind: MountKind,
pub local_path: String,
+136
View File
@@ -0,0 +1,136 @@
//! Explicitly bind an existing My files folder to a real local mirror.
//!
//! Unlike a FUSE mount, this never covers or evicts the local directory.
//! The existing three-way engine performs the initial, non-destructive union.
use super::{Core, parse_uid, sync};
use pdfs_core::db::StoredSyncFolder;
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
impl Core {
/// A bounded, server-verified ancestry walk. Do not decide ownership or
/// remote-root overlap from a possibly incomplete cached folder listing.
pub(super) fn attach_ancestors(&self, start: &NodeUid) -> CoreResult<Vec<NodeUid>> {
let mut ancestors = Vec::new();
let mut seen = HashSet::new();
let mut next = Some(start.clone());
while let Some(uid) = next {
if !seen.insert(uid.clone()) || ancestors.len() >= 256 {
return Err(CoreError::invalid(
"invalid or excessively deep remote folder ancestry",
));
}
ancestors.push(uid.clone());
if uid == self.primary_root_uid {
break;
}
let nodes = self
.rt
.block_on(self.drive.enumerate_nodes(std::slice::from_ref(&uid)))
.map_err(|e| CoreError::from_api(&e, "verify sync folder ancestry"))?;
let node = nodes
.into_iter()
.next()
.ok_or_else(|| CoreError::not_found("remote sync folder is missing"))?;
if node.trashed {
return Err(CoreError::invalid("remote sync folder is in the trash"));
}
next = node.parent_uid;
}
Ok(ancestors)
}
pub(crate) fn attach_sync_folder(
&self,
local: &Path,
remote: &Path,
mountpoint: &Path,
) -> CoreResult<StoredSyncFolder> {
let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock();
if !self.primary {
return Err(CoreError::invalid("attach must use My files"));
}
if remote.is_absolute()
|| remote
.components()
.any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
{
return Err(CoreError::invalid(
"remote folder must stay inside My files",
));
}
let local = local
.canonicalize()
.map_err(|e| CoreError::invalid(format!("resolve {}: {e}", local.display())))?;
if !local.is_dir() {
return Err(CoreError::invalid("local sync path is not a directory"));
}
let existing = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let others = existing
.iter()
.map(|f| PathBuf::from(&f.local_path))
.collect::<Vec<_>>();
let mut reserved = crate::devices::sync_root_reserved();
reserved.mountpoint = Some(
mountpoint
.canonicalize()
.map_err(|e| CoreError::internal(format!("resolve mountpoint: {e}")))?,
);
if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) {
return Err(CoreError::invalid(problem));
}
let (ino, remote_uid) = self.resolve(remote)?;
let is_folder = self
.state()
.entries
.get(&ino)
.is_some_and(|e| e.node.is_folder() && !e.node.trashed);
if !is_folder {
return Err(CoreError::invalid("remote sync path is not a folder"));
}
let ancestors = self.attach_ancestors(&remote_uid)?;
if remote_uid == self.primary_root_uid || !ancestors.contains(&self.primary_root_uid) {
return Err(CoreError::invalid(
"choose an owned folder below My files, not its root or a shared folder",
));
}
for other in existing.iter().filter(|f| f.kind == "attached") {
let uid = parse_uid(&other.remote_uid)
.ok_or_else(|| CoreError::internal("invalid existing remote sync root"))?;
if uid == remote_uid
|| ancestors.contains(&uid)
|| self.attach_ancestors(&uid)?.contains(&remote_uid)
{
return Err(CoreError::invalid(
"remote folder overlaps an existing synced folder",
));
}
}
let share_id = self
.rt
.block_on(self.drive.context_share_id(&self.primary_root_uid))
.map_err(|e| CoreError::from_api(&e, "resolve My files share"))?
.to_string();
if share_id.is_empty() {
return Err(CoreError::invalid("My files share is unavailable"));
}
let id = self
.db
.sync_folder_attach(&local.to_string_lossy(), &remote_uid.to_string(), &share_id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
// An empty baseline makes one-sided files new, never deletions. The
// engine hashes matching-size files and preserves divergent versions.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
self.db
.sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found("new sync folder disappeared"))
}
}
+15
View File
@@ -385,6 +385,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
R::AddSyncFolder { .. }
| R::AttachSyncFolder { .. }
| R::RemoveSyncFolder { .. }
| R::SetSyncFolderMode { .. }
| R::SetSyncFolderPaused { .. }
@@ -1397,6 +1398,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: format!("adding {local_path}"),
}
}
Ok(CtlRequest::AttachSyncFolder {
local_path,
remote_path,
}) => {
let result = rel_to_mount(mountpoint, &remote_path).and_then(|remote| {
core.attach_sync_folder(Path::new(&local_path), &remote, mountpoint)
});
match result {
Ok(folder) => CtlResponse::Ok {
message: format!("sync folder {} attached: {}", folder.id, folder.local_path),
},
Err(error) => CtlResponse::error(error),
}
}
Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() {
Ok(items) => CtlResponse::SyncFolders { items },
Err(e) => CtlResponse::error(e),
+27 -3
View File
@@ -33,6 +33,11 @@ use super::{
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
};
/// Registration validates local and remote root overlap before inserting a
/// mapping. Serialize all registration paths so concurrent control requests
/// cannot both validate against the same old set of roots.
pub(super) static SYNC_ROOT_REGISTRATION: Mutex<()> = Mutex::new(());
/// A mode that this daemon is allowed to apply.
///
/// Persisted and control-protocol values first pass through [`MountMode`] so
@@ -152,7 +157,9 @@ impl Core {
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
for location in &mut locations {
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
if let MountKind::Device { sync_folder_id } = &location.kind {
if let MountKind::Device { sync_folder_id } | MountKind::Attached { sync_folder_id } =
&location.kind
{
location.progress = progress.get(sync_folder_id).cloned();
location.paused = self.sync_folder_paused(*sync_folder_id);
}
@@ -495,7 +502,7 @@ impl Core {
}
/// Everything directly under a device root, trashed nodes included.
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
pub(super) fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
let uids = self
.rt
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
@@ -513,6 +520,7 @@ impl Core {
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
/// the two-way engine (Phase 2) reconciles later changes.
pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult<StoredSyncFolder> {
let _registration = SYNC_ROOT_REGISTRATION.lock();
let meta = std::fs::metadata(local)
.map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?;
if !meta.is_dir() {
@@ -699,6 +707,11 @@ impl Core {
.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.kind == "attached" && delete_remote {
return Err(CoreError::invalid(
"attached My files folders cannot be deleted by stopping sync; omit --delete-remote",
));
}
// An `ondemand` folder *is* a FUSE mount over its local path, so dropping
// only the row would strand the mount: the path would keep serving a
// folder the daemon no longer tracks, and nothing would ever unmount it.
@@ -785,6 +798,11 @@ impl Core {
.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.kind == "attached" && target_mode != ApplicableMountMode::Mirror {
return Err(CoreError::invalid(
"attached My files folders only support mirror mode; use the My files mount for online-only access",
));
}
let current_mode = ApplicableMountMode::parse(&folder.mode)
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
// A switch waits on the folder's reconcile, which a pause skips; refuse
@@ -960,6 +978,11 @@ impl Core {
.sync_folder_get(id)
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
if folder.kind == "attached" && mode != ApplicableMountMode::Mirror {
return Err(SwitchBlocked::Failed(
"attached My files folders only support mirror mode".into(),
));
}
if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) {
return Err(SwitchBlocked::Superseded);
}
@@ -1199,7 +1222,7 @@ impl Core {
}
};
for folder in folders {
if folder.mode != "ondemand" {
if folder.kind != "device" || folder.mode != "ondemand" {
continue;
}
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
@@ -1395,6 +1418,7 @@ mod tests {
fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder {
StoredSyncFolder {
kind: "device".into(),
id: 7,
local_path: local_path.to_string(),
remote_uid: remote_uid.to_string(),
+7 -1
View File
@@ -26,7 +26,7 @@ use futures::StreamExt as _;
use futures::stream::BoxStream;
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::proton_sdk::ids::{DriveEventId, NodeUid, ShareId};
use proton_drive_rs::{
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
ProtonDriveClient, RevisionReader, Thumbnail,
@@ -43,6 +43,8 @@ pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>>
pub(crate) trait DriveApi: Send + Sync {
async fn get_my_files_folder(&self) -> Result<Node>;
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId>;
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
@@ -305,6 +307,10 @@ impl RevisionRead for RevisionReader {
#[async_trait]
impl DriveApi for ProtonDriveClient {
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId> {
ProtonDriveClient::context_share_id(self, uid).await
}
async fn get_my_files_folder(&self) -> Result<Node> {
ProtonDriveClient::get_my_files_folder(self).await
}
+4 -1
View File
@@ -79,6 +79,7 @@ use proton_drive_rs::{
};
mod albums;
mod attach;
mod background;
mod conflicts;
mod control;
@@ -118,13 +119,14 @@ mod workers;
use background::{forget_and_notify, run_event_sync, run_local_index, run_photos_event_sync};
use link::BATCH_CALL_TIMEOUT;
pub(crate) use mount::is_stale_mount;
pub use mount::prepare_mountpoint;
pub use mount::{MountOptions, MountOutcome, mount};
use mount::{
SecondaryInsertRejection, SecondaryMount, clear_stale_mount, fuse_connection_id, spawn_session,
};
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
pub use systemd::{idle_sleep, status as notify_status};
pub use systemd::{idle_sleep, status as notify_status, stretch_watchdog_for_startup};
use tracing::{debug, error, info, warn};
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
use r#virtual::{
@@ -6441,6 +6443,7 @@ fn sync_folder_info(
) -> SyncFolderInfo {
SyncFolderInfo {
id: f.id,
kind: f.kind,
local_path: f.local_path,
remote_uid: f.remote_uid,
mode: f.mode,
+14
View File
@@ -196,6 +196,20 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
}
}
/// Make sure `mountpoint` is a directory a mount can go on, clearing a dead
/// mount a killed daemon left there first.
///
/// A dead mount answers `ENOTCONN`, which `create_dir_all` takes for "absent";
/// it then tries `mkdir` and fails with `EEXIST`. The daemon retried that error
/// forever and never got as far as [`check_mountpoint`], which would have
/// cleared the mount (`docs/BUGS.md` B279).
pub fn prepare_mountpoint(mountpoint: &Path) -> std::io::Result<()> {
if is_stale_mount(mountpoint) {
clear_stale_mount(mountpoint);
}
std::fs::create_dir_all(mountpoint)
}
/// Refuse to mount My files over a folder that already has files in it: FUSE
/// would hide them, unsent and out of sight, until the service stops
/// (`docs/BUGS.md` B277). Runs before the root fetch and before any worker
+178 -3
View File
@@ -72,6 +72,105 @@ const UPLOAD_THUMB_QUALITIES: [u8; 4] = [65, 50, 35, 20];
/// Largest still image read whole to render the thumbnail its upload carries.
const UPLOAD_IMAGE_MAX_BYTES: u64 = 256 * 1024 * 1024;
/// How much memory every thumbnail decode in the daemon may hold at once:
/// the encoded file plus its decoded pixels.
///
/// Without a limit, each of the 16 drain workers decoded its own full-size
/// photo. A batch of 100-megapixel JPEGs then held about 9 GiB, which swapped the
/// machine until the watchdog killed the daemon, on every restart
/// (`docs/BUGS.md` B280). With this budget, a phone photo still decodes beside
/// several others, and a 100-megapixel one decodes alone.
const DECODE_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
/// The process-wide [`DecodeBudget`] every thumbnail decode draws from.
static DECODE_BUDGET: DecodeBudget = DecodeBudget::new(DECODE_BUDGET_BYTES);
/// A counting gate over bytes: [`acquire`](Self::acquire) waits until the cost
/// fits beside what is already held.
struct DecodeBudget {
limit: u64,
used: std::sync::Mutex<u64>,
freed: std::sync::Condvar,
}
/// A share of a [`DecodeBudget`], given back on drop.
struct DecodePermit<'a> {
budget: &'a DecodeBudget,
cost: u64,
}
impl DecodeBudget {
const fn new(limit: u64) -> Self {
Self {
limit,
used: std::sync::Mutex::new(0),
freed: std::sync::Condvar::new(),
}
}
/// Wait until `cost` bytes fit, and hold them. A cost above the limit is
/// cut down to the limit, so the largest image waits for an idle budget
/// and then runs alone, instead of waiting forever.
fn acquire(&self, cost: u64) -> DecodePermit<'_> {
let cost = cost.clamp(1, self.limit);
let mut used = self
.used
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while *used + cost > self.limit {
used = self
.freed
.wait(used)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
*used += cost;
DecodePermit { budget: self, cost }
}
}
impl Drop for DecodePermit<'_> {
fn drop(&mut self) {
let mut used = self
.budget
.used
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*used -= self.cost;
self.budget.freed.notify_all();
}
}
/// What decoding an image of `encoded_len` bytes and `dimensions` costs in
/// memory: the encoded bytes, the decoded pixels at up to 4 bytes each, and a
/// second copy of the pixels when [`decode_upright`] rotates them. When the
/// header gives no dimensions, assume the image is compressed 16 to 1.
fn decode_cost(encoded_len: u64, dimensions: Option<(u32, u32)>) -> u64 {
let pixels = match dimensions {
Some((width, height)) => u64::from(width) * u64::from(height) * 4 * 2,
None => encoded_len.saturating_mul(16),
};
encoded_len.saturating_add(pixels)
}
/// The pixel dimensions in the header of an encoded image, without decoding it.
fn dimensions_of(bytes: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()
}
/// [`dimensions_of`] for an image file. Reads only the header.
fn dimensions_of_file(path: &Path) -> Option<(u32, u32)> {
image::ImageReader::open(path)
.ok()?
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()
}
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no
/// pixels are decoded. `None` when the format is unknown or the header is torn.
fn ratio_of(bytes: &[u8]) -> Option<f64> {
@@ -209,7 +308,14 @@ fn thumbnail_build_may_start(status: &ThumbnailBuildStatus, path: &str) -> CoreR
/// formats return an undecodable verdict.
///
/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool.
/// Waits for its share of [`DECODE_BUDGET`] first.
fn scale_thumbnail(bytes: &[u8], name: &str, staging_dir: &Path) -> ScaleAttempt {
let _permit = DECODE_BUDGET.acquire(decode_cost(bytes.len() as u64, dimensions_of(bytes)));
scale_thumbnail_unbudgeted(bytes, name, staging_dir)
}
/// [`scale_thumbnail`] for a caller that already holds its [`DecodePermit`].
fn scale_thumbnail_unbudgeted(bytes: &[u8], name: &str, staging_dir: &Path) -> ScaleAttempt {
scale_thumbnail_with_exiftool(
bytes,
name,
@@ -544,11 +650,20 @@ fn upload_thumbnails_with(
}
video_file_thumbnail(path, ffmpeg, FFMPEG_TIMEOUT)
} else if is_thumbnail_image_name(name) {
let fits = std::fs::metadata(path).is_ok_and(|meta| meta.len() <= UPLOAD_IMAGE_MAX_BYTES);
let Some(bytes) = fits.then(|| std::fs::read(path).ok()).flatten() else {
let Some(len) = std::fs::metadata(path)
.ok()
.map(|meta| meta.len())
.filter(|len| *len <= UPLOAD_IMAGE_MAX_BYTES)
else {
return Vec::new();
};
scale_thumbnail(&bytes, name, staging_dir)
// Taken before the read, so the encoded bytes count against the
// budget too.
let _permit = DECODE_BUDGET.acquire(decode_cost(len, dimensions_of_file(path)));
let Ok(bytes) = std::fs::read(path) else {
return Vec::new();
};
scale_thumbnail_unbudgeted(&bytes, name, staging_dir)
} else {
return Vec::new();
};
@@ -2116,6 +2231,66 @@ impl Core {
}
}
#[cfg(test)]
mod decode_budget_tests {
use super::{DecodeBudget, decode_cost};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
/// A decode that does not fit beside the ones running waits for them,
/// instead of every drain worker decoding at once (`docs/BUGS.md` B280).
#[test]
fn a_decode_over_the_budget_waits_for_the_running_one() {
let budget = Arc::new(DecodeBudget::new(100));
let first = budget.acquire(60);
let started = Arc::new(AtomicBool::new(false));
let waiter = {
let (budget, started) = (budget.clone(), started.clone());
std::thread::spawn(move || {
let _second = budget.acquire(60);
started.store(true, Ordering::SeqCst);
})
};
std::thread::sleep(Duration::from_millis(100));
assert!(
!started.load(Ordering::SeqCst),
"second decode ran over budget"
);
drop(first);
waiter.join().expect("waiter");
assert!(started.load(Ordering::SeqCst));
}
/// Decodes that fit together run together.
#[test]
fn small_decodes_share_the_budget() {
let budget = DecodeBudget::new(100);
let _a = budget.acquire(30);
let _b = budget.acquire(30);
let _c = budget.acquire(30);
}
/// An image bigger than the whole budget still decodes, alone, rather than
/// waiting forever.
#[test]
fn an_image_over_the_whole_budget_still_decodes() {
let budget = DecodeBudget::new(100);
drop(budget.acquire(10_000));
let _again = budget.acquire(100);
}
/// The cost of the 102-megapixel JPEG that set off B280 fills the real
/// budget, so it never decodes beside another one.
#[test]
fn a_hundred_megapixel_photo_costs_the_whole_budget() {
let cost = decode_cost(97_266_141, Some((11_656, 8_742)));
assert!(cost >= super::DECODE_BUDGET_BYTES);
let phone = decode_cost(4_000_000, Some((4_000, 3_000)));
assert!(phone * 4 < super::DECODE_BUDGET_BYTES);
}
}
#[cfg(test)]
mod thumb_tests {
use super::{
+51 -13
View File
@@ -141,19 +141,11 @@ impl Core {
/// Snapshot the local arrangement into a [`Profile`].
fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult<Profile> {
let folders = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.into_iter()
.map(|f| ProfileFolder {
remote_uid: f.remote_uid,
local_path: f.local_path,
// A queued switch is where the user asked the folder to be, so
// that is what the next machine should set up.
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
})
.collect();
let folders = profile_folders(
self.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?,
);
let pins = self
.db
.pin_list()
@@ -389,6 +381,7 @@ impl Core {
items: &[pdfs_core::control::RestoreItem],
device: Option<&str>,
) -> CoreResult<RestoreOutcome> {
let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock();
if items.is_empty() {
return Err(CoreError::invalid("nothing to restore"));
}
@@ -398,6 +391,11 @@ impl Core {
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
// A caller must not smuggle a My files root into a device mapping,
// where destructive device-only operations would otherwise be allowed.
let device_root = super::parse_uid(&device.root_uid)
.ok_or_else(|| CoreError::internal("invalid device root"))?;
let children = self.device_root_nodes(&device_root)?;
let reserved = crate::devices::sync_root_reserved();
let mut roots: Vec<PathBuf> = existing
.iter()
@@ -406,6 +404,18 @@ impl Core {
let mut restored = 0usize;
let mut skipped: Vec<String> = Vec::new();
for item in items {
if !children.iter().any(|node| {
node.uid.to_string() == item.remote_uid
&& node.is_folder()
&& !node.trashed
&& node.name != PROFILE_DIR_NAME
}) {
skipped.push(format!(
"{}: not a folder under this device",
item.local_path
));
continue;
}
// The path is proposed from the profile another machine wrote, and
// may hold a folder name from Drive: checked like any new root.
let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) {
@@ -468,6 +478,19 @@ impl Core {
}
}
/// Only device backups belong in the device's cross-machine profile.
fn profile_folders(folders: Vec<pdfs_core::db::StoredSyncFolder>) -> Vec<ProfileFolder> {
folders
.into_iter()
.filter(|f| f.kind == "device")
.map(|f| ProfileFolder {
remote_uid: f.remote_uid,
local_path: f.local_path,
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
})
.collect()
}
/// What a restore did: how many folders it attached, and why it left out the
/// others.
pub(crate) struct RestoreOutcome {
@@ -530,6 +553,21 @@ fn default_local_path(name: &str) -> PathBuf {
mod tests {
use super::*;
#[test]
fn attached_my_files_folders_are_not_in_the_device_profile() {
let dir = crate::sim::daemon::scratch("profile-folder-kinds");
let db = pdfs_core::db::Db::open(&dir.join("cache.db")).unwrap();
db.sync_folder_add("/backup", "vol~backup", "device-share")
.unwrap();
db.sync_folder_attach("/work", "vol~work", "my-files-share")
.unwrap();
let folders = profile_folders(db.sync_folder_list().unwrap());
assert_eq!(folders.len(), 1);
assert_eq!(folders[0].remote_uid, "vol~backup");
drop(db);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn foreign_home_is_not_reused() {
assert!(!path_is_usable_here(Path::new(
+1
View File
@@ -555,6 +555,7 @@ mod tests {
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
StoredSyncFolder {
kind: "device".into(),
id,
local_path: local_path.to_string(),
remote_uid: format!("vol~root{id}"),
+427
View File
@@ -2912,6 +2912,433 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attaching_my_files_preserves_local_files_and_syncs_both_ways() {
use std::os::unix::fs::MetadataExt;
let drive = FakeDrive::new();
let dir = scratch("attach-my-files");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Work").is_some()
}));
drive.device().write("Work/cloud.txt", b"from cloud");
drive.device().write("Work/common.txt", b"remote version");
drive.device().write("Work/identical.txt", b"same bytes");
let local = dir.join("local");
std::fs::create_dir(&local).unwrap();
std::fs::write(local.join("local.txt"), b"only on this computer").unwrap();
std::fs::write(local.join("common.txt"), b"local version").unwrap();
std::fs::write(local.join("identical.txt"), b"same bytes").unwrap();
daemon
.request(&Request::AttachSyncFolder {
local_path: local.to_str().unwrap().into(),
remote_path: "Work".into(),
})
.unwrap();
let folder = match daemon.request(&Request::ListSyncFolders).unwrap() {
Response::SyncFolders { items } => {
items
.into_iter()
.find(|f| f.local_path == local.to_str().unwrap())
.unwrap()
.id
}
other => panic!("unexpected reply: {other:?}"),
};
assert!(wait_until(Duration::from_secs(30), || {
drive.lookup("Work/local.txt").is_some()
&& std::fs::read(local.join("cloud.txt")).is_ok_and(|b| b == b"from cloud")
&& std::fs::read(local.join("common.txt")).is_ok_and(|b| b == b"remote version")
&& !conflict_copies(&local, "common").is_empty()
}));
assert_ne!(
std::fs::metadata(&local).unwrap().dev(),
std::fs::metadata(&daemon.mountpoint).unwrap().dev()
);
assert!(
conflict_copies(&local, "common")
.iter()
.any(|p| std::fs::read(p).is_ok_and(|b| b == b"local version"))
);
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: local.to_str().unwrap().into(),
remote_path: "Work".into()
})
.is_err()
);
let duplicate = dir.join("duplicate");
std::fs::create_dir(&duplicate).unwrap();
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: duplicate.to_str().unwrap().into(),
remote_path: "Work".into()
})
.is_err()
);
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: duplicate.to_str().unwrap().into(),
remote_path: "../Work".into()
})
.is_err()
);
// Parent/child mappings, the account root and files cannot be attached.
std::fs::create_dir(daemon.mountpoint.join("Work/Sub")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Work/Sub").is_some()
}));
for remote in ["Work/Sub", ".", "Work/cloud.txt"] {
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: duplicate.to_str().unwrap().into(),
remote_path: remote.into(),
})
.is_err()
);
}
// Local overlap and symlink aliases into the actual cloud mount are refused.
let alias = dir.join("cloud-alias");
std::os::unix::fs::symlink(daemon.mountpoint.join("Work"), &alias).unwrap();
std::fs::create_dir_all(local.join("Sub")).unwrap();
for path in [
local.join("Sub"),
dir.clone(),
daemon.mountpoint.join("Work"),
alias,
] {
let error = daemon
.request(&Request::AttachSyncFolder {
local_path: path.to_string_lossy().into_owned(),
remote_path: "Work/Sub".into(),
})
.unwrap_err()
.to_string();
assert!(error.contains("overlaps"), "{error}");
}
assert!(conflict_copies(&local, "identical").is_empty());
assert_eq!(
std::fs::read(local.join("identical.txt")).unwrap(),
b"same bytes"
);
// Simultaneous requests cannot register two local trees for one cloud root.
std::fs::create_dir(daemon.mountpoint.join("Other")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Other").is_some()
}));
let candidate_a = dir.join("candidate-a");
let candidate_b = dir.join("candidate-b");
std::fs::create_dir(&candidate_a).unwrap();
std::fs::create_dir(&candidate_b).unwrap();
let outcomes = std::thread::scope(|scope| {
let a = scope.spawn(|| {
daemon
.request(&Request::AttachSyncFolder {
local_path: candidate_a.to_str().unwrap().into(),
remote_path: "Other".into(),
})
.map_err(|e| e.to_string())
});
let b = scope.spawn(|| {
daemon
.request(&Request::AttachSyncFolder {
local_path: candidate_b.to_str().unwrap().into(),
remote_path: "Other".into(),
})
.map_err(|e| e.to_string())
});
[a.join().unwrap(), b.join().unwrap()]
});
assert_eq!(
outcomes.iter().filter(|result| result.is_ok()).count(),
1,
"exactly one mapping may own a remote root: {outcomes:?}"
);
drive
.device()
.write("Work/cloud.txt", b"new cloud revision");
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || std::fs::read(
local.join("cloud.txt")
)
.is_ok_and(|b| b == b"new cloud revision")));
std::fs::write(local.join("local.txt"), b"new local revision").unwrap();
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || drive
.tree()
.get("Work/local.txt")
== Some(&Entry::File(Arc::new(b"new local revision".to_vec())))));
assert!(wait_until(Duration::from_secs(30), || listed_folder(
&daemon, folder
)
.is_some_and(|f| f.state == "idle")));
daemon
.request(&Request::SetSyncFolderPaused {
id: folder,
paused: true,
})
.unwrap();
let before = drive.tree();
for entry in std::fs::read_dir(&local).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
std::fs::remove_dir_all(path).unwrap();
} else {
std::fs::remove_file(path).unwrap();
}
}
daemon
.request(&Request::SetSyncFolderPaused {
id: folder,
paused: false,
})
.unwrap();
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || listed_folder(
&daemon, folder
)
.is_some_and(|f| f.state == "error")));
assert_eq!(
drive.tree(),
before,
"the wipe guard must preserve the remote tree"
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let db = Db::open(&dir.join("pdfs.db")).unwrap();
assert_eq!(
db.sync_folder_get(folder).unwrap().unwrap().remote_share_id,
"my-files-share"
);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
}
fn attached_my_files(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 {
std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.drained() && drive.lookup("Work").is_some()
}));
std::fs::create_dir(local).unwrap();
daemon
.request(&Request::AttachSyncFolder {
local_path: local.to_string_lossy().into_owned(),
remote_path: "Work".into(),
})
.unwrap();
match daemon.request(&Request::ListSyncFolders).unwrap() {
Response::SyncFolders { items } => {
items
.into_iter()
.find(|f| f.local_path == local.to_string_lossy())
.unwrap()
.id
}
other => panic!("unexpected reply: {other:?}"),
}
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attached_my_files_views_preserve_concurrent_edits() {
let drive = FakeDrive::new();
let dir = scratch("attach-views");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("local");
let id = attached_my_files(&daemon, &drive, &local);
let mounted = daemon.mountpoint.join("Work");
// Prime the mount listing before a mirror upload, to catch stale listings.
assert_eq!(std::fs::read_dir(&mounted).unwrap().count(), 0);
std::fs::write(local.join("mirror.txt"), b"mirror bytes").unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read_dir(&mounted)
.unwrap()
.any(|e| e.unwrap().file_name() == "mirror.txt")
&& std::fs::read(mounted.join("mirror.txt")).is_ok_and(|b| b == b"mirror bytes")
}),
"mirror upload must reach the mounted view without a restart"
);
std::fs::write(mounted.join("mount.txt"), b"mounted bytes").unwrap();
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mounted bytes")
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")
}),
"mounted writes must reach the mirror"
);
daemon
.request(&Request::SetSyncFolderPaused { id, paused: true })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || {
listed_folder(&daemon, id).is_some_and(|f| f.state != "syncing")
}));
std::fs::write(mounted.join("mount.txt"), b"mount changed").unwrap();
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
std::fs::write(local.join("mount.txt"), b"local changed").unwrap();
daemon
.request(&Request::SetSyncFolderPaused { id, paused: false })
.unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mount changed")
&& conflict_copies(&local, "mount")
.iter()
.any(|p| std::fs::read(p).is_ok_and(|b| b == b"local changed"))
&& drive.tree().iter().any(|(path, entry)| {
path.starts_with("Work/mount (sync-conflict")
&& entry == &Entry::File(Arc::new(b"local changed".to_vec()))
})
}),
"both edits must survive the next pass"
);
let error = daemon
.request(&Request::SetSyncFolderMode {
id,
mode: "ondemand".into(),
})
.unwrap_err()
.to_string();
assert!(error.contains("only support mirror"), "{error}");
let error = daemon
.request(&Request::RemoveSyncFolder {
id,
delete_remote: true,
})
.unwrap_err()
.to_string();
assert!(error.contains("cannot be deleted"), "{error}");
let folder = listed_folder(&daemon, id).unwrap();
assert_eq!(folder.kind, "attached");
assert_eq!(folder.mode, "mirror");
assert!(folder.pending_mode.is_none());
// Restore must not reclassify a My files root as a device backup,
// which would make the device-only destructive operations available.
let restore_path = dir.join("forged-device-restore");
daemon
.request(&Request::RestoreSyncFolders {
items: vec![control::RestoreItem {
remote_uid: folder.remote_uid.clone(),
local_path: restore_path.to_string_lossy().into_owned(),
mode: "ondemand".into(),
}],
})
.unwrap();
assert!(wait_until(Duration::from_secs(30), || {
matches!(daemon.request(&Request::ListActivity { limit: 100 }),
Ok(Response::Activity { items }) if items.iter().any(|item| {
!item.ok && item.detail.contains("not a folder under this device")
}))
}));
assert!(!restore_path.exists());
match daemon.request(&Request::ListLocations).unwrap() {
Response::Locations { items } => assert!(items.iter().any(|m| {
matches!(m.kind, pdfs_core::mounts::MountKind::Attached { sync_folder_id } if sync_folder_id == id)
})),
other => panic!("unexpected reply: {other:?}"),
}
daemon
.request(&Request::RemoveSyncFolder {
id,
delete_remote: false,
})
.unwrap();
assert!(listed_folder(&daemon, id).is_none());
assert!(drive.lookup("Work/mount.txt").is_some());
assert_eq!(
std::fs::read(local.join("mount.txt")).unwrap(),
b"mount changed"
);
daemon.stop().unwrap();
let _ = std::fs::remove_dir_all(dir);
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attached_my_files_trashed_root_keeps_local_files() {
attached_root_loss_keeps_local_files(false);
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attached_my_files_root_moved_outside_my_files_keeps_local_files() {
attached_root_loss_keeps_local_files(true);
}
fn attached_root_loss_keeps_local_files(move_outside: bool) {
let drive = FakeDrive::new();
let dir = scratch("attach-root-gone");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("local");
let id = attached_my_files(&daemon, &drive, &local);
std::fs::write(local.join("keep.txt"), b"keep my bytes").unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
drive.lookup("Work/keep.txt").is_some()
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")
}));
daemon
.request(&Request::SetSyncFolderPaused { id, paused: true })
.unwrap();
if move_outside {
use crate::drive::DriveApi;
let rt = daemon.rt.as_ref().unwrap();
let device = rt
.block_on(
daemon
.client
.create_device("other computer", proton_drive_rs::DeviceType::Linux),
)
.unwrap();
let root = drive.lookup("Work").unwrap();
rt.block_on(daemon.client.move_node(&root, &device.root_folder_uid))
.unwrap();
} else {
drive.device().trash("Work");
}
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")
}));
assert_eq!(
std::fs::read(local.join("keep.txt")).unwrap(),
b"keep my bytes"
);
assert_eq!(std::fs::read_dir(&local).unwrap().count(), 1);
let daemon = daemon.restart().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")
}));
assert_eq!(
std::fs::read(local.join("keep.txt")).unwrap(),
b"keep my bytes"
);
daemon.stop().unwrap();
let _ = std::fs::remove_dir_all(dir);
}
/// Register `local` as a synced folder in `mode` and wait until the daemon
/// lists it that way, returning its id.
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
+8
View File
@@ -1123,6 +1123,14 @@ fn delete_one(server: &mut Server, uid: &NodeUid) -> Result<()> {
#[async_trait]
impl DriveApi for FakeClient {
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId> {
self.request(false, |server| {
assert_eq!(uid, &server.root);
Ok(ShareId::new("my-files-share"))
})
.await
}
async fn get_my_files_folder(&self) -> Result<Node> {
self.request(false, |server| {
let root = server.root.clone();
+29 -3
View File
@@ -57,7 +57,10 @@ pub(crate) fn run(core: Core, control_socket: PathBuf) {
}
let mut last_rss = Instant::now();
let mut last_ping = Instant::now();
// `None` until the first probe, which runs on the first tick: startup has
// already used up much of the watchdog's time (`docs/BUGS.md` B279).
let mut last_ping: Option<Instant> = None;
let mut restored = false;
// Per-lane completion counts from the previous tick, to tell a queue that is
// being worked through from one that is not moving at all.
let mut last_completed = (0_u64, 0_u64);
@@ -84,16 +87,22 @@ pub(crate) fn run(core: Core, control_socket: PathBuf) {
// throw away in-flight uploads. The probe decides that; a stall only
// gets said out loud.
if let Some(period) = watchdog
&& last_ping.elapsed() >= period
&& ping_due(last_ping, period)
{
if probe(&control_socket) {
if !restored {
// Startup ran on the longer timeout the daemon asked for.
// The socket answers now, so the unit's own one applies.
systemd::restore_watchdog();
restored = true;
}
systemd::watchdog_ping();
} else {
warn!(
"daemon did not answer its own control socket; withholding the watchdog ping"
);
}
last_ping = Instant::now();
last_ping = Some(Instant::now());
}
}
}
@@ -165,6 +174,11 @@ fn report_stalls(report: &pdfs_core::control::Diagnostics, last_completed: (u64,
stalled
}
/// Whether the watchdog wants its next ping. The first one is due at once.
fn ping_due(last_ping: Option<Instant>, period: Duration) -> bool {
last_ping.is_none_or(|at| at.elapsed() >= period)
}
/// One round trip over the control socket: connect, ask for the cheapest
/// request there is, read the answer.
///
@@ -205,6 +219,18 @@ mod tests {
}
}
/// The first ping goes out on the first tick, not a full period after the
/// supervisor starts: startup has already spent most of the watchdog's time
/// (`docs/BUGS.md` B279).
#[test]
fn first_watchdog_ping_is_due_at_once() {
let period = Duration::from_secs(60);
assert!(ping_due(None, period));
assert!(!ping_due(Some(Instant::now()), period));
let long_ago = Instant::now().checked_sub(period).expect("uptime");
assert!(ping_due(Some(long_ago), period));
}
/// A worker doing normal work must not be reported: a WARN every tick is a
/// WARN nobody reads.
#[test]
+18 -1
View File
@@ -170,7 +170,24 @@ impl Core {
}
let started = Instant::now();
self.progress_begin(folder.id);
let result = if push_only {
// An attachment's root can be trashed or moved by the web app. A
// missing root must never turn into a scan of an empty remote tree.
let attached_root = if current.kind == "attached" {
self.attach_ancestors(&remote_root).and_then(|ancestors| {
if ancestors.contains(&self.primary_root_uid) {
Ok(())
} else {
Err(pdfs_core::CoreError::invalid(
"attached folder is no longer below My files",
))
}
})
} else {
Ok(())
};
let result = if let Err(error) = attached_root {
Err(PassAbort::Failed(error.to_string()))
} else if push_only {
self.push_pass(folder.id, &local_root, &remote_root, &rules)
} else {
match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) {
+31
View File
@@ -102,6 +102,37 @@ pub fn idle_sleep(duration: Duration) {
}
}
/// How long a mount may take to start before the watchdog counts it as hung.
///
/// Startup has no control socket to probe yet, and on a large account it is
/// long: hydrating 48 000 nodes took 70 s on 2026-11-12, and the secondary
/// mounts followed. Under the 2-minute `WatchdogSec` that run was aborted just
/// before its first ping, and left six dead mounts behind (`docs/BUGS.md` B279).
const STARTUP_WATCHDOG: Duration = Duration::from_secs(600);
/// Give the mount about to start [`STARTUP_WATCHDOG`] before the watchdog fires.
///
/// Sends `WATCHDOG_USEC=`, which replaces the unit's timeout and restarts its
/// timer. The supervisor puts the configured timeout back with
/// [`restore_watchdog`] once it can prove the daemon answers. A startup that
/// wedges for good is still aborted, only later.
pub fn stretch_watchdog_for_startup() {
if watchdog_period().is_some() {
notify(&format!("WATCHDOG_USEC={}\n", STARTUP_WATCHDOG.as_micros()));
}
}
/// Put back the timeout the unit configured, undoing
/// [`stretch_watchdog_for_startup`].
pub(crate) fn restore_watchdog() {
if watchdog_period().is_none() {
return;
}
if let Ok(usec) = std::env::var("WATCHDOG_USEC") {
notify(&format!("WATCHDOG_USEC={usec}\n"));
}
}
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
/// for a hang by the watchdog it is racing.
pub(crate) fn stopping() {
+29 -4
View File
@@ -588,6 +588,7 @@ fn platform_label(device_type: &str) -> &str {
/// How many folders this machine backs up, and whether any of them needs
/// attention — the one fact worth surfacing away from the folder list itself.
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
let folders: Vec<_> = folders.iter().filter(|f| f.kind == "device").collect();
if folders.is_empty() {
return gettext("No folders backed up yet");
}
@@ -939,7 +940,13 @@ pub(crate) fn reload_sync_pages(ui: &Rc<Ui>) {
}
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
pub(crate) fn prompt_remove_sync_folder(
ui: &Rc<Ui>,
id: i64,
path: &str,
ondemand: bool,
attached: bool,
) {
let win = ui_window(ui);
// The two modes leave the user in opposite places, so they can't share a
// sentence. A mirror folder's files are already on this disk and stay there.
@@ -947,7 +954,12 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
// in Proton Drive, so unmounting it leaves an empty directory — which is a
// nasty surprise if the dialog claimed the local files were safe, and is
// recoverable only by turning On-demand off *first* and letting it download.
let body = if ondemand {
let body = if attached {
gettext_f(
"Stop syncing “{path}”? Both the local files and the folder in My files are kept.",
&[("path", path)],
)
} else if ondemand {
// Translators: {path} is a local folder path.
gettext_f(
"Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.",
@@ -975,7 +987,9 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
.active(false)
.build();
group.add(&delete_remote);
dialog.set_extra_child(Some(&group));
if !attached {
dialog.set_extra_child(Some(&group));
}
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("remove", &gettext("Stop Syncing"));
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
@@ -988,7 +1002,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
&ui,
Request::RemoveSyncFolder {
id,
delete_remote: delete_remote.is_active(),
delete_remote: !attached && delete_remote.is_active(),
},
&gettext("Stopped syncing folder"),
&gettext("Couldn't stop syncing the folder"),
@@ -1291,6 +1305,7 @@ mod tests {
fn folder(state: &str) -> SyncFolderInfo {
SyncFolderInfo {
kind: "device".into(),
id: 1,
local_path: "/home/u/Documents".into(),
remote_uid: "vol~link".into(),
@@ -1303,6 +1318,16 @@ mod tests {
}
}
#[test]
fn attached_my_files_folders_do_not_count_as_device_backups() {
let mut attached = folder("error");
attached.kind = "attached".into();
assert_eq!(
this_computer_subtitle(&[attached]),
"No folders backed up yet"
);
}
#[test]
fn a_machine_with_no_folders_says_so() {
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
+27 -5
View File
@@ -1361,6 +1361,9 @@ fn location_row(ui: &Rc<Ui>, spec: &MountSpec) -> LocationRow {
prompt_mountpoint(&ui)
});
}
MountKind::Attached { sync_folder_id } => {
add_device_items(ui, &mut menu, spec, *sync_folder_id);
}
MountKind::Device { sync_folder_id } => {
row.add_suffix(&mode_button(ui, spec, *sync_folder_id));
add_device_items(ui, &mut menu, spec, *sync_folder_id);
@@ -1520,8 +1523,9 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
// The folder's *current* mode, not a queued one: a switch that hasn't landed
// yet has not moved the files anywhere.
let is_ondemand = spec.mode == MountMode::OnDemand;
let attached = matches!(spec.kind, MountKind::Attached { .. });
menu.item(&gettext("Stop Syncing…"), move || {
prompt_remove_sync_folder(&ui, id, &path, is_ondemand)
prompt_remove_sync_folder(&ui, id, &path, is_ondemand, attached)
});
}
@@ -1699,7 +1703,7 @@ fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
match kind {
MountKind::MyFiles => "folder-remote-symbolic",
MountKind::Device { .. } => "folder-symbolic",
MountKind::Device { .. } | MountKind::Attached { .. } => "folder-symbolic",
MountKind::Shared { .. } => "pdfs-people-symbolic",
}
}
@@ -1716,6 +1720,7 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
match &spec.kind {
MountKind::MyFiles => parts.push(gettext("My files")),
MountKind::Shared { .. } => parts.push(gettext("Shared folder")),
MountKind::Attached { .. } => parts.push(gettext("My files mirror")),
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
(Some(MountMode::OnDemand), _) => parts.push(gettext("Going online only")),
(Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")),
@@ -1725,7 +1730,10 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
(Some(MountMode::Unknown) | None, _) => parts.push(folder_mode_label(false)),
},
}
if matches!(spec.kind, MountKind::Device { .. }) {
if matches!(
spec.kind,
MountKind::Device { .. } | MountKind::Attached { .. }
) {
parts.push(match &spec.progress {
Some(p) => sync_progress_label(p),
None if spec.paused => pgettext("state", "paused"),
@@ -1737,8 +1745,10 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
}
// Only the locations that are supposed to have a session report its absence:
// a mirror folder never has one.
let expects_session = !matches!(spec.kind, MountKind::Device { .. })
|| spec.mode == MountMode::OnDemand
let expects_session = !matches!(
spec.kind,
MountKind::Device { .. } | MountKind::Attached { .. }
) || spec.mode == MountMode::OnDemand
|| spec.pending_mode == Some(MountMode::OnDemand);
if expects_session && !spec.mounted {
// Translators: a folder's files can't be opened right now.
@@ -1769,6 +1779,18 @@ mod tests {
}
}
#[test]
fn an_attached_mirror_is_available_without_a_fuse_session() {
let spec = spec(
MountKind::Attached { sync_folder_id: 7 },
MountMode::Mirror,
false,
);
let subtitle = location_subtitle(&spec);
assert!(subtitle.contains("My files mirror"), "{subtitle}");
assert!(!subtitle.contains("not available"), "{subtitle}");
}
#[test]
fn a_mirror_folder_is_never_reported_as_unmounted() {
// It is a plain local directory: no FUSE session is expected, so saying
+72
View File
@@ -109,6 +109,8 @@ a live account since.
| Entry | Title |
|---|---|
| [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed |
| [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back |
| [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds |
| [B277](#b277--my-files-mounted-over-a-folder-with-files-in-it-and-hid-them) | My files mounted over a folder with files in it, and hid them |
| [B274](#b274--opening-a-file-for-writing-fails-with-eio-while-its-queued-revision-lands) | Opening a file for writing fails with EIO while its queued revision lands |
@@ -381,6 +383,76 @@ a live account since.
Newest first.
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
**Status:** Fixed, not verified live
**Found:** 2026-11-12, after B279. 868 new pictures (15.6 GiB) were copied into
`~/ProtonDrive/Pictures`. Each restart reached 1.7 GiB resident and 9.8 GiB of swap within 40 s.
The 16 `pdfs-drain-*` threads were all busy, cgroup memory pressure (`full avg10`) was 77 %, and
the system's 15 GiB of swap was full. The watchdog killed the daemon, and the next start did the
same. The largest picture was a 97 MB JPEG of 11656×8742 pixels.
**Where:** `upload_thumbnails_with` and `scale_thumbnail` in `crates/pdfs-fuse/src/photos.rs`
**Repro:** Queue more large photos than there are drain workers, for example by copying a folder
of 100-megapixel JPEGs into the mount, and start the daemon.
**Cause.** Every upload of an image builds its thumbnail by decoding the whole file. Nothing
limited how many of these decodes ran at once, so all 16 drain workers did it together. One
102-megapixel JPEG holds about 300 MB of pixels, and twice that when it has to be turned upright.
Sixteen of them do not fit in memory, and `MemoryHigh=2G` throttled the daemon into swap until it
stopped answering.
**Fix.** Each thumbnail decode first takes a share of a process-wide 512 MiB budget. The share is
the encoded size plus 8 bytes per pixel, read from the image header. A 12-megapixel phone photo
still decodes beside four others. A 100-megapixel photo waits until no other decode runs, and
then runs alone. The upload path takes its share before it reads the file.
**Test:** `decode_budget_tests` in `crates/pdfs-fuse/src/photos.rs`. On 2026-11-12 the same queue
on the fixed build peaked at 1.3 GiB resident and 90 MB of swap, with memory pressure near 0 %.
---
### B279 — After a watchdog kill the mount never comes back
**Status:** Fixed, not verified live
**Found:** 2026-11-12, copying a folder of pictures into `~/ProtonDrive/Pictures`. The daemon
stopped answering, the watchdog aborted it after 4 h 39 min (2.5 GiB memory peak, 11.8 GiB swap
peak), and every restart then logged
`create mountpoint /home/narl/ProtonDrive: File exists (os error 17)`. After the dead mounts were
cleared by hand, the next start mounted all six folders and was aborted again, 120 s after it
began, leaving six dead mounts behind once more.
**Where:** `mount_once` in `crates/pdfs-cli/src/main.rs`; `prepare_mountpoint` in
`crates/pdfs-fuse/src/mount.rs`; `crates/pdfs-fuse/src/supervisor.rs`;
`crates/pdfs-fuse/src/systemd.rs`
**Repro:** Kill the daemon with `SIGABRT` while it is mounted. Each restart fails with `EEXIST`
until `fusermount3 -uz` clears every mount. Then restart on an account where hydrating the
metadata cache takes about a minute: the watchdog aborts the new daemon before it pings.
**Cause.** Two faults, one after the other.
1. A dead FUSE mount answers `ENOTCONN`. `create_dir_all` in `mount_once` read that as "absent",
tried `mkdir`, and failed with `EEXIST`. The daemon retried that error forever and never
reached `check_mountpoint`, which clears a dead mount.
2. systemd arms the watchdog when the process starts, but the supervisor sent its first ping one
full period (60 s) after it started. The supervisor starts only once the mount is live: 74 s
in on this account (70 s of that is hydrating 47 958 nodes). 74 s + 60 s is past the 120 s
`WatchdogSec`, so the run was aborted just before its first ping.
**Fix.** `mount_once` calls `prepare_mountpoint`, which clears a dead mount before it creates the
directory. Before startup, the daemon sends `WATCHDOG_USEC=` to allow 10 minutes. When the
supervisor first gets an answer from the control socket, it sends the unit's own timeout back.
The supervisor also probes and pings on its first tick, not one period later. A startup that
hangs for good is still aborted, only later.
The daemon stopped answering during the copy because of
[B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed).
**Test:** `first_watchdog_ping_is_due_at_once` in `crates/pdfs-fuse/src/supervisor.rs`. The
stale-mount path and the watchdog handshake need a live systemd unit to check.
---
### B278 — `sync list` and `devices list` show the last sync as epoch seconds
**Status:** Fixed, not verified live
+43
View File
@@ -11,6 +11,49 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased]
## [3.8.0] — 2026-11-12
Schema: **45**. Synced folders now tell device backups apart from folders attached to My files.
Existing folders stay device backups. Older builds cannot open the upgraded database.
### Added
- **Sync a local folder with a folder you already have in My files** (#67, #77).
`pdfs sync attach ~/Work --to Work` keeps `~/Work` an ordinary local folder and syncs it both
ways with My files/Work. The first sync uploads files that are only on this computer, downloads
files that are only on Proton Drive, and keeps both versions of a file that differs: the one on
Proton Drive keeps its name, and the local one becomes a conflict copy next to it and is
uploaded too. Attached folders are always kept on this computer, never online only. They are
listed apart from device backups and are not part of the device profile used to restore another
computer. Stopping one keeps both copies, and `--delete-remote` is refused for it. If the folder
on Proton Drive is trashed or moved out of My files, syncing stops with an error and the local
files stay. Attaching a folder is done from a terminal for now.
- **Tab completion for `pdfs`** in bash, zsh and fish, installed by every package. Besides
commands and options it completes paths in My files (`pdfs pin Doc<Tab>`) and what the running
service knows, each with a description: synced folders, queued changes, computers, trash, albums,
items shared with you, invitations, bookmarks and conflict copies, and once a path is typed, the
file's versions or the share's members and link. Without the service it still completes commands
and options. Elvish and PowerShell load it from `pdfs completions SHELL`, and so does a source
build; the [command-line reference](CLI.md#shell-completion) has the line for each shell.
`pdfs share` and `pdfs share-role` now check the role (`viewer`, `editor` or `admin`) before
sending the request, and `pdfs public-link` does the same for `viewer` and `editor`.
### Fixed
- **My files comes back after the service was killed for hanging** (B279). A killed service left
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
forever without opening anything, until the mounts were cleared by hand. The service now clears
a dead mount before it opens the folder. On a large account a restart that did open My files was
killed again, because loading the account took longer than the 2 minutes the service has to
report in. Startup now gets 10 minutes, and the 2 minutes apply again once the service answers.
- **Uploading many large photos no longer runs the computer out of memory** (B280). Every image
upload decodes the whole picture to make its thumbnail, and up to 16 did so at once. A batch of
100-megapixel photos took about 9 GiB, filled the swap, and the service was killed on every
restart. Thumbnails now share 512 MiB between them: phone photos are still made side by side,
and a very large photo waits and is made on its own.
## [3.7.0] — 2026-11-02
Schema: **44**, unchanged.
+33
View File
@@ -8,6 +8,7 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount
what the commands do in the app, see the [user guide](USER_GUIDE.md).
- [Conventions](#conventions)
- [Shell completion](#shell-completion)
- [Account and service](#account-and-service)
- [Files](#files)
- [Available offline](#available-offline)
@@ -32,6 +33,36 @@ what the commands do in the app, see the [user guide](USER_GUIDE.md).
service.
- **Output** is English and stable enough for `grep`; use `--json` where it is available.
## Shell completion
Tab completes commands and options, and also the values the service knows. Paths complete
from My files, and these complete with a description: synced-folder and queue ids, computers,
trash items, albums, items shared with you, invitations, bookmarks, conflict copies, and a file's
versions or a share's members and link once the path is typed. Values come from the running
service. Without it, only commands, options and local paths complete.
| Example | Completes |
|---|---|
| `pdfs pin Doc<Tab>` | `Documents/`; Tab again lists its contents |
| `pdfs sync rm <Tab>` | `1 /home/me/Documents (mirror)` … |
| `pdfs devices rm <Tab>` | computer uids, with their names |
| `pdfs versions restore Documents/a.pdf <Tab>` | version ids, with date and size |
The packages install completion for bash, zsh and fish, which these shells load by themselves. For
a source build, or another shell, load the script that `pdfs completions <shell>` prints:
| Shell | Add to |
|---|---|
| bash | `~/.bashrc`: `source <(pdfs completions bash)` |
| zsh | `~/.zshrc`, after `compinit`: `source <(pdfs completions zsh)` |
| fish | `~/.config/fish/config.fish`: `pdfs completions fish \| source` |
| elvish | `~/.config/elvish/rc.elv`: `eval (pdfs completions elvish \| slurp)` |
| PowerShell | `$PROFILE`: `pdfs completions powershell \| Out-String \| Invoke-Expression` |
The script runs `pdfs` from `PATH` on every Tab, so it keeps working after an update. A
`pdfs` that is not on `PATH` prints a script for its own location with
`PDFS_COMPLETE=<shell> ./pdfs`.
## Account and service
| Command | Description |
@@ -80,6 +111,7 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description |
|---|---|
| `pdfs sync add PATH` | Back up a local folder under this computer and keep it in sync. |
| `pdfs sync attach PATH --to REMOTE` | Merge a real local folder with an existing My files folder, keeping both versions on initial conflicts. |
| `pdfs sync list` | List synced folders with their IDs and state. |
| `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`). |
@@ -184,6 +216,7 @@ commands are for scripts and for cases where the mount is not available.
| `pdfs cache inspect [--deep]` | Database size, reclaimable space, row counts, cache use against the limit. `--deep` also runs SQLite's integrity check (slow). |
| `pdfs cache vacuum` | Compact the database. Needs free space for a second copy of it while it runs. |
| `pdfs cache clear` | Delete cached file content. Files kept available offline stay. |
| `pdfs completions SHELL` | Print the [shell completion](#shell-completion) script for bash, zsh, fish, elvish or powershell. |
[Troubleshooting](TROUBLESHOOTING.md) explains when to use each of these.
+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.7.0";
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.8.0";
```
Each release tag is built with Nix before the release is published.
+28 -1
View File
@@ -5,6 +5,7 @@ up to Proton Drive and kept in step with it. This page covers adding one, what t
that should not be backed up, changes that are still waiting to upload, conflicts, and pausing.
- [Add a folder](#add-a-folder)
- [Attach an existing My files folder](#attach-an-existing-my-files-folder)
- [Synced or online only](#synced-or-online-only)
- [Move files between synced folders and My files](#move-files-between-synced-folders-and-my-files)
- [Stop syncing a folder](#stop-syncing-a-folder)
@@ -74,7 +75,8 @@ again when they move into a synced one.
**Stop Syncing** in the app, or `pdfs sync rm ID`, keeps both the local files and the copy on
Proton Drive. To delete the copy on Proton Drive as well, tick **Also delete from Proton Drive**,
or add `--delete-remote`.
or add `--delete-remote`. Attached My files folders always keep the cloud copy;
`--delete-remote` is refused for them.
## Sync a folder from scratch
@@ -169,3 +171,28 @@ The version you do not keep goes to the Trash, so a wrong choice can be undone.
`pdfs sync resume` continues at once.
- **Limit bandwidth** under **Preferences → General → Network**, or with
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
## Attach an existing My files folder
Run `pdfs sync attach /path/to/local --to Work` to merge a real local directory
with the existing `Work` folder in My files. `--to` also accepts an absolute path
inside the My files mount. Both folders must already exist.
The local directory stays a normal mirrored directory. The first pass copies files
found on only one side, adopts identical files, and keeps a conflict copy when
versions differ; it does not propagate deletions. Later passes use ordinary
bidirectional sync, including deletions and the existing wipe guards.
The My files root, shared folders, overlapping sync roots, and local paths inside
or containing the cloud mount are refused. Use `pdfs sync list` to find the new
folder's ID and monitor its state. `pdfs sync rm ID` stops syncing and keeps both
copies. `--delete-remote` is refused for attached folders.
Attached folders support **mirror** mode only. Use the main My files mount for online-only
access. They are listed separately from device backups and are not included in the device
profile used to restore another computer.
On the first sync, the remote version keeps the original name when both versions differ.
The local version is preserved as a conflict copy next to it and is uploaded too. If the
remote root is trashed or moved outside My files, syncing stops with an error and local
files are kept.
+4 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=3.7.0
pkgver=3.8.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64')
@@ -80,5 +80,8 @@ package() {
# Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/usr/share/locale"
# Shell completion for bash, zsh and fish.
packaging/completions.sh "$rel/pdfs" "$pkgdir/usr/share"
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-bin
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
pkgver = 3.6.0
pkgver = 3.7.0
pkgrel = 1
url = https://proton-drive.narl.io
install = proton-drive.install
@@ -22,21 +22,21 @@ pkgbase = proton-drive-for-linux-bin
optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.6.0
provides = proton-drive-linux=3.6.0
provides = proton-drive-for-linux=3.7.0
provides = proton-drive-linux=3.7.0
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux
conflicts = proton-drive-for-linux-git
options = !strip
options = !debug
source = proton-drive-linux-3.6.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.6.0/proton-drive-linux-3.6.0-x86_64.tar.gz
source = io.narl.proton-drive-linux-3.6.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray-3.6.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux-3.6.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive-3.6.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/proton-drive.service
source = LICENSE-3.6.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/LICENSE
sha256sums = 8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7
source = proton-drive-linux-3.7.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.7.0/proton-drive-linux-3.7.0-x86_64.tar.gz
source = io.narl.proton-drive-linux-3.7.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray-3.7.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux-3.7.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive-3.7.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/proton-drive.service
source = LICENSE-3.7.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/LICENSE
sha256sums = 3f0d16c19b10b30e3f181e3c7fb439f0a8c38980e8ab7a4a96ae6596b349787c
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-bin
pkgver=3.6.0
pkgver=3.7.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
arch=('x86_64')
@@ -36,7 +36,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
"LICENSE-$pkgver::$_raw/LICENSE")
sha256sums=('8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7'
sha256sums=('3f0d16c19b10b30e3f181e3c7fb439f0a8c38980e8ab7a4a96ae6596b349787c'
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
@@ -64,6 +64,14 @@ package() {
install -dm755 "$pkgdir/usr/share"
cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale"
# Shell completion for bash, zsh and fish, written by the binary itself.
local share="$pkgdir/usr/share"
install -d "$share/bash-completion/completions" "$share/zsh/site-functions" \
"$share/fish/vendor_completions.d"
"$srcdir/pdfs" completions bash > "$share/bash-completion/completions/pdfs"
"$srcdir/pdfs" completions zsh > "$share/zsh/site-functions/_pdfs"
"$srcdir/pdfs" completions fish > "$share/fish/vendor_completions.d/pdfs.fish"
install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
# pacman brings new releases, so the app never checks for them itself.
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-git
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
pkgver = 3.6.0.r0.g2720191
pkgver = 3.7.0.r0.g7ab14bb
pkgrel = 1
url = https://proton-drive.narl.io
install = proton-drive.install
@@ -25,8 +25,8 @@ pkgbase = proton-drive-for-linux-git
optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.6.0.r0.g2720191
provides = proton-drive-linux=3.6.0.r0.g2720191
provides = proton-drive-for-linux=3.7.0.r0.g7ab14bb
provides = proton-drive-linux=3.7.0.r0.g7ab14bb
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-git
pkgver=3.6.0.r0.g2720191
pkgver=3.7.0.r0.g7ab14bb
pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
arch=('x86_64')
@@ -81,6 +81,9 @@ package() {
# Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/usr/share/locale"
# Shell completion for bash, zsh and fish.
packaging/completions.sh target/release/pdfs "$pkgdir/usr/share"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
pkgver = 3.6.0
pkgver = 3.7.0
pkgrel = 1
url = https://proton-drive.narl.io
install = proton-drive.install
@@ -24,13 +24,13 @@ pkgbase = proton-drive-for-linux
optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-linux=3.6.0
provides = proton-drive-linux=3.7.0
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux-bin
conflicts = proton-drive-for-linux-git
options = !lto
source = proton-drive-for-linux-3.6.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.6.0.tar.gz
sha256sums = d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d
source = proton-drive-for-linux-3.7.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.7.0.tar.gz
sha256sums = 542c534976e335cde6658de127e1eefffade9057d5332fd33457bc371c7a2737
pkgname = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux
pkgver=3.6.0
pkgver=3.7.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
arch=('x86_64')
@@ -26,7 +26,7 @@ options=('!lto')
# Stops the running daemon before removal, so the mount stops mirroring deletes.
install=proton-drive.install
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d')
sha256sums=('542c534976e335cde6658de127e1eefffade9057d5332fd33457bc371c7a2737')
_srcname="proton-drive-linux-$pkgver"
@@ -76,6 +76,9 @@ package() {
# Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/usr/share/locale"
# Shell completion for bash, zsh and fish.
packaging/completions.sh target/release/pdfs "$pkgdir/usr/share"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Write the shell completion scripts of a built `pdfs` into <datadir>, where
# bash-completion, zsh and fish look for them. Packages pass their install
# tree, e.g. "$pkgdir/usr/share". The scripts call `pdfs` from PATH on every
# Tab, so they are the same for every version and need no rebuild per release.
set -euo pipefail
if [ $# -ne 2 ]; then
echo "usage: $0 <pdfs binary> <datadir>" >&2
exit 2
fi
pdfs="$1"
datadir="$2"
install -d "$datadir/bash-completion/completions" \
"$datadir/zsh/site-functions" \
"$datadir/fish/vendor_completions.d"
"$pdfs" completions bash > "$datadir/bash-completion/completions/pdfs"
"$pdfs" completions zsh > "$datadir/zsh/site-functions/_pdfs"
"$pdfs" completions fish > "$datadir/fish/vendor_completions.d/pdfs.fish"
chmod 644 "$datadir/bash-completion/completions/pdfs" \
"$datadir/zsh/site-functions/_pdfs" \
"$datadir/fish/vendor_completions.d/pdfs.fish"
+4
View File
@@ -40,6 +40,7 @@ rustPlatform.buildRustPackage {
../io.narl.proton-drive-linux.desktop
../io.narl.proton-drive-linux-tray.desktop
../io.narl.proton-drive-linux.svg
../completions.sh
];
};
@@ -107,6 +108,9 @@ rustPlatform.buildRustPackage {
patchShebangs po/build.sh
po/build.sh $out/share/locale
patchShebangs packaging/completions.sh
packaging/completions.sh $out/bin/pdfs $out/share
# Nix brings new releases, so the app never checks for them itself.
install -Dm644 /dev/null $out/share/proton-drive-linux/managed-updates
'';
+5 -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.7.0}
%{!?version: %global version 3.8.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:.}
@@ -114,6 +114,7 @@ install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux.svg \
install -D -m0644 %{src}/packaging/proton-drive.service \
%{buildroot}/usr/lib/systemd/user/proton-drive.service
%{src}/po/build.sh %{buildroot}%{_datadir}/locale
%{src}/packaging/completions.sh "$rel/pdfs" %{buildroot}%{_datadir}
%if 0%{!?git_dir:1}
# dnf brings new releases from COPR, so the app does not offer its own check.
install -D -m0644 /dev/null %{buildroot}%{_datadir}/proton-drive-linux/managed-updates
@@ -149,6 +150,9 @@ fi
%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
/usr/lib/systemd/user/proton-drive.service
%{_datadir}/locale/*/LC_MESSAGES/pdfs.mo
%{_datadir}/bash-completion/completions/pdfs
%{_datadir}/zsh/site-functions/_pdfs
%{_datadir}/fish/vendor_completions.d/pdfs.fish
%if 0%{!?git_dir:1}
%{_datadir}/proton-drive-linux/managed-updates
%endif
+1 -1
View File
@@ -6,7 +6,7 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: proton-drive-linux 3.7.0\n"
"Project-Id-Version: proton-drive-linux 3.8.0\n"
"Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n"
"POT-Creation-Date: 2026-11-01 17:51+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"