mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-12 17:27:03 +01:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad648ab5cc | ||
|
|
2b95708260 | ||
|
|
c78bb529bc | ||
|
|
8d824af656 | ||
|
|
c77c548d7e | ||
|
|
d4a14a17a8 | ||
|
|
5ca0272f15 | ||
|
|
800705ccb0 | ||
|
|
7ab14bb8f7 |
No files matched your search
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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;
|
||||
";
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
Executable
+23
@@ -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"
|
||||
@@ -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
|
||||
'';
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user