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
19 changed files with 1013 additions and 40 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 packaging/proton-drive.service deb/usr/lib/systemd/user/
cp LICENSE deb/usr/share/licenses/proton-drive-linux/ cp LICENSE deb/usr/share/licenses/proton-drive-linux/
po/build.sh deb/usr/share/locale po/build.sh deb/usr/share/locale
packaging/completions.sh target/release/pdfs deb/usr/share
# Stops the running daemon before removal, so the mount stops mirroring deletes # Stops the running daemon before removal, so the mount stops mirroring deletes
install -m755 packaging/deb/prerm deb/DEBIAN/prerm install -m755 packaging/deb/prerm deb/DEBIAN/prerm
Generated
+26 -4
View File
@@ -754,6 +754,18 @@ dependencies = [
"strsim", "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]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.7" version = "4.6.7"
@@ -2551,6 +2563,15 @@ version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" 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]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@@ -3520,10 +3541,11 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "3.7.0" version = "3.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"clap_complete",
"pdfs-core", "pdfs-core",
"pdfs-fuse", "pdfs-fuse",
"rpassword", "rpassword",
@@ -3536,7 +3558,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "3.7.0" version = "3.8.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3563,7 +3585,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "3.7.0" version = "3.8.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3585,7 +3607,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "3.7.0" version = "3.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "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"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "3.7.0" version = "3.8.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
@@ -56,6 +56,9 @@ rusqlite = { version = "0.41", features = ["bundled"] }
keyring-core = "1" keyring-core = "1"
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] } dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
clap = { version = "4", features = ["derive"] } 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" rpassword = "7"
async-trait = "0.1" async-trait = "0.1"
futures = "0.3" 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 | | [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 | | [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
| [FAQ](docs/FAQ.md) | Short answers to common questions | | [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 | | [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones | | [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine | | [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
+1
View File
@@ -21,6 +21,7 @@ anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
clap.workspace = true clap.workspace = true
clap_complete.workspace = true
rpassword.workspace = true rpassword.workspace = true
serde_json.workspace = true serde_json.workspace = true
zeroize.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);
}
}
+116 -22
View File
@@ -8,7 +8,8 @@ use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context, Result, anyhow, bail}; 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::auth;
use pdfs_core::cache::ContentCache; use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
@@ -20,6 +21,7 @@ use pdfs_core::control::{
use pdfs_core::db::Db; use pdfs_core::db::Db;
use pdfs_core::service; use pdfs_core::service;
mod complete;
mod journal; mod journal;
/// `println!` for this binary, shadowing std's, which panics when stdout is /// `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 Proton Drive at the given (or default) path. Blocks until unmounted.
Mount { Mount {
/// Mountpoint; defaults to ~/ProtonDrive. /// Mountpoint; defaults to ~/ProtonDrive.
#[arg(value_hint = ValueHint::DirPath)]
mountpoint: Option<PathBuf>, mountpoint: Option<PathBuf>,
}, },
/// Run the auto-mount daemon: wait for login, mount, and keep it mounted /// 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. /// don't invoke it by hand.
Daemon { Daemon {
/// Mountpoint; defaults to ~/ProtonDrive. /// Mountpoint; defaults to ~/ProtonDrive.
#[arg(value_hint = ValueHint::DirPath)]
mountpoint: Option<PathBuf>, mountpoint: Option<PathBuf>,
}, },
/// Keep a file or folder on this device (download + cache it). A folder's /// Keep a file or folder on this device (download + cache it). A folder's
@@ -179,11 +183,13 @@ enum Command {
/// Needs a running mount. /// Needs a running mount.
Pin { Pin {
/// File or folder path, inside the mountpoint or relative to it. /// File or folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
}, },
/// Stop keeping a file or folder on this device, evicting its cached content. /// Stop keeping a file or folder on this device, evicting its cached content.
Unpin { Unpin {
/// File or folder path, inside the mountpoint or relative to it. /// File or folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
}, },
/// List files and folders pinned to this device. /// 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). /// List a directory via the running daemon (in-app browser backend).
Ls { Ls {
/// Directory path, inside the mountpoint or relative to it. Root if omitted. /// Directory path, inside the mountpoint or relative to it. Root if omitted.
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
path: Option<PathBuf>, path: Option<PathBuf>,
}, },
/// List the photos timeline (newest first) via the running daemon. /// 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. /// so an interrupted import can simply be run again.
ImportGooglePhotos { ImportGooglePhotos {
/// The export's `.zip` files (e.g. `takeout-*.zip`). /// The export's `.zip` files (e.g. `takeout-*.zip`).
#[arg(required = true)] #[arg(required = true, value_hint = ValueHint::FilePath)]
archives: Vec<PathBuf>, archives: Vec<PathBuf>,
/// Report what would be imported without uploading anything. /// Report what would be imported without uploading anything.
#[arg(long)] #[arg(long)]
@@ -277,6 +284,7 @@ enum Command {
/// List the photos in one album (newest capture first). /// List the photos in one album (newest capture first).
Album { Album {
/// Album node uid in `volume~link` form (from `pdfs albums`). /// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String, uid: String,
/// Max photos to list. /// Max photos to list.
#[arg(long, default_value_t = 50)] #[arg(long, default_value_t = 50)]
@@ -293,6 +301,7 @@ enum Command {
/// Rename a photo album. /// Rename a photo album.
RenameAlbum { RenameAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`). /// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String, uid: String,
/// New name. /// New name.
name: String, name: String,
@@ -300,6 +309,7 @@ enum Command {
/// Delete a photo album. Its photos stay in the timeline. /// Delete a photo album. Its photos stay in the timeline.
DeleteAlbum { DeleteAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`). /// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String, uid: String,
/// Also delete photos that exist only in this album (saved from a /// Also delete photos that exist only in this album (saved from a
/// shared album); without it the server refuses to delete such an album. /// shared album); without it the server refuses to delete such an album.
@@ -309,6 +319,7 @@ enum Command {
/// Add photos to an album. /// Add photos to an album.
AddToAlbum { AddToAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`). /// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String, uid: String,
/// Photo node uids (from `pdfs photos`). /// Photo node uids (from `pdfs photos`).
#[arg(required = true)] #[arg(required = true)]
@@ -317,6 +328,7 @@ enum Command {
/// Take photos out of an album. They stay in the timeline. /// Take photos out of an album. They stay in the timeline.
RemoveFromAlbum { RemoveFromAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`). /// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String, uid: String,
/// Photo node uids (from `pdfs album <uid>`). /// Photo node uids (from `pdfs album <uid>`).
#[arg(required = true)] #[arg(required = true)]
@@ -335,13 +347,14 @@ enum Command {
#[arg(long, default_value_t = 50)] #[arg(long, default_value_t = 50)]
limit: usize, limit: usize,
/// Only search below this folder (under the mountpoint). /// 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>, scope: Option<PathBuf>,
}, },
/// Rename a file or folder via the running daemon. /// Rename a file or folder via the running daemon.
Rename { Rename {
/// File/folder path, inside My files or an on-demand synced folder, or /// File/folder path, inside My files or an on-demand synced folder, or
/// relative to the mountpoint. /// relative to the mountpoint.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
/// New name (a single path component). /// New name (a single path component).
new_name: String, new_name: String,
@@ -355,7 +368,7 @@ enum Command {
Move { Move {
/// Files/folders to move, then the destination folder: each inside a /// Files/folders to move, then the destination folder: each inside a
/// location, or relative to the mountpoint. /// 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>, paths: Vec<PathBuf>,
}, },
/// Copy files or folders into another folder in My files via the running /// Copy files or folders into another folder in My files via the running
@@ -367,7 +380,7 @@ enum Command {
Copy { Copy {
/// Files/folders to copy, then the destination folder: inside My files, /// Files/folders to copy, then the destination folder: inside My files,
/// or relative to the mountpoint. /// 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>, paths: Vec<PathBuf>,
}, },
/// Show how much a folder holds on Proton Drive. /// 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. /// encrypted bytes on storage and includes items in the trash.
Du { Du {
/// Folder path, inside My files or relative to the mountpoint. /// Folder path, inside My files or relative to the mountpoint.
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
path: PathBuf, path: PathBuf,
}, },
/// Trash a file or folder via the running daemon. /// Trash a file or folder via the running daemon.
Rm { Rm {
/// File/folder path, inside My files or an on-demand synced folder, or /// File/folder path, inside My files or an on-demand synced folder, or
/// relative to the mountpoint. /// relative to the mountpoint.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
}, },
/// Create a folder via the running daemon. /// Create a folder via the running daemon.
Mkdir { Mkdir {
/// Parent folder path, inside My files or an on-demand synced folder, /// Parent folder path, inside My files or an on-demand synced folder,
/// or relative to the mountpoint. /// or relative to the mountpoint.
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
parent: PathBuf, parent: PathBuf,
/// New folder name (a single path component). /// New folder name (a single path component).
name: String, name: String,
@@ -397,10 +413,10 @@ enum Command {
/// background; watch progress with `pdfs transfers`. /// background; watch progress with `pdfs transfers`.
Upload { Upload {
/// Local files and/or folders to upload. /// Local files and/or folders to upload.
#[arg(required = true)] #[arg(required = true, value_hint = ValueHint::AnyPath)]
sources: Vec<PathBuf>, sources: Vec<PathBuf>,
/// Destination folder, inside the mountpoint or relative to it. /// 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, parent: PathBuf,
}, },
/// Show the daemon's in-flight transfers (active uploads/downloads). /// 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 trashed items, by uid, to the folders they were trashed from.
Restore { Restore {
/// Node uids (`volume~link`), as printed by `pdfs trash`. /// Node uids (`volume~link`), as printed by `pdfs trash`.
#[arg(required = true)] #[arg(required = true, add = ArgValueCandidates::new(complete::trash_uid))]
uids: Vec<String>, uids: Vec<String>,
}, },
/// Permanently delete trashed items by uid. This cannot be undone. /// Permanently delete trashed items by uid. This cannot be undone.
DeleteForever { DeleteForever {
/// Node uids (`volume~link`), as printed by `pdfs trash`. /// Node uids (`volume~link`), as printed by `pdfs trash`.
#[arg(required = true)] #[arg(required = true, add = ArgValueCandidates::new(complete::trash_uid))]
uids: Vec<String>, uids: Vec<String>,
}, },
/// Permanently delete everything in the trash. This cannot be undone. /// Permanently delete everything in the trash. This cannot be undone.
@@ -433,6 +449,7 @@ enum Command {
Refresh { Refresh {
/// What to refresh: a folder path (inside the mountpoint or relative to /// What to refresh: a folder path (inside the mountpoint or relative to
/// it), `trash`, or `photos`. The mount root if omitted. /// it), `trash`, or `photos`. The mount root if omitted.
#[arg(add = ArgValueCompleter::new(complete::refresh_target))]
target: Option<String>, target: Option<String>,
/// With `photos`: read every photo's metadata again instead of only what /// With `photos`: read every photo's metadata again instead of only what
/// changed. Minutes on a large library; for a gallery that disagrees with /// 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 a file or folder with Proton and/or external email addresses.
Share { Share {
/// File/folder path, inside the mountpoint or relative to it. /// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
/// One or more invitee emails (Proton or external, auto-detected). /// One or more invitee emails (Proton or external, auto-detected).
#[arg(required = true)] #[arg(required = true)]
emails: Vec<String>, emails: Vec<String>,
/// Role to grant: viewer, editor or admin. /// 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, role: String,
/// Optional message included in the invitation email. /// Optional message included in the invitation email.
#[arg(long)] #[arg(long)]
@@ -473,17 +491,21 @@ enum Command {
/// List a node's members, pending invitations and public link. /// List a node's members, pending invitations and public link.
Members { Members {
/// File/folder path, inside the mountpoint or relative to it. /// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
}, },
/// Change the role of a member or pending invitation on a node's share. /// Change the role of a member or pending invitation on a node's share.
ShareRole { ShareRole {
/// File/folder path, inside the mountpoint or relative to it. /// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
/// Entry id (membership or invitation id), from `pdfs members`. /// Entry id (membership or invitation id), from `pdfs members`.
#[arg(add = ArgValueCompleter::new(complete::share_entry_id))]
id: String, id: String,
/// Entry kind: member, proton or external. /// Entry kind: member, proton or external.
kind: ShareKindArg, kind: ShareKindArg,
/// New role: viewer, editor or admin. /// New role: viewer, editor or admin.
#[arg(value_parser = ["viewer", "editor", "admin"], ignore_case = true)]
role: String, role: String,
}, },
/// Stop sharing a node, or remove one member or pending invitation. /// 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. /// member. With an id and kind (from `pdfs members`), removes that entry.
Unshare { Unshare {
/// File/folder path, inside the mountpoint or relative to it. /// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
/// Entry id (membership or invitation id), from `pdfs members`. /// 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>, id: Option<String>,
/// Entry kind: member, proton or external. /// Entry kind: member, proton or external.
kind: Option<ShareKindArg>, kind: Option<ShareKindArg>,
@@ -515,18 +538,22 @@ enum Command {
SharedWithMe { SharedWithMe {
/// Folder uid to list into, in `volume~link` form (from a previous run). /// Folder uid to list into, in `volume~link` form (from a previous run).
/// Omit for the top level. /// Omit for the top level.
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
uid: Option<String>, uid: Option<String>,
}, },
/// Download a file shared with me by its uid (from `pdfs shared-with-me`). /// Download a file shared with me by its uid (from `pdfs shared-with-me`).
SharedGet { SharedGet {
/// Node uid in `volume~link` form. /// Node uid in `volume~link` form.
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
uid: String, uid: String,
/// Where to write it. Omit to leave it in the cache and print the path. /// Where to write it. Omit to leave it in the cache and print the path.
#[arg(value_hint = ValueHint::AnyPath)]
dest: Option<PathBuf>, dest: Option<PathBuf>,
}, },
/// Leave a shared node by its uid (from `pdfs shared-with-me`). /// Leave a shared node by its uid (from `pdfs shared-with-me`).
Leave { Leave {
/// Node uid in `volume~link` form. /// Node uid in `volume~link` form.
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
uid: String, uid: String,
}, },
/// Manage invitations addressed to me. /// Manage invitations addressed to me.
@@ -557,6 +584,17 @@ enum Command {
/// Runs without a daemon on purpose: the state worth diagnosing is usually /// Runs without a daemon on purpose: the state worth diagnosing is usually
/// the state where the daemon will not start. /// the state where the daemon will not start.
Diagnose, 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)] #[derive(Subcommand)]
@@ -604,6 +642,7 @@ enum VersionCmd {
/// List a file's versions, newest first. /// List a file's versions, newest first.
List { List {
/// File path, inside the mountpoint or relative to it. /// File path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
}, },
/// Make an earlier version the file's current content. /// Make an earlier version the file's current content.
@@ -612,25 +651,32 @@ enum VersionCmd {
/// moment to show up in the mount. /// moment to show up in the mount.
Restore { Restore {
/// File path, inside the mountpoint or relative to it. /// File path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
/// Version id, from `pdfs versions list`. /// Version id, from `pdfs versions list`.
#[arg(add = ArgValueCompleter::new(complete::version_id))]
id: String, id: String,
}, },
/// Write an earlier version to a local file, leaving the file untouched. /// Write an earlier version to a local file, leaving the file untouched.
Save { Save {
/// File path, inside the mountpoint or relative to it. /// File path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
/// Version id, from `pdfs versions list`. /// Version id, from `pdfs versions list`.
#[arg(add = ArgValueCompleter::new(complete::version_id))]
id: String, id: String,
/// Where to write it. Must not already exist. /// Where to write it. Must not already exist.
#[arg(value_hint = ValueHint::AnyPath)]
dest: PathBuf, dest: PathBuf,
}, },
/// Permanently delete an earlier version. The content is unrecoverable, and /// Permanently delete an earlier version. The content is unrecoverable, and
/// the version that is currently active cannot be deleted. /// the version that is currently active cannot be deleted.
Rm { Rm {
/// File path, inside the mountpoint or relative to it. /// File path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
/// Version id, from `pdfs versions list`. /// Version id, from `pdfs versions list`.
#[arg(add = ArgValueCompleter::new(complete::version_id))]
id: String, id: String,
}, },
} }
@@ -640,13 +686,21 @@ enum DeviceCmd {
/// List registered devices. /// List registered devices.
List, List,
/// Rename a device by uid. /// 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. /// 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 /// Adopt an existing device as this machine's, so a hostname change or a
/// reinstall re-attaches to it instead of registering a duplicate. /// reinstall re-attaches to it instead of registering a duplicate.
Adopt { Adopt {
/// Device uid (from `devices list`). Omit with `--clear`. /// Device uid (from `devices list`). Omit with `--clear`.
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: Option<String>, uid: Option<String>,
/// Drop the adoption and go back to matching by hostname. /// Drop the adoption and go back to matching by hostname.
#[arg(long, conflicts_with = "uid")] #[arg(long, conflicts_with = "uid")]
@@ -657,6 +711,7 @@ enum DeviceCmd {
/// here. Both computers keep them in step. /// here. Both computers keep them in step.
Join { Join {
/// Device uid (from `devices list`). /// Device uid (from `devices list`).
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: String, uid: String,
/// Skip the confirmation and accept every proposed local path. /// Skip the confirmation and accept every proposed local path.
#[arg(long)] #[arg(long)]
@@ -666,6 +721,7 @@ enum DeviceCmd {
/// them here. The other computer stays registered, without those folders. /// them here. The other computer stays registered, without those folders.
Migrate { Migrate {
/// Device uid (from `devices list`). /// Device uid (from `devices list`).
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: String, uid: String,
/// Skip the confirmation and accept every proposed local path. /// Skip the confirmation and accept every proposed local path.
#[arg(long)] #[arg(long)]
@@ -678,6 +734,7 @@ enum ConflictCmd {
/// Decide which side of a conflict to keep. Whatever is removed goes to Trash. /// Decide which side of a conflict to keep. Whatever is removed goes to Trash.
Resolve { Resolve {
/// The conflict copy, absolute or relative to the mountpoint. /// The conflict copy, absolute or relative to the mountpoint.
#[arg(add = ArgValueCandidates::new(complete::conflict_path))]
path: PathBuf, path: PathBuf,
/// `original` trashes the copy; `copy` trashes the original and gives the /// `original` trashes the copy; `copy` trashes the original and gives the
/// copy its name; `both` renames the copy to `--name`. /// copy its name; `both` renames the copy to `--name`.
@@ -694,15 +751,17 @@ enum SyncCmd {
/// Add a local folder to this machine's device and upload its contents. /// Add a local folder to this machine's device and upload its contents.
Add { Add {
/// Local directory to sync. /// Local directory to sync.
#[arg(value_hint = ValueHint::DirPath)]
path: PathBuf, path: PathBuf,
}, },
/// Sync a real local folder with an existing folder in My files. /// Sync a real local folder with an existing folder in My files.
/// The first pass preserves local-only files and keeps differing versions. /// The first pass preserves local-only files and keeps differing versions.
Attach { Attach {
/// Existing local directory; it remains a real local directory. /// Existing local directory; it remains a real local directory.
#[arg(value_hint = ValueHint::DirPath)]
path: PathBuf, path: PathBuf,
/// Existing folder in My files, relative to the cloud mount or inside it. /// Existing folder in My files, relative to the cloud mount or inside it.
#[arg(long)] #[arg(long, add = ArgValueCompleter::new(complete::drive_dir))]
to: PathBuf, to: PathBuf,
}, },
/// List this machine's synced folders. /// List this machine's synced folders.
@@ -710,6 +769,7 @@ enum SyncCmd {
/// Remove a synced folder by id. /// Remove a synced folder by id.
Rm { Rm {
/// Synced-folder id (from `sync list`). /// Synced-folder id (from `sync list`).
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
id: i64, id: i64,
/// Also delete the folder's copy in Proton Drive. /// Also delete the folder's copy in Proton Drive.
#[arg(long)] #[arg(long)]
@@ -718,6 +778,7 @@ enum SyncCmd {
/// Force a sync pass now (all folders, or one by id). /// Force a sync pass now (all folders, or one by id).
Now { Now {
/// Optional synced-folder id; omit to reconcile every folder. /// Optional synced-folder id; omit to reconcile every folder.
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
id: Option<i64>, id: Option<i64>,
}, },
/// Forget what a synced folder synced before and compare it with Proton /// Forget what a synced folder synced before and compare it with Proton
@@ -725,6 +786,7 @@ enum SyncCmd {
/// that differs keeps both versions. Nothing is deleted. /// that differs keeps both versions. Nothing is deleted.
Reset { Reset {
/// Synced-folder id (from `sync list`). /// Synced-folder id (from `sync list`).
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
id: i64, id: i64,
}, },
/// Pause syncing: queued uploads and mirror folders wait, reads keep working. /// Pause syncing: queued uploads and mirror folders wait, reads keep working.
@@ -734,13 +796,13 @@ enum SyncCmd {
#[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")] #[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")]
duration: Option<u64>, duration: Option<u64>,
/// Pause only this mirror folder (id from `sync list`) until resumed. /// 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>, folder: Option<i64>,
}, },
/// Resume syncing after `pdfs sync pause`. /// Resume syncing after `pdfs sync pause`.
Resume { Resume {
/// Resume only this folder, paused with `sync pause --folder`. /// Resume only this folder, paused with `sync pause --folder`.
#[arg(long)] #[arg(long, add = ArgValueCandidates::new(complete::sync_folder_id))]
folder: Option<i64>, folder: Option<i64>,
}, },
/// Cap upload and download speed, or show the caps with no options. /// Cap upload and download speed, or show the caps with no options.
@@ -759,8 +821,10 @@ enum SyncCmd {
/// Save the content of a queued upload (by id from `sync queue`) to a file. /// Save the content of a queued upload (by id from `sync queue`) to a file.
Export { Export {
/// Queue id. /// Queue id.
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
id: i64, id: i64,
/// Where to save it: a new file, or an existing folder to save it in. /// Where to save it: a new file, or an existing folder to save it in.
#[arg(value_hint = ValueHint::AnyPath)]
dest: PathBuf, dest: PathBuf,
}, },
/// Drop a queued change (by id from `sync queue`) and undo it here: a new file /// Drop a queued change (by id from `sync queue`) and undo it here: a new file
@@ -768,6 +832,7 @@ enum SyncCmd {
/// content is lost unless saved with `sync export` first. /// content is lost unless saved with `sync export` first.
Discard { Discard {
/// Queue id. /// Queue id.
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
id: i64, id: i64,
/// Do not ask first. /// Do not ask first.
#[arg(long)] #[arg(long)]
@@ -776,11 +841,13 @@ enum SyncCmd {
/// Retry a queued operation now (by id from `sync queue`), or every failed one. /// Retry a queued operation now (by id from `sync queue`), or every failed one.
Retry { Retry {
/// Queue id; omit to retry every failed operation. /// Queue id; omit to retry every failed operation.
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
id: Option<i64>, id: Option<i64>,
}, },
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage). /// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
Mode { Mode {
/// Synced-folder id (from `sync list`). /// Synced-folder id (from `sync list`).
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
id: i64, id: i64,
/// `mirror` (full local copy) or `ondemand` (FUSE mount, reclaims disk). /// `mirror` (full local copy) or `ondemand` (FUSE mount, reclaims disk).
#[arg(value_parser = ["mirror", "ondemand"])] #[arg(value_parser = ["mirror", "ondemand"])]
@@ -794,7 +861,7 @@ enum SyncCmd {
yes: bool, yes: bool,
/// Restore another computer's folders instead, by device uid (from /// Restore another computer's folders instead, by device uid (from
/// `pdfs devices list`). /// `pdfs devices list`).
#[arg(long)] #[arg(long, add = ArgValueCandidates::new(complete::device_uid))]
device: Option<String>, device: Option<String>,
}, },
} }
@@ -804,9 +871,10 @@ enum PublicLinkCmd {
/// Create a public link on a node. /// Create a public link on a node.
Create { Create {
/// File/folder path, inside the mountpoint or relative to it. /// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
/// Role: viewer or editor. /// Role: viewer or editor.
#[arg(long, default_value = "viewer")] #[arg(long, default_value = "viewer", value_parser = ["viewer", "editor"], ignore_case = true)]
role: String, role: String,
/// Optional custom password protecting the link. /// Optional custom password protecting the link.
#[arg(long)] #[arg(long)]
@@ -819,11 +887,13 @@ enum PublicLinkCmd {
/// given is kept. /// given is kept.
Update { Update {
/// File/folder path, inside the mountpoint or relative to it. /// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
/// Public-link id, from `pdfs members`. /// Public-link id, from `pdfs members`.
#[arg(add = ArgValueCompleter::new(complete::public_link_id))]
id: String, id: String,
/// New role: viewer or editor. /// New role: viewer or editor.
#[arg(long)] #[arg(long, value_parser = ["viewer", "editor"], ignore_case = true)]
role: Option<String>, role: Option<String>,
/// New custom password protecting the link. /// New custom password protecting the link.
#[arg(long, conflicts_with = "no_password")] #[arg(long, conflicts_with = "no_password")]
@@ -841,8 +911,10 @@ enum PublicLinkCmd {
/// Remove a node's public link. /// Remove a node's public link.
Remove { Remove {
/// File/folder path, inside the mountpoint or relative to it. /// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf, path: PathBuf,
/// Public-link id, from `pdfs members`. /// Public-link id, from `pdfs members`.
#[arg(add = ArgValueCompleter::new(complete::public_link_id))]
id: String, id: String,
}, },
} }
@@ -852,9 +924,15 @@ enum InvitationCmd {
/// List invitations addressed to me. /// List invitations addressed to me.
List, List,
/// Accept an invitation by id. /// Accept an invitation by id.
Accept { id: String }, Accept {
#[arg(add = ArgValueCandidates::new(complete::invitation_id))]
id: String,
},
/// Reject an invitation by id. /// Reject an invitation by id.
Reject { id: String }, Reject {
#[arg(add = ArgValueCandidates::new(complete::invitation_id))]
id: String,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -870,7 +948,10 @@ enum BookmarkCmd {
password: Option<String>, password: Option<String>,
}, },
/// Remove a saved bookmark by its token. /// 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. /// Render a daemon failure for a terminal.
@@ -890,6 +971,9 @@ fn cli_error(kind: ErrorKind, message: &str) -> String {
} }
fn main() -> Result<()> { 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() let filter = tracing_subscriber::EnvFilter::try_from_default_env()
// `pgp` logs a benign WARN when it re-serializes a Proton secret-key // `pgp` logs a benign WARN when it re-serializes a Proton secret-key
// packet with a longer (but equivalent) length encoding than the // packet with a longer (but equivalent) length encoding than the
@@ -1030,6 +1114,7 @@ fn main() -> Result<()> {
Command::Activity { limit } => cmd_activity(limit), Command::Activity { limit } => cmd_activity(limit),
Command::Quota => cmd_quota(), Command::Quota => cmd_quota(),
Command::Cache { action } => cmd_cache(action), Command::Cache { action } => cmd_cache(action),
Command::Completions { shell } => cmd_completions(shell),
Command::Diagnose => cmd_diagnose(), Command::Diagnose => cmd_diagnose(),
} }
} }
@@ -3806,6 +3891,15 @@ fn writable_probe(dir: &Path) -> std::io::Result<()> {
result.and(remove) 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 /// Self-check the installation, reporting each finding and exiting non-zero if
/// any check failed. /// any check failed.
/// ///
+10 -2
View File
@@ -2405,9 +2405,17 @@ impl Request {
/// ///
/// Shared by the CLI and GUI so both speak the wire format identically. /// Shared by the CLI and GUI so both speak the wire format identically.
pub fn send(socket: &Path, req: &Request) -> Result<Response> { 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)?; let mut stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(req.read_timeout()))?; stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(WRITE_TIMEOUT))?; stream.set_write_timeout(Some(timeout.min(WRITE_TIMEOUT)))?;
let mut line = serde_json::to_vec(req)?; let mut line = serde_json::to_vec(req)?;
line.push(b'\n'); line.push(b'\n');
stream.write_all(&line)?; stream.write_all(&line)?;
+39 -6
View File
@@ -11,15 +11,48 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
Schema: **45**. Synced folders now distinguish device backups from My files attachments. ## [3.8.0] — 2026-11-12
Existing folders remain device backups; older builds cannot open the upgraded database.
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 ### Added
- `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing - **Sync a local folder with a folder you already have in My files** (#67, #77).
My files folder. The first sync preserves one-sided files and differing versions (#67). `pdfs sync attach ~/Work --to Work` keeps `~/Work` an ordinary local folder and syncs it both
Attachments stay in mirror mode, are excluded from device profiles, and cannot delete ways with My files/Work. The first sync uploads files that are only on this computer, downloads
the remote root when syncing is stopped. 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 ## [3.7.0] — 2026-11-02
+32
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). what the commands do in the app, see the [user guide](USER_GUIDE.md).
- [Conventions](#conventions) - [Conventions](#conventions)
- [Shell completion](#shell-completion)
- [Account and service](#account-and-service) - [Account and service](#account-and-service)
- [Files](#files) - [Files](#files)
- [Available offline](#available-offline) - [Available offline](#available-offline)
@@ -32,6 +33,36 @@ what the commands do in the app, see the [user guide](USER_GUIDE.md).
service. service.
- **Output** is English and stable enough for `grep`; use `--json` where it is available. - **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 ## Account and service
| Command | Description | | Command | Description |
@@ -185,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 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 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 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. [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: the tag to update:
```nix ```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. Each release tag is built with Nix before the release is published.
+4 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=3.7.0 pkgver=3.8.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')
@@ -80,5 +80,8 @@ package() {
# Translations: compiled from po/ into /usr/share/locale. # Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/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" install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
} }
@@ -64,6 +64,14 @@ package() {
install -dm755 "$pkgdir/usr/share" install -dm755 "$pkgdir/usr/share"
cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale" 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" install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
# pacman brings new releases, so the app never checks for them itself. # pacman brings new releases, so the app never checks for them itself.
@@ -81,6 +81,9 @@ package() {
# Translations: compiled from po/ into /usr/share/locale. # Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/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 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
@@ -76,6 +76,9 @@ package() {
# Translations: compiled from po/ into /usr/share/locale. # Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/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 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" 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.desktop
../io.narl.proton-drive-linux-tray.desktop ../io.narl.proton-drive-linux-tray.desktop
../io.narl.proton-drive-linux.svg ../io.narl.proton-drive-linux.svg
../completions.sh
]; ];
}; };
@@ -107,6 +108,9 @@ rustPlatform.buildRustPackage {
patchShebangs po/build.sh patchShebangs po/build.sh
po/build.sh $out/share/locale 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. # Nix brings new releases, so the app never checks for them itself.
install -Dm644 /dev/null $out/share/proton-drive-linux/managed-updates 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. # Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
%global _lto_cflags %{nil} %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 %global forge https://github.com/narrrl/proton-drive-linux
# Files come from the checkout, or from the unpacked tag archive. # Files come from the checkout, or from the unpacked tag archive.
%global src %{?git_dir}%{!?git_dir:.} %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 \ install -D -m0644 %{src}/packaging/proton-drive.service \
%{buildroot}/usr/lib/systemd/user/proton-drive.service %{buildroot}/usr/lib/systemd/user/proton-drive.service
%{src}/po/build.sh %{buildroot}%{_datadir}/locale %{src}/po/build.sh %{buildroot}%{_datadir}/locale
%{src}/packaging/completions.sh "$rel/pdfs" %{buildroot}%{_datadir}
%if 0%{!?git_dir:1} %if 0%{!?git_dir:1}
# dnf brings new releases from COPR, so the app does not offer its own check. # 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 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 %{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
/usr/lib/systemd/user/proton-drive.service /usr/lib/systemd/user/proton-drive.service
%{_datadir}/locale/*/LC_MESSAGES/pdfs.mo %{_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} %if 0%{!?git_dir:1}
%{_datadir}/proton-drive-linux/managed-updates %{_datadir}/proton-drive-linux/managed-updates
%endif %endif
+1 -1
View File
@@ -6,7 +6,7 @@
#, fuzzy #, fuzzy
msgid "" msgid ""
msgstr "" 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" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n"
"POT-Creation-Date: 2026-11-01 17:51+0100\n" "POT-Creation-Date: 2026-11-01 17:51+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"