mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
800705ccb0 | ||
|
|
7ab14bb8f7 | ||
|
|
9c8b6680a7 | ||
|
|
bbd913b87d | ||
|
|
04f4610e4d | ||
|
|
0a5afd0b6a | ||
|
|
163c54a1e9 | ||
|
|
0d7c6e7bae | ||
|
|
10252fa84b | ||
|
|
bf9ec0862a | ||
|
|
d0d0c73a51 | ||
|
|
129ad1a50d | ||
|
|
506a610273 | ||
|
|
5048492a83 | ||
|
|
321547fb77 | ||
|
|
a06a8150fb | ||
|
|
c4bce50d35 | ||
|
|
d2b5916b99 | ||
|
|
4626ce17a7 | ||
|
|
630f316fe6 | ||
|
|
801681f747 | ||
|
|
e7382f2c7e | ||
|
|
7046f0f3ef | ||
|
|
37e8dae61b | ||
|
|
c52f2098f2 | ||
|
|
f4a2674b9c | ||
|
|
347861bbc1 | ||
|
|
c57763b07f | ||
|
|
846dfd8d14 | ||
|
|
2ae99de844 | ||
|
|
69ff216e7d | ||
|
|
b3ea564c6b | ||
|
|
463c97a14d | ||
|
|
40aa8016ea | ||
|
|
d1b7535696 | ||
|
|
ba414c4446 | ||
|
|
5b7f17bdaf | ||
|
|
73068f5868 | ||
|
|
b093010b64 | ||
|
|
176112a9cb | ||
|
|
7957e1ed59 | ||
|
|
53543c91f9 | ||
|
|
fccb3d9561 | ||
|
|
6f2b4bb80a | ||
|
|
a617b2b410 | ||
|
|
bdbfc0bfd4 | ||
|
|
e9577f953f | ||
|
|
639ca359fa |
No files matched your search
Generated
+395
-406
File diff suppressed because it is too large.
Load diff
+6
-6
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "3.6.0"
|
version = "3.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
|||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.10.0" }
|
proton-sdk = { version = "0.10.1" }
|
||||||
proton-drive-rs = { version = "0.10.0" }
|
proton-drive-rs = { version = "0.10.1" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
@@ -33,7 +33,7 @@ anyhow = "1"
|
|||||||
# wedge every later acquisition of it: `Workers` catches the unwind to keep the
|
# wedge every later acquisition of it: `Workers` catches the unwind to keep the
|
||||||
# pool alive, and `std::sync::Mutex` would defeat that by poisoning the state
|
# pool alive, and `std::sync::Mutex` would defeat that by poisoning the state
|
||||||
# the recovered worker needs.
|
# the recovered worker needs.
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.13"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "6"
|
directories = "6"
|
||||||
@@ -41,7 +41,7 @@ ignore = "0.4"
|
|||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
# Google Takeout exports are zip sets; the importer reads their central
|
# Google Takeout exports are zip sets; the importer reads their central
|
||||||
# directories and streams entries out without extracting to disk first.
|
# directories and streams entries out without extracting to disk first.
|
||||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||||
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||||
# import has to speak SHA-1 whatever its age.
|
# import has to speak SHA-1 whatever its age.
|
||||||
sha1 = "0.11"
|
sha1 = "0.11"
|
||||||
@@ -49,7 +49,7 @@ sha1 = "0.11"
|
|||||||
# formats a camera or phone actually produces — the rest of the crate's codecs
|
# formats a camera or phone actually produces — the rest of the crate's codecs
|
||||||
# would be dead weight in the daemon.
|
# would be dead weight in the daemon.
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||||
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
||||||
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
||||||
# with no `target` it matches items v3 wrote, so existing logins survive.
|
# with no `target` it matches items v3 wrote, so existing logins survive.
|
||||||
|
|||||||
+350
-43
@@ -1036,10 +1036,7 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
|
|||||||
CtlResponse::Devices { items } if items.is_empty() => println!("No devices."),
|
CtlResponse::Devices { items } if items.is_empty() => println!("No devices."),
|
||||||
CtlResponse::Devices { items } => {
|
CtlResponse::Devices { items } => {
|
||||||
for d in items {
|
for d in items {
|
||||||
let sync = d
|
let sync = last_synced(d.last_sync);
|
||||||
.last_sync
|
|
||||||
.map(|t| t.to_string())
|
|
||||||
.unwrap_or_else(|| "never".to_string());
|
|
||||||
let tag = match (d.adopted, d.this_device) {
|
let tag = match (d.adopted, d.this_device) {
|
||||||
(true, _) => " *adopted*",
|
(true, _) => " *adopted*",
|
||||||
(false, true) => " *this machine*",
|
(false, true) => " *this machine*",
|
||||||
@@ -1130,11 +1127,7 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
|||||||
}
|
}
|
||||||
CtlResponse::SyncFolders { items } => {
|
CtlResponse::SyncFolders { items } => {
|
||||||
for f in items {
|
for f in items {
|
||||||
let sync = if f.last_sync == 0 {
|
let sync = last_synced(Some(f.last_sync));
|
||||||
"never".to_string()
|
|
||||||
} else {
|
|
||||||
f.last_sync.to_string()
|
|
||||||
};
|
|
||||||
// A queued switch is reported as the mode the folder is moving
|
// A queued switch is reported as the mode the folder is moving
|
||||||
// to, so `sync list` never reads as if the request was dropped.
|
// to, so `sync list` never reads as if the request was dropped.
|
||||||
let mode = match &f.pending_mode {
|
let mode = match &f.pending_mode {
|
||||||
@@ -1467,7 +1460,11 @@ fn cmd_sync_restore(yes: bool, device: Option<String>) -> Result<()> {
|
|||||||
items: chosen,
|
items: chosen,
|
||||||
},
|
},
|
||||||
None => CtlRequest::RestoreSyncFolders { items: chosen },
|
None => CtlRequest::RestoreSyncFolders { items: chosen },
|
||||||
})?)
|
})?)?;
|
||||||
|
// The daemon acks before it works, so the outcome, and any folder it had
|
||||||
|
// to skip, only shows up later.
|
||||||
|
println!("Run `pdfs activity` to see which folders were restored.");
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expand a leading `~` in a hand-typed path. The daemon requires an absolute
|
/// Expand a leading `~` in a hand-typed path. The daemon requires an absolute
|
||||||
@@ -1911,6 +1908,15 @@ fn format_epoch(secs: i64) -> String {
|
|||||||
format!("{m:02}-{d:02} {h:02}:{mi:02}")
|
format!("{m:02}-{d:02} {h:02}:{mi:02}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `synced:` column of `sync list` and `devices list`: when the last pass
|
||||||
|
/// finished, or `never`. The daemon reports 0 for a folder that never synced.
|
||||||
|
fn last_synced(secs: Option<i64>) -> String {
|
||||||
|
match secs {
|
||||||
|
Some(secs) if secs > 0 => format_epoch(secs),
|
||||||
|
_ => "never".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_leave(uid: String) -> Result<()> {
|
fn cmd_leave(uid: String) -> Result<()> {
|
||||||
ok_or_bail(control_request(CtlRequest::LeaveShared { uid })?)
|
ok_or_bail(control_request(CtlRequest::LeaveShared { uid })?)
|
||||||
}
|
}
|
||||||
@@ -2384,12 +2390,21 @@ where
|
|||||||
/// an external unmount triggers a remount; errors back off and retry. This is
|
/// an external unmount triggers a remount; errors back off and retry. This is
|
||||||
/// the entry point for the systemd user service.
|
/// the entry point for the systemd user service.
|
||||||
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||||
|
let state_dir = AppDirs::new()?.state_dir();
|
||||||
|
// A failure an earlier daemon recorded is not this one's.
|
||||||
|
pdfs_core::mountfail::clear(&state_dir);
|
||||||
|
let mut failure: Option<pdfs_core::mountfail::MountFailure> = None;
|
||||||
loop {
|
loop {
|
||||||
// Wait until a session is stored. The GUI enables this service on login,
|
// Wait until a session is stored. The GUI enables this service on login,
|
||||||
// but the service may also start at boot before the user has logged in.
|
// but the service may also start at boot before the user has logged in.
|
||||||
wait_for_session(auth::ready, pdfs_fuse::idle_sleep)?;
|
wait_for_session(auth::ready, pdfs_fuse::idle_sleep)?;
|
||||||
|
|
||||||
match mount_once(mountpoint.clone()) {
|
let outcome = mount_once(mountpoint.clone());
|
||||||
|
if outcome.is_ok() {
|
||||||
|
// The mount came up, so the next failure starts a new run of them.
|
||||||
|
failure = None;
|
||||||
|
}
|
||||||
|
match outcome {
|
||||||
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
||||||
tracing::info!("daemon stopping");
|
tracing::info!("daemon stopping");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -2404,13 +2419,69 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
// `{:#}` so the whole anyhow chain lands in the log — the top
|
// `{:#}` so the whole anyhow chain lands in the log — the top
|
||||||
// context alone ("mount failed") never says *why*.
|
// context alone ("mount failed") never says *why*.
|
||||||
tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s");
|
let error = format!("{e:#}");
|
||||||
pdfs_fuse::idle_sleep(std::time::Duration::from_secs(5));
|
let now = unix_now();
|
||||||
|
let attempts = failure.as_ref().map_or(1, |f| f.attempts.saturating_add(1));
|
||||||
|
let delay = mount_retry_delay(attempts);
|
||||||
|
tracing::error!(
|
||||||
|
error,
|
||||||
|
attempts,
|
||||||
|
retry_secs = delay.as_secs(),
|
||||||
|
"mount failed; retrying"
|
||||||
|
);
|
||||||
|
let record = pdfs_core::mountfail::MountFailure {
|
||||||
|
error: error.clone(),
|
||||||
|
since: failure.as_ref().map_or(now, |f| f.since),
|
||||||
|
attempts,
|
||||||
|
next_attempt: now + delay.as_secs() as i64,
|
||||||
|
not_empty: not_empty_cause(&e),
|
||||||
|
};
|
||||||
|
if let Err(e) = pdfs_core::mountfail::record(&state_dir, &record) {
|
||||||
|
tracing::warn!(error = %e, "could not record the mount failure");
|
||||||
|
}
|
||||||
|
pdfs_fuse::notify_status(&format!("Mount failing: {error}"));
|
||||||
|
failure = Some(record);
|
||||||
|
pdfs_fuse::idle_sleep(delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The folder in the way, when `error` is a mount refused over a folder that
|
||||||
|
/// already has files in it. The refusal travels inside an `io::Error`, whose
|
||||||
|
/// `source` skips the error it wraps, so each link is unwrapped by hand.
|
||||||
|
fn not_empty_cause(error: &anyhow::Error) -> Option<pdfs_core::mountpoint::NotEmpty> {
|
||||||
|
error.chain().find_map(|cause| {
|
||||||
|
cause
|
||||||
|
.downcast_ref::<pdfs_core::mountpoint::NotEmpty>()
|
||||||
|
.or_else(|| {
|
||||||
|
cause
|
||||||
|
.downcast_ref::<std::io::Error>()?
|
||||||
|
.get_ref()?
|
||||||
|
.downcast_ref::<pdfs_core::mountpoint::NotEmpty>()
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long to wait before mount attempt `attempts + 1`, after `attempts`
|
||||||
|
/// failures in a row.
|
||||||
|
///
|
||||||
|
/// Doubles from 5 s to a 2-minute ceiling. A flat 5 s retry sent about four
|
||||||
|
/// requests to Proton per attempt, tens of thousands a day from an account
|
||||||
|
/// whose mount fails on a key, which no retry fixes (`docs/BUGS.md` B263). The
|
||||||
|
/// ceiling stays at minutes because a cold start without a network fails the
|
||||||
|
/// same way, and must mount soon after the network returns.
|
||||||
|
fn mount_retry_delay(attempts: u32) -> std::time::Duration {
|
||||||
|
const BASE_SECS: u64 = 5;
|
||||||
|
const MAX_SECS: u64 = 120;
|
||||||
|
|
||||||
|
let factor = 1u64
|
||||||
|
.checked_shl(attempts.saturating_sub(1))
|
||||||
|
.unwrap_or(u64::MAX);
|
||||||
|
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
|
||||||
|
}
|
||||||
|
|
||||||
/// One mount run: resume the session, mount, and block until the mount ends
|
/// One mount run: resume the session, mount, and block until the mount ends
|
||||||
/// (clean shutdown or external unmount). Returns the [`MountOutcome`] so the
|
/// (clean shutdown or external unmount). Returns the [`MountOutcome`] so the
|
||||||
/// daemon loop can decide whether to exit or remount.
|
/// daemon loop can decide whether to exit or remount.
|
||||||
@@ -2476,14 +2547,30 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
"could not save the refreshed session tokens; the next start may need `pdfs login`"
|
"could not save the refreshed session tokens; the next start may need `pdfs login`"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Dropping the runtime waits for every blocking task, however long; a call
|
release_runtime(rt, matches!(result, Ok(pdfs_fuse::MountOutcome::Shutdown)));
|
||||||
// still stuck on Drive would hold the stop until systemd kills it (B165).
|
|
||||||
rt.shutdown_timeout(std::time::Duration::from_secs(2));
|
|
||||||
|
|
||||||
let outcome = result.context("mount failed")?;
|
let outcome = result.context("mount failed")?;
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Let go of a mount's runtime once the mount has ended.
|
||||||
|
///
|
||||||
|
/// On a stop the process exits right after, so the runtime is left running
|
||||||
|
/// for it to end with. Shutting it down first broke every thread the mount
|
||||||
|
/// does not join, such as a size lookup or a profile backup a request started
|
||||||
|
/// during the stop: its next call to Drive panicked with "A Tokio 1.x context
|
||||||
|
/// was found, but it is being shutdown" (`docs/BUGS.md` B276). A mount that
|
||||||
|
/// is about to be tried again does shut it down, because a new runtime
|
||||||
|
/// replaces it. Dropping it would wait for every blocking task, however long;
|
||||||
|
/// a call still stuck on Drive would hold the retry (B165).
|
||||||
|
fn release_runtime(rt: tokio::runtime::Runtime, exiting: bool) {
|
||||||
|
if exiting {
|
||||||
|
std::mem::forget(rt);
|
||||||
|
} else {
|
||||||
|
rt.shutdown_timeout(std::time::Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_pin(path: PathBuf) -> Result<()> {
|
fn cmd_pin(path: PathBuf) -> Result<()> {
|
||||||
match control_request(CtlRequest::Pin {
|
match control_request(CtlRequest::Pin {
|
||||||
path: path_arg(&path)?,
|
path: path_arg(&path)?,
|
||||||
@@ -3399,6 +3486,79 @@ fn cmd_sync_limit(up: Option<u64>, down: Option<u64>) -> Result<()> {
|
|||||||
})?)
|
})?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long the oldest write may wait to upload before `diagnose` calls it
|
||||||
|
/// out. Under a day it is a slow link or a big file; past it, something is
|
||||||
|
/// holding the queue.
|
||||||
|
const STAGED_STALE_SECS: u64 = 86_400;
|
||||||
|
|
||||||
|
/// `diagnose` findings for the queue and the pause, from the `Status` reply.
|
||||||
|
/// `paused_for` is how long a timed pause still runs, in seconds.
|
||||||
|
fn sync_health(
|
||||||
|
failing_ops: u64,
|
||||||
|
failing_error: Option<&str>,
|
||||||
|
staged_bytes: u64,
|
||||||
|
staged_oldest_secs: u64,
|
||||||
|
paused: bool,
|
||||||
|
paused_for: Option<i64>,
|
||||||
|
) -> Vec<(DiagnoseLevel, &'static str, String)> {
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
if failing_ops > 0 {
|
||||||
|
findings.push((
|
||||||
|
DiagnoseLevel::Warn,
|
||||||
|
" failing writes",
|
||||||
|
match failing_error {
|
||||||
|
Some(error) => format!("{failing_ops} keep failing; the last error: {error}"),
|
||||||
|
None => format!("{failing_ops} keep failing"),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if staged_bytes > 0 && staged_oldest_secs >= STAGED_STALE_SECS {
|
||||||
|
findings.push((
|
||||||
|
DiagnoseLevel::Warn,
|
||||||
|
" oldest unsent write",
|
||||||
|
format!(
|
||||||
|
"{} old, {} only on this computer",
|
||||||
|
human_secs(staged_oldest_secs),
|
||||||
|
human_bytes(staged_bytes)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if paused {
|
||||||
|
findings.push((
|
||||||
|
DiagnoseLevel::Warn,
|
||||||
|
" sync",
|
||||||
|
match paused_for {
|
||||||
|
Some(left) if left > 0 => {
|
||||||
|
format!("paused for another {}", human_secs(left as u64))
|
||||||
|
}
|
||||||
|
_ => "paused until resumed (`pdfs sync resume`)".to_string(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
findings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `diagnose` findings for the synced folders that need someone to look.
|
||||||
|
fn folder_health(
|
||||||
|
folders: &[pdfs_core::control::SyncFolderInfo],
|
||||||
|
) -> Vec<(DiagnoseLevel, String, String)> {
|
||||||
|
folders
|
||||||
|
.iter()
|
||||||
|
.filter_map(|folder| {
|
||||||
|
let what = match folder.state.as_str() {
|
||||||
|
"error" => "sync error; `pdfs sync list` and the activity feed say more",
|
||||||
|
"conflict" => "conflict copies to look at",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((
|
||||||
|
DiagnoseLevel::Warn,
|
||||||
|
format!(" synced folder [{}]", folder.id),
|
||||||
|
format!("{}: {what}", folder.local_path),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn unix_now() -> i64 {
|
fn unix_now() -> i64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@@ -3598,26 +3758,6 @@ fn executable_on_path(name: &str) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mounted_fs_type(path: &Path) -> Option<String> {
|
|
||||||
let wanted = path.canonicalize().ok()?;
|
|
||||||
let mounts = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
|
|
||||||
mounts
|
|
||||||
.lines()
|
|
||||||
.filter_map(|line| {
|
|
||||||
let fields: Vec<_> = line.split_whitespace().collect();
|
|
||||||
let separator = fields.iter().position(|field| *field == "-")?;
|
|
||||||
let encoded = *fields.get(4)?;
|
|
||||||
let decoded = encoded.replace("\\040", " ").replace("\\011", "\t");
|
|
||||||
let mountpoint = PathBuf::from(decoded);
|
|
||||||
if mountpoint.canonicalize().ok()? == wanted {
|
|
||||||
Some(fields.get(separator + 1)?.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.next()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create, sync, and remove a new file without ever replacing an existing one.
|
/// Create, sync, and remove a new file without ever replacing an existing one.
|
||||||
fn writable_probe(dir: &Path) -> std::io::Result<()> {
|
fn writable_probe(dir: &Path) -> std::io::Result<()> {
|
||||||
let name = format!(".pdfs-diagnose-probe-{}", std::process::id());
|
let name = format!(".pdfs-diagnose-probe-{}", std::process::id());
|
||||||
@@ -3735,21 +3875,26 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
let socket_exists = socket.exists();
|
let socket_exists = socket.exists();
|
||||||
let socket_kind_ok =
|
let socket_kind_ok =
|
||||||
std::fs::symlink_metadata(&socket).is_ok_and(|m| m.file_type().is_socket());
|
std::fs::symlink_metadata(&socket).is_ok_and(|m| m.file_type().is_socket());
|
||||||
|
// A stale socket file with no listener is the common failure, so the reply
|
||||||
|
// is what decides this rather than the file's existence.
|
||||||
|
let status = control_request(CtlRequest::Status);
|
||||||
report.finding(
|
report.finding(
|
||||||
if socket_exists && !socket_kind_ok {
|
if socket_exists && !socket_kind_ok {
|
||||||
DiagnoseLevel::Fail
|
DiagnoseLevel::Fail
|
||||||
} else if socket_exists {
|
} else if socket_exists && status.is_ok() {
|
||||||
DiagnoseLevel::Ok
|
DiagnoseLevel::Ok
|
||||||
} else {
|
} else {
|
||||||
DiagnoseLevel::Warn
|
DiagnoseLevel::Warn
|
||||||
},
|
},
|
||||||
" control socket",
|
" control socket",
|
||||||
socket.display().to_string(),
|
if socket_exists && socket_kind_ok && status.is_err() {
|
||||||
|
format!("{} (left over; nothing listens on it)", socket.display())
|
||||||
|
} else {
|
||||||
|
socket.display().to_string()
|
||||||
|
},
|
||||||
);
|
);
|
||||||
// A stale socket file with no listener is the common failure, so the reply
|
|
||||||
// is what decides this rather than the file's existence.
|
|
||||||
let mut daemon_responding = false;
|
let mut daemon_responding = false;
|
||||||
match control_request(CtlRequest::Status) {
|
match status {
|
||||||
Ok(CtlResponse::Status {
|
Ok(CtlResponse::Status {
|
||||||
mountpoint,
|
mountpoint,
|
||||||
online,
|
online,
|
||||||
@@ -3757,6 +3902,12 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
tokens_unsaved,
|
tokens_unsaved,
|
||||||
pending_uploads,
|
pending_uploads,
|
||||||
pending_changes,
|
pending_changes,
|
||||||
|
failing_ops,
|
||||||
|
failing_error,
|
||||||
|
staged_bytes,
|
||||||
|
staged_oldest_secs,
|
||||||
|
paused,
|
||||||
|
paused_until,
|
||||||
version,
|
version,
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
@@ -3769,7 +3920,7 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
outdated_daemon_note(&version),
|
outdated_daemon_note(&version),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let live_type = mounted_fs_type(Path::new(&mountpoint));
|
let live_type = pdfs_core::mountpoint::mounted_fs_type(Path::new(&mountpoint));
|
||||||
let is_fuse = live_type
|
let is_fuse = live_type
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|kind| kind.starts_with("fuse"));
|
.is_some_and(|kind| kind.starts_with("fuse"));
|
||||||
@@ -3831,6 +3982,27 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
format!("{pending} waiting to upload")
|
format!("{pending} waiting to upload")
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// The rest of the daemon's state that the report used to drop:
|
||||||
|
// writes that keep failing, a pause, a folder in error. Each of
|
||||||
|
// them stops changes from reaching Drive while everything above
|
||||||
|
// reads as fine (`docs/BUGS.md` B268).
|
||||||
|
for (level, check, detail) in sync_health(
|
||||||
|
failing_ops,
|
||||||
|
failing_error.as_deref(),
|
||||||
|
staged_bytes,
|
||||||
|
staged_oldest_secs,
|
||||||
|
paused,
|
||||||
|
paused_until.map(|until| until - unix_now()),
|
||||||
|
) {
|
||||||
|
report.finding(level, check, detail);
|
||||||
|
}
|
||||||
|
if let Ok(CtlResponse::SyncFolders { items }) =
|
||||||
|
control_request(CtlRequest::ListSyncFolders)
|
||||||
|
{
|
||||||
|
for (level, check, detail) in folder_health(&items) {
|
||||||
|
report.finding(level, &check, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
match await_integrity_check() {
|
match await_integrity_check() {
|
||||||
Ok(CtlResponse::CacheReport {
|
Ok(CtlResponse::CacheReport {
|
||||||
integrity_problems,
|
integrity_problems,
|
||||||
@@ -3887,7 +4059,23 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
" daemon responding",
|
" daemon responding",
|
||||||
format!("stopped or unreachable: {e:#}"),
|
format!("stopped or unreachable: {e:#}"),
|
||||||
);
|
);
|
||||||
println!(" (start it with `systemctl --user start proton-drive.service`)");
|
match pdfs_core::mountfail::read(&dirs.state_dir()) {
|
||||||
|
// A daemon whose mount keeps failing never opens the socket,
|
||||||
|
// so this is the only place its reason shows (B263).
|
||||||
|
Some(failure) => report.finding(
|
||||||
|
DiagnoseLevel::Fail,
|
||||||
|
" mount",
|
||||||
|
format!(
|
||||||
|
"failing for {}, {} attempt(s): {}",
|
||||||
|
human_secs(unix_now().saturating_sub(failure.since).max(0) as u64),
|
||||||
|
failure.attempts,
|
||||||
|
failure.error
|
||||||
|
),
|
||||||
|
),
|
||||||
|
None => println!(
|
||||||
|
" (start it with `systemctl --user start proton-drive.service`)"
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3974,6 +4162,47 @@ fn control_request(req: CtlRequest) -> Result<CtlResponse> {
|
|||||||
.with_context(|| format!("talk to mount daemon at {}", socket.display()))
|
.with_context(|| format!("talk to mount daemon at {}", socket.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod release_runtime_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// A thread the mount never joined still reaches Drive after a stop. It
|
||||||
|
/// used to panic on its first timer once the runtime was shut down.
|
||||||
|
#[test]
|
||||||
|
fn a_thread_left_running_at_a_stop_can_still_use_the_runtime() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
let handle = rt.handle().clone();
|
||||||
|
let (released_tx, released_rx) = std::sync::mpsc::channel();
|
||||||
|
let straggler = std::thread::spawn(move || {
|
||||||
|
released_rx.recv().unwrap();
|
||||||
|
handle.block_on(async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||||
|
7
|
||||||
|
})
|
||||||
|
});
|
||||||
|
release_runtime(rt, true);
|
||||||
|
released_tx.send(()).unwrap();
|
||||||
|
assert_eq!(straggler.join().expect("the thread must not panic"), 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mount_tried_again_shuts_its_runtime_down() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
|
||||||
|
rt.spawn(async move {
|
||||||
|
let _done = done_tx;
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
});
|
||||||
|
release_runtime(rt, false);
|
||||||
|
// The task is dropped with the runtime, and its sender with it.
|
||||||
|
assert_eq!(
|
||||||
|
done_rx.recv_timeout(Duration::from_secs(5)),
|
||||||
|
Err(std::sync::mpsc::RecvTimeoutError::Disconnected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod pause_tests {
|
mod pause_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -4012,6 +4241,50 @@ mod pause_tests {
|
|||||||
mod diagnose_tests {
|
mod diagnose_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A healthy queue says nothing; writes that keep failing, an old unsent
|
||||||
|
/// write and a pause each say so (`docs/BUGS.md` B268).
|
||||||
|
#[test]
|
||||||
|
fn a_stuck_queue_or_a_pause_is_reported() {
|
||||||
|
assert!(sync_health(0, None, 0, 0, false, None).is_empty());
|
||||||
|
assert!(sync_health(0, None, 4096, STAGED_STALE_SECS - 1, false, None).is_empty());
|
||||||
|
let checks: Vec<&str> = sync_health(
|
||||||
|
2,
|
||||||
|
Some("upload a.txt: quota"),
|
||||||
|
4096,
|
||||||
|
STAGED_STALE_SECS,
|
||||||
|
true,
|
||||||
|
Some(600),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(level, check, _)| {
|
||||||
|
assert!(matches!(level, DiagnoseLevel::Warn));
|
||||||
|
check.trim()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(checks, ["failing writes", "oldest unsent write", "sync"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #53: a folder that would not sync, and `diagnose` found nothing.
|
||||||
|
#[test]
|
||||||
|
fn a_synced_folder_in_error_is_reported() {
|
||||||
|
let folder = |id: i64, state: &str| pdfs_core::control::SyncFolderInfo {
|
||||||
|
id,
|
||||||
|
local_path: format!("/home/u/f{id}"),
|
||||||
|
remote_uid: "vol~link".into(),
|
||||||
|
mode: "mirror".into(),
|
||||||
|
state: state.into(),
|
||||||
|
last_sync: 0,
|
||||||
|
pending_mode: None,
|
||||||
|
progress: None,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
|
let findings =
|
||||||
|
folder_health(&[folder(1, "idle"), folder(2, "error"), folder(3, "syncing")]);
|
||||||
|
assert_eq!(findings.len(), 1);
|
||||||
|
assert_eq!(findings[0].1.trim(), "synced folder [2]");
|
||||||
|
assert!(findings[0].2.starts_with("/home/u/f2: sync error"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finding_severity_is_counted_centrally() {
|
fn finding_severity_is_counted_centrally() {
|
||||||
let mut report = DiagnoseReport::default();
|
let mut report = DiagnoseReport::default();
|
||||||
@@ -4047,6 +4320,32 @@ mod diagnose_tests {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod login_wait_tests {
|
mod login_wait_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The mount retry backs off from 5 s to a ceiling of minutes, rather than
|
||||||
|
/// asking Proton every 5 s forever (`docs/BUGS.md` B263).
|
||||||
|
#[test]
|
||||||
|
fn a_failing_mount_backs_off_to_a_ceiling() {
|
||||||
|
let secs: Vec<u64> = (1..=8).map(|n| mount_retry_delay(n).as_secs()).collect();
|
||||||
|
assert_eq!(secs, [5, 10, 20, 40, 80, 120, 120, 120]);
|
||||||
|
assert_eq!(mount_retry_delay(u32::MAX).as_secs(), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A refusal over a folder with files in it reaches the failure record
|
||||||
|
/// through the `io::Error` and the context around it (`docs/BUGS.md` B277).
|
||||||
|
#[test]
|
||||||
|
fn a_mount_refused_over_files_is_recorded_as_such() {
|
||||||
|
let found = pdfs_core::mountpoint::NotEmpty {
|
||||||
|
path: PathBuf::from("/home/you/ProtonDrive"),
|
||||||
|
entries: vec!["Work".into()],
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
let error =
|
||||||
|
anyhow::Error::new(std::io::Error::other(found.clone())).context("mount failed");
|
||||||
|
assert_eq!(not_empty_cause(&error), Some(found));
|
||||||
|
let other =
|
||||||
|
anyhow::Error::new(std::io::Error::other("missing key")).context("mount failed");
|
||||||
|
assert_eq!(not_empty_cause(&other), None);
|
||||||
|
}
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4155,6 +4454,14 @@ mod login_wait_tests {
|
|||||||
assert_eq!(format_epoch_utc(-86_400), "1969-12-31");
|
assert_eq!(format_epoch_utc(-86_400), "1969-12-31");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_synced_shows_a_time_not_epoch_seconds() {
|
||||||
|
// B278: issue #46 showed `synced: 1793570850`.
|
||||||
|
assert_eq!(last_synced(Some(1_793_570_850)), "11-01 22:07");
|
||||||
|
assert_eq!(last_synced(Some(0)), "never");
|
||||||
|
assert_eq!(last_synced(None), "never");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_forged_looking_invitation_is_flagged_not_stated() {
|
fn a_forged_looking_invitation_is_flagged_not_stated() {
|
||||||
let mut entry = pdfs_core::control::DirEntry {
|
let mut entry = pdfs_core::control::DirEntry {
|
||||||
|
|||||||
@@ -621,10 +621,20 @@ impl ContentCache {
|
|||||||
for entry in rd.flatten() {
|
for entry in rd.flatten() {
|
||||||
let name = entry.file_name();
|
let name = entry.file_name();
|
||||||
let Some(name) = name.to_str() else { continue };
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
// A `.meta` tags the blob named like it without the suffix. One
|
||||||
|
// whose blob is gone is what an eviction leaves between its two
|
||||||
|
// removals, by a crash or by a store of the same uid landing in
|
||||||
|
// between, and nothing else would ever remove it.
|
||||||
|
if let Some(blob) = name.strip_suffix(".meta") {
|
||||||
|
if !dir.join(blob).exists() {
|
||||||
|
let _ = std::fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// `.geom` is a block-geometry sidecar (see `GeometryMeta`), tens
|
// `.geom` is a block-geometry sidecar (see `GeometryMeta`), tens
|
||||||
// of bytes and not a cached block; like `.meta` it is neither
|
// of bytes and not a cached block; like `.meta` it is neither
|
||||||
// budgeted nor evictable on its own.
|
// budgeted nor evictable on its own.
|
||||||
if name.ends_with(".meta") || name.ends_with(".geom") {
|
if name.ends_with(".geom") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// A `.tmp` is a staging file from a store that never completed
|
// A `.tmp` is a staging file from a store that never completed
|
||||||
@@ -2250,6 +2260,28 @@ mod tests {
|
|||||||
assert!(total <= 8, "budget enforced after reconcile, total={total}");
|
assert!(total <= 8, "budget enforced after reconcile, total={total}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An eviction removes the blob, then its tag. A crash in between, or a
|
||||||
|
/// store of the same uid landing in between, left the tag on its own, and
|
||||||
|
/// nothing removed it.
|
||||||
|
#[test]
|
||||||
|
fn a_tag_without_its_blob_is_swept_at_open() {
|
||||||
|
let dir = TempDir::new();
|
||||||
|
let content = dir.path().join("content");
|
||||||
|
let pins = dir.path().join("pins.json");
|
||||||
|
let db = Arc::new(Db::open_in_memory().unwrap());
|
||||||
|
{
|
||||||
|
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
|
||||||
|
c.store(&uid("kept"), 1, 4, b"kept").unwrap();
|
||||||
|
c.store(&uid("gone"), 1, 4, b"gone").unwrap();
|
||||||
|
std::fs::remove_file(c.blob_path(&uid("gone"))).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let c = ContentCache::open(content, pins, 0, db).unwrap();
|
||||||
|
assert!(!c.meta_path(&uid("gone")).exists());
|
||||||
|
assert!(c.meta_path(&uid("kept")).exists());
|
||||||
|
assert_eq!(c.read_range(&uid("kept"), 1, 4, 0, 4).unwrap(), b"kept");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stale_metadata_is_a_miss() {
|
fn stale_metadata_is_a_miss() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
|
|||||||
@@ -160,13 +160,14 @@ impl CoreError {
|
|||||||
/// telling the user their connection is down beats telling them the
|
/// telling the user their connection is down beats telling them the
|
||||||
/// server refused. The SDK's other failures say what went wrong instead
|
/// server refused. The SDK's other failures say what went wrong instead
|
||||||
/// (see [`proton_kind`]).
|
/// (see [`proton_kind`]).
|
||||||
|
///
|
||||||
|
/// Callers also hand it this crate's [`Error`] from the cache and the
|
||||||
|
/// database, which is not a transport error at all. Taking those for
|
||||||
|
/// "offline" told the user their connection was down when a pin failed on
|
||||||
|
/// the database (`docs/BUGS.md` B266), so the source chain is searched for
|
||||||
|
/// either type before falling back.
|
||||||
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
|
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
|
||||||
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
|
Self::new(api_kind_of(e), format!("{what}: {e}"))
|
||||||
Some(proton) => proton_kind(proton),
|
|
||||||
// Not the SDK's at all: a timeout around the call, or the socket.
|
|
||||||
None => ErrorKind::Offline,
|
|
||||||
};
|
|
||||||
Self::new(kind, format!("{what}: {e}"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prefix the message while keeping the classification.
|
/// Prefix the message while keeping the classification.
|
||||||
@@ -208,7 +209,13 @@ impl From<serde_json::Error> for CoreError {
|
|||||||
|
|
||||||
impl From<Error> for CoreError {
|
impl From<Error> for CoreError {
|
||||||
fn from(e: Error) -> Self {
|
fn from(e: Error) -> Self {
|
||||||
let kind = match &e {
|
Self::new(core_kind(&e), e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this crate's [`Error`] means to a front-end.
|
||||||
|
fn core_kind(e: &Error) -> ErrorKind {
|
||||||
|
match e {
|
||||||
Error::NotLoggedIn
|
Error::NotLoggedIn
|
||||||
| Error::ReloginRequired
|
| Error::ReloginRequired
|
||||||
| Error::Locked
|
| Error::Locked
|
||||||
@@ -216,11 +223,26 @@ impl From<Error> for CoreError {
|
|||||||
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
||||||
Error::Proton(proton) => proton_kind(proton),
|
Error::Proton(proton) => proton_kind(proton),
|
||||||
_ => ErrorKind::Internal,
|
_ => ErrorKind::Internal,
|
||||||
};
|
|
||||||
Self::new(kind, e.to_string())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The kind for [`CoreError::from_api`]: the first SDK or crate error in `e`'s
|
||||||
|
/// source chain decides it.
|
||||||
|
fn api_kind_of(e: &(dyn std::error::Error + 'static)) -> ErrorKind {
|
||||||
|
let mut cause = Some(e);
|
||||||
|
while let Some(err) = cause {
|
||||||
|
if let Some(proton) = err.downcast_ref::<proton_sdk::ProtonError>() {
|
||||||
|
return proton_kind(proton);
|
||||||
|
}
|
||||||
|
if let Some(core) = err.downcast_ref::<Error>() {
|
||||||
|
return core_kind(core);
|
||||||
|
}
|
||||||
|
cause = err.source();
|
||||||
|
}
|
||||||
|
// Not the SDK's at all: a timeout around the call, or the socket.
|
||||||
|
ErrorKind::Offline
|
||||||
|
}
|
||||||
|
|
||||||
/// What a failed SDK call means to a front-end.
|
/// What a failed SDK call means to a front-end.
|
||||||
///
|
///
|
||||||
/// Only a transport failure is the connection. The rest happened on this
|
/// Only a transport failure is the connection. The rest happened on this
|
||||||
@@ -414,6 +436,20 @@ mod tests {
|
|||||||
assert_ne!(e.kind, ErrorKind::Offline);
|
assert_ne!(e.kind, ErrorKind::Offline);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A pin or a search that fails on the database is not the connection, so
|
||||||
|
/// it must not say "You're offline" (`docs/BUGS.md` B266). An SDK error
|
||||||
|
/// wrapped in this crate's type classifies as the SDK error does.
|
||||||
|
#[test]
|
||||||
|
fn a_database_error_handed_to_from_api_is_not_offline() {
|
||||||
|
let db = Error::Other("database is locked".into());
|
||||||
|
assert_eq!(CoreError::from_api(&db, "pin").kind, ErrorKind::Internal);
|
||||||
|
let wrapped = Error::Proton(api(C::DoesNotExist));
|
||||||
|
assert_eq!(
|
||||||
|
CoreError::from_api(&wrapped, "load node").kind,
|
||||||
|
ErrorKind::NotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `Error` is the plumbing type; converting it must classify as sharply as
|
/// `Error` is the plumbing type; converting it must classify as sharply as
|
||||||
/// the direct path, or the two disagree about the same failure.
|
/// the direct path, or the two disagree about the same failure.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ pub mod kernelkey;
|
|||||||
pub mod keycheck;
|
pub mod keycheck;
|
||||||
pub mod localindex;
|
pub mod localindex;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
|
pub mod mountfail;
|
||||||
|
pub mod mountpoint;
|
||||||
pub mod mounts;
|
pub mod mounts;
|
||||||
pub mod opener;
|
pub mod opener;
|
||||||
pub mod places;
|
pub mod places;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//! Why the daemon's mount keeps failing, for the front ends to read.
|
||||||
|
//!
|
||||||
|
//! The control socket opens only once a mount has succeeded, so a daemon whose
|
||||||
|
//! mount fails on every try had no way to say why. The app and the tray showed
|
||||||
|
//! "Connecting…" for days while the reason sat in the journal (`docs/BUGS.md`
|
||||||
|
//! B263). The daemon now writes its last failure to a small file in the state
|
||||||
|
//! directory, next to the control socket, and removes it once a mount is up.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The file's name inside the state directory.
|
||||||
|
const FILE_NAME: &str = "mount-failure.json";
|
||||||
|
|
||||||
|
/// The last failed mount attempt.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MountFailure {
|
||||||
|
/// The error, in English, as the journal has it.
|
||||||
|
pub error: String,
|
||||||
|
/// Unix seconds of the first failure in this run of failures.
|
||||||
|
pub since: i64,
|
||||||
|
/// Failed attempts in a row.
|
||||||
|
pub attempts: u32,
|
||||||
|
/// Unix seconds of the next attempt.
|
||||||
|
pub next_attempt: i64,
|
||||||
|
/// Set when the mount was refused because the folder already has files in
|
||||||
|
/// it, so the app can explain that in the user's language. Absent in a
|
||||||
|
/// file an older daemon wrote.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub not_empty: Option<crate::mountpoint::NotEmpty>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the failure is kept for the state directory `state_dir`.
|
||||||
|
pub fn path(state_dir: &Path) -> PathBuf {
|
||||||
|
state_dir.join(FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record `failure`, replacing the one before it. Written to a temporary file
|
||||||
|
/// and renamed, so a reader never sees half of it.
|
||||||
|
pub fn record(state_dir: &Path, failure: &MountFailure) -> std::io::Result<()> {
|
||||||
|
let json = serde_json::to_vec(failure).map_err(std::io::Error::other)?;
|
||||||
|
let tmp = state_dir.join(format!("{FILE_NAME}.tmp"));
|
||||||
|
std::fs::write(&tmp, json)?;
|
||||||
|
std::fs::rename(&tmp, path(state_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the failure: a mount is up, or a new daemon has started.
|
||||||
|
pub fn clear(state_dir: &Path) {
|
||||||
|
let _ = std::fs::remove_file(path(state_dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recorded failure, if there is one and it can be read.
|
||||||
|
pub fn read(state_dir: &Path) -> Option<MountFailure> {
|
||||||
|
let bytes = std::fs::read(path(state_dir)).ok()?;
|
||||||
|
serde_json::from_slice(&bytes).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_recorded_failure_reads_back_until_cleared() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pdfs-mountfail-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
assert_eq!(read(&dir), None);
|
||||||
|
let failure = MountFailure {
|
||||||
|
error: "fetch My Files root: missing key".into(),
|
||||||
|
since: 1_700_000_000,
|
||||||
|
attempts: 3,
|
||||||
|
next_attempt: 1_700_000_040,
|
||||||
|
not_empty: None,
|
||||||
|
};
|
||||||
|
record(&dir, &failure).unwrap();
|
||||||
|
assert_eq!(read(&dir), Some(failure));
|
||||||
|
clear(&dir);
|
||||||
|
assert_eq!(read(&dir), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failure_an_older_daemon_wrote_still_reads() {
|
||||||
|
let old = r#"{"error":"missing key","since":1,"attempts":2,"next_attempt":3}"#;
|
||||||
|
let failure: MountFailure = serde_json::from_str(old).unwrap();
|
||||||
|
assert_eq!(failure.not_empty, None);
|
||||||
|
assert_eq!(failure.attempts, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
//! Whether a folder can take the My files mount without hiding anything.
|
||||||
|
//!
|
||||||
|
//! FUSE mounts over a folder that already has files in it without a word. The
|
||||||
|
//! files then sit under the mount, out of sight: not uploaded, and back only
|
||||||
|
//! once the service stops, so they look deleted (`docs/BUGS.md` B277, #67).
|
||||||
|
//! The daemon refuses such a folder, and the app refuses it when it is picked.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The file system type the kernel reports for this client's own mounts.
|
||||||
|
pub const OWN_FS_TYPE: &str = "fuse.protondrive";
|
||||||
|
|
||||||
|
/// How many of a folder's entries an error names.
|
||||||
|
const NAMED_ENTRIES: usize = 3;
|
||||||
|
|
||||||
|
/// A folder that already has something in it.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NotEmpty {
|
||||||
|
/// The folder.
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// The first few names in it, sorted.
|
||||||
|
pub entries: Vec<String>,
|
||||||
|
/// How many entries it has in all.
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for NotEmpty {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{} is not empty (holds ", self.path.display())?;
|
||||||
|
write!(f, "{}", self.entries.join(", "))?;
|
||||||
|
let rest = self.count.saturating_sub(self.entries.len());
|
||||||
|
if rest > 0 {
|
||||||
|
write!(f, " and {rest} more")?;
|
||||||
|
}
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"); My files would hide those files. Move them out, or choose another folder for My files"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for NotEmpty {}
|
||||||
|
|
||||||
|
/// What is in `dir`, if anything. A folder that does not exist yet is empty.
|
||||||
|
/// Hidden entries count: a `.directory` or a `.Trash-1000` hidden under the
|
||||||
|
/// mount is as lost as any other file.
|
||||||
|
pub fn not_empty(dir: &Path) -> std::io::Result<Option<NotEmpty>> {
|
||||||
|
let read = match std::fs::read_dir(dir) {
|
||||||
|
Ok(read) => read,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
let mut names = read
|
||||||
|
.map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
|
||||||
|
.collect::<std::io::Result<Vec<_>>>()?;
|
||||||
|
if names.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
names.sort();
|
||||||
|
let count = names.len();
|
||||||
|
names.truncate(NAMED_ENTRIES);
|
||||||
|
Ok(Some(NotEmpty {
|
||||||
|
path: dir.to_path_buf(),
|
||||||
|
entries: names,
|
||||||
|
count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file system type mounted at `path`, as `/proc/self/mountinfo` names it
|
||||||
|
/// (`ext4`, `fuse.protondrive`, …), or `None` when nothing is mounted there.
|
||||||
|
pub fn mounted_fs_type(path: &Path) -> Option<String> {
|
||||||
|
let wanted = path.canonicalize().ok()?;
|
||||||
|
let mounts = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
|
||||||
|
mounts
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let fields: Vec<_> = line.split_whitespace().collect();
|
||||||
|
let separator = fields.iter().position(|field| *field == "-")?;
|
||||||
|
let encoded = *fields.get(4)?;
|
||||||
|
let decoded = encoded.replace("\\040", " ").replace("\\011", "\t");
|
||||||
|
let mountpoint = PathBuf::from(decoded);
|
||||||
|
if mountpoint.canonicalize().ok()? == wanted {
|
||||||
|
Some(fields.get(separator + 1)?.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("pdfs-mountpoint-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_or_missing_folder_takes_the_mount() {
|
||||||
|
let dir = temp_dir("empty");
|
||||||
|
assert_eq!(not_empty(&dir).unwrap(), None);
|
||||||
|
assert_eq!(not_empty(&dir.join("missing")).unwrap(), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_with_files_is_refused_and_named() {
|
||||||
|
let dir = temp_dir("files");
|
||||||
|
for name in ["report.pdf", "Notes", ".directory", "zeta.txt"] {
|
||||||
|
std::fs::write(dir.join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
let found = not_empty(&dir).unwrap().expect("must refuse");
|
||||||
|
assert_eq!(found.count, 4);
|
||||||
|
assert_eq!(found.entries, [".directory", "Notes", "report.pdf"]);
|
||||||
|
let message = found.to_string();
|
||||||
|
assert!(message.contains("is not empty"), "{message}");
|
||||||
|
assert!(
|
||||||
|
message.contains(".directory, Notes, report.pdf and 1 more"),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hidden_file_alone_is_enough_to_refuse() {
|
||||||
|
let dir = temp_dir("hidden");
|
||||||
|
std::fs::write(dir.join(".Trash-1000"), b"").unwrap();
|
||||||
|
let found = not_empty(&dir).unwrap().expect("must refuse");
|
||||||
|
assert_eq!(found.count, 1);
|
||||||
|
assert!(!found.to_string().contains("more"));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_folder_is_not_a_mount() {
|
||||||
|
let dir = temp_dir("plain");
|
||||||
|
assert_eq!(mounted_fs_type(&dir), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1332,8 +1332,8 @@ mod tests {
|
|||||||
));
|
));
|
||||||
let file = std::fs::File::create(&path).unwrap();
|
let file = std::fs::File::create(&path).unwrap();
|
||||||
let mut zip = zip::ZipWriter::new(file);
|
let mut zip = zip::ZipWriter::new(file);
|
||||||
let options: zip::write::FileOptions<'_, ()> =
|
let options = zip::write::SimpleFileOptions::default()
|
||||||
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
.compression_method(zip::CompressionMethod::Stored);
|
||||||
for (name, body) in files {
|
for (name, body) in files {
|
||||||
zip.start_file(*name, options).unwrap();
|
zip.start_file(*name, options).unwrap();
|
||||||
zip.write_all(body).unwrap();
|
zip.write_all(body).unwrap();
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ struct ControlHandlerPermit;
|
|||||||
impl ControlHandlerPermit {
|
impl ControlHandlerPermit {
|
||||||
fn acquire() -> Option<Self> {
|
fn acquire() -> Option<Self> {
|
||||||
ACTIVE_CONTROL_HANDLERS
|
ACTIVE_CONTROL_HANDLERS
|
||||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| {
|
.try_update(Ordering::AcqRel, Ordering::Acquire, |active| {
|
||||||
(active < MAX_CONTROL_HANDLERS).then_some(active + 1)
|
(active < MAX_CONTROL_HANDLERS).then_some(active + 1)
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
@@ -1526,9 +1526,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
match core.restore_sync_folders(&items, device.as_deref()) {
|
match core.restore_sync_folders(&items, device.as_deref()) {
|
||||||
Ok(message) => {
|
Ok(outcome) => {
|
||||||
core.touch_profile();
|
core.touch_profile();
|
||||||
core.log_activity(ActivityKind::Download, &message, "", true);
|
if !outcome.complete() {
|
||||||
|
warn!(skipped = ?outcome.skipped, "restore skipped folders");
|
||||||
|
}
|
||||||
|
core.log_activity(
|
||||||
|
ActivityKind::Download,
|
||||||
|
outcome.summary(),
|
||||||
|
outcome.skipped.join("; "),
|
||||||
|
outcome.complete(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = %e, "restore failed");
|
warn!(error = %e, "restore failed");
|
||||||
|
|||||||
@@ -1281,6 +1281,13 @@ impl Core {
|
|||||||
sent: (&sent.0, &sent.1),
|
sent: (&sent.0, &sent.1),
|
||||||
landed: (&home.to_string(), &name),
|
landed: (&home.to_string(), &name),
|
||||||
};
|
};
|
||||||
|
// Counted from the op's retirement until the bytes are cached and the
|
||||||
|
// tree has the real uid, as an upload is. Uncounted, the queue read
|
||||||
|
// empty while that still ran: a delete in between took an open file
|
||||||
|
// for closed and dropped what its handle reads, and a script that
|
||||||
|
// waited for an empty queue read the file before it was cached
|
||||||
|
// (`docs/BUGS.md` B257).
|
||||||
|
let _landing = LandingUpload::begin(&self.landing_uploads);
|
||||||
let newer = match self.retire_create(op, uploaded, &landing, &local, &real)? {
|
let newer = match self.retire_create(op, uploaded, &landing, &local, &real)? {
|
||||||
CreateRetired::Withdrawn { trash } => {
|
CreateRetired::Withdrawn { trash } => {
|
||||||
return self.trash_withdrawn_create(op, trash, &local, &real, &name);
|
return self.trash_withdrawn_create(op, trash, &local, &real, &name);
|
||||||
|
|||||||
@@ -1434,6 +1434,14 @@ impl ProtonFs {
|
|||||||
// (`ContentCache::clone_or_copy`).
|
// (`ContentCache::clone_or_copy`).
|
||||||
if let Err(e) = ContentCache::clone_or_copy(&pending.path, &path) {
|
if let Err(e) = ContentCache::clone_or_copy(&pending.path, &path) {
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
// The drain lets go of the pending entry before it
|
||||||
|
// discards the blob, so a blob that went away under the
|
||||||
|
// copy is a revision that landed or was replaced: the
|
||||||
|
// same race the re-check below catches, only earlier.
|
||||||
|
if self.core.pending_blob(&uid).as_ref() != Some(&pending.path) {
|
||||||
|
warn!(%uid, attempt, "queued revision went away while opening for write; retrying");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
error!(%uid, source = %pending.path.display(), error = %e,
|
error!(%uid, source = %pending.path.display(), error = %e,
|
||||||
"copy queued revision into write scratch failed");
|
"copy queued revision into write scratch failed");
|
||||||
break 'install Err(Errno::EIO);
|
break 'install Err(Errno::EIO);
|
||||||
@@ -2368,7 +2376,7 @@ impl ProtonFs {
|
|||||||
// regardless — the file is gone from their point of view the moment the
|
// regardless — the file is gone from their point of view the moment the
|
||||||
// command returns. The same when the network goes
|
// command returns. The same when the network goes
|
||||||
// away under the remote call below.
|
// away under the remote call below.
|
||||||
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) {
|
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name, open_now) {
|
||||||
Ok(()) => reply.ok(),
|
Ok(()) => reply.ok(),
|
||||||
Err(e) => reply.error(e),
|
Err(e) => reply.error(e),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ use mount::{
|
|||||||
};
|
};
|
||||||
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
|
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
|
||||||
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
|
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
|
||||||
pub use systemd::idle_sleep;
|
pub use systemd::{idle_sleep, status as notify_status};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
|
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
|
||||||
use r#virtual::{
|
use r#virtual::{
|
||||||
@@ -3999,7 +3999,12 @@ impl Core {
|
|||||||
/// the cached ones, as when the trash is sent at once: a partial write
|
/// the cached ones, as when the trash is sent at once: a partial write
|
||||||
/// fills its gaps from them, offline too (`docs/BUGS.md` B186). The final
|
/// fills its gaps from them, offline too (`docs/BUGS.md` B186). The final
|
||||||
/// close or the trash landing, whichever comes last, lets go of them.
|
/// close or the trash landing, whichever comes last, lets go of them.
|
||||||
fn queue_trash(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
///
|
||||||
|
/// `open` is whether the caller found the file open. A create that landed
|
||||||
|
/// under a delete is trashed by its real uid before the tree has it, so
|
||||||
|
/// asking the tree alone took an open file for closed (`docs/BUGS.md`
|
||||||
|
/// B257).
|
||||||
|
fn queue_trash(&self, uid: &NodeUid, name: &str, open: bool) -> Result<(), Errno> {
|
||||||
self.require_uid_writable(uid)?;
|
self.require_uid_writable(uid)?;
|
||||||
// The bytes below are about to be discarded, and a drain worker may be
|
// The bytes below are about to be discarded, and a drain worker may be
|
||||||
// reading them onto the wire right now. Stop it first: with several
|
// reading them onto the wire right now. Stop it first: with several
|
||||||
@@ -4014,7 +4019,7 @@ impl Core {
|
|||||||
error!(%uid, error = %e, "queueing trash failed");
|
error!(%uid, error = %e, "queueing trash failed");
|
||||||
Errno::EIO
|
Errno::EIO
|
||||||
})?;
|
})?;
|
||||||
let open_now = self.is_open_anywhere(uid);
|
let open_now = open || self.is_open_anywhere(uid);
|
||||||
if open_now {
|
if open_now {
|
||||||
let read_from = self.pending_blob(uid);
|
let read_from = self.pending_blob(uid);
|
||||||
self.release_dropped_below(
|
self.release_dropped_below(
|
||||||
@@ -5383,9 +5388,12 @@ impl Core {
|
|||||||
/// `unlink` under an open handle. Its queued ops go now, or the drain would
|
/// `unlink` under an open handle. Its queued ops go now, or the drain would
|
||||||
/// upload to the node after it was trashed (`docs/BUGS.md` B106).
|
/// upload to the node after it was trashed (`docs/BUGS.md` B106).
|
||||||
fn remove_replaced(&self, ino: u64, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
fn remove_replaced(&self, ino: u64, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
||||||
|
// Asked under the uid the tree has now: a landed create's real uid is
|
||||||
|
// not in the tree yet (`docs/BUGS.md` B257).
|
||||||
|
let open = self.is_open_anywhere(uid);
|
||||||
let landed;
|
let landed;
|
||||||
let uid = if is_local_uid(uid) {
|
let uid = if is_local_uid(uid) {
|
||||||
if self.is_open_anywhere(uid) {
|
if open {
|
||||||
self.withdraw_queued_ops(uid)?;
|
self.withdraw_queued_ops(uid)?;
|
||||||
} else {
|
} else {
|
||||||
self.discard_queued_ops(uid)?;
|
self.discard_queued_ops(uid)?;
|
||||||
@@ -5410,7 +5418,7 @@ impl Core {
|
|||||||
// Local-first or offline, or the network goes away under the call:
|
// Local-first or offline, or the network goes away under the call:
|
||||||
// queue the trash. The rename that follows queues as well, behind it.
|
// queue the trash. The rename that follows queues as well, behind it.
|
||||||
if !self.sends_inline() {
|
if !self.sends_inline() {
|
||||||
return self.queue_trash(uid, name);
|
return self.queue_trash(uid, name, open);
|
||||||
}
|
}
|
||||||
self.require_uid_writable(uid)?;
|
self.require_uid_writable(uid)?;
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
@@ -5418,7 +5426,7 @@ impl Core {
|
|||||||
.and_then(batch::into_unit)
|
.and_then(batch::into_unit)
|
||||||
{
|
{
|
||||||
if self.lost_link(&e, "trash a replaced node") {
|
if self.lost_link(&e, "trash a replaced node") {
|
||||||
return self.queue_trash(uid, name);
|
return self.queue_trash(uid, name, open);
|
||||||
}
|
}
|
||||||
error!(%uid, name, error = %e, "trashing the node a rename replaces failed");
|
error!(%uid, name, error = %e, "trashing the node a rename replaces failed");
|
||||||
self.log_activity(ActivityKind::Trash, name, e.to_string(), false);
|
self.log_activity(ActivityKind::Trash, name, e.to_string(), false);
|
||||||
@@ -9258,7 +9266,7 @@ mod tests {
|
|||||||
assert!(!replaced.contains("st.forget(uid)"));
|
assert!(!replaced.contains("st.forget(uid)"));
|
||||||
assert_before(
|
assert_before(
|
||||||
replaced,
|
replaced,
|
||||||
"if self.is_open_anywhere(uid)",
|
"let open = self.is_open_anywhere(uid)",
|
||||||
"self.discard_queued_ops(uid)",
|
"self.discard_queued_ops(uid)",
|
||||||
);
|
);
|
||||||
assert_before(replaced, "if open_now", "self.cache.evict(uid)");
|
assert_before(replaced, "if open_now", "self.cache.evict(uid)");
|
||||||
|
|||||||
@@ -196,6 +196,29 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// starts, so a refusal costs nothing to retry.
|
||||||
|
///
|
||||||
|
/// A dead mount is cleared first, since it cannot be listed. One of this
|
||||||
|
/// client's own live mounts is left to [`spawn_session`], which clears it as it
|
||||||
|
/// always has; listing it would show Drive's files, not the folder's.
|
||||||
|
pub(super) fn check_mountpoint(mountpoint: &Path) -> std::io::Result<()> {
|
||||||
|
if is_stale_mount(mountpoint) {
|
||||||
|
clear_stale_mount(mountpoint);
|
||||||
|
}
|
||||||
|
if pdfs_core::mountpoint::mounted_fs_type(mountpoint).as_deref()
|
||||||
|
== Some(pdfs_core::mountpoint::OWN_FS_TYPE)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match pdfs_core::mountpoint::not_empty(mountpoint)? {
|
||||||
|
Some(found) => Err(std::io::Error::other(found)),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
||||||
/// recover it from `nodes` without the API.
|
/// recover it from `nodes` without the API.
|
||||||
const ROOT_UID_KEY: &str = "root_uid";
|
const ROOT_UID_KEY: &str = "root_uid";
|
||||||
@@ -427,6 +450,7 @@ pub(crate) fn mount_with(
|
|||||||
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||||
// so the age it reports is the daemon's own.
|
// so the age it reports is the daemon's own.
|
||||||
super::diagnostics::start_clock();
|
super::diagnostics::start_clock();
|
||||||
|
check_mountpoint(mountpoint)?;
|
||||||
let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
||||||
let online = start == Start::Online;
|
let online = start == Start::Online;
|
||||||
let scope = root.tree_event_scope_id();
|
let scope = root.tree_event_scope_id();
|
||||||
@@ -541,12 +565,11 @@ pub(crate) fn mount_with(
|
|||||||
// and sweeps all operating on a mount that no longer exists (`docs/BUGS.md` B44).
|
// and sweeps all operating on a mount that no longer exists (`docs/BUGS.md` B44).
|
||||||
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||||
for worker in 0..DRAIN_WORKERS {
|
for worker in 0..DRAIN_WORKERS {
|
||||||
let core = core.clone();
|
let drain_core = core.clone();
|
||||||
workers.push(
|
let spawned = std::thread::Builder::new()
|
||||||
std::thread::Builder::new()
|
|
||||||
.name(format!("pdfs-drain-{worker}"))
|
.name(format!("pdfs-drain-{worker}"))
|
||||||
.spawn(move || core.run_pending_drain(worker == 0))?,
|
.spawn(move || drain_core.run_pending_drain(worker == 0));
|
||||||
);
|
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the folder-sync engine. It watches every mirror folder, polls the
|
// Start the folder-sync engine. It watches every mirror folder, polls the
|
||||||
@@ -566,24 +589,22 @@ pub(crate) fn mount_with(
|
|||||||
info!("conflict sweep disabled by configuration");
|
info!("conflict sweep disabled by configuration");
|
||||||
} else {
|
} else {
|
||||||
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
||||||
let core = core.clone();
|
let sweep_core = core.clone();
|
||||||
workers.push(
|
let spawned = std::thread::Builder::new()
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("pdfs-conflict-sweep".into())
|
.name("pdfs-conflict-sweep".into())
|
||||||
.spawn(move || core.run_conflict_sweep_loop())?,
|
.spawn(move || sweep_core.run_conflict_sweep_loop());
|
||||||
);
|
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch the link for the life of the mount: a mount from the cache waits for
|
// Watch the link for the life of the mount: a mount from the cache waits for
|
||||||
// the network to come back, and an online one can lose it at any moment and
|
// the network to come back, and an online one can lose it at any moment and
|
||||||
// must notice it coming back just the same.
|
// must notice it coming back just the same.
|
||||||
{
|
{
|
||||||
let core = core.clone();
|
let probe_core = core.clone();
|
||||||
workers.push(
|
let spawned = std::thread::Builder::new()
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("pdfs-online-probe".into())
|
.name("pdfs-online-probe".into())
|
||||||
.spawn(move || core.run_online_probe())?,
|
.spawn(move || probe_core.run_online_probe());
|
||||||
);
|
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||||
@@ -594,11 +615,10 @@ pub(crate) fn mount_with(
|
|||||||
let transfers = core.transfers.clone();
|
let transfers = core.transfers.clone();
|
||||||
let mountpoint = mountpoint.to_path_buf();
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
let shutdown = core.shutdown.clone();
|
let shutdown = core.shutdown.clone();
|
||||||
workers.push(
|
let spawned = std::thread::Builder::new()
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("pdfs-localindex".into())
|
.name("pdfs-localindex".into())
|
||||||
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown))?,
|
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown));
|
||||||
);
|
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind the control socket before the FUSE session takes over the thread. A
|
// Bind the control socket before the FUSE session takes over the thread. A
|
||||||
@@ -607,12 +627,18 @@ pub(crate) fn mount_with(
|
|||||||
let old_umask = unsafe { libc::umask(0o77) };
|
let old_umask = unsafe { libc::umask(0o77) };
|
||||||
let listener_res = UnixListener::bind(control_socket);
|
let listener_res = UnixListener::bind(control_socket);
|
||||||
unsafe { libc::umask(old_umask) };
|
unsafe { libc::umask(old_umask) };
|
||||||
let listener = listener_res?;
|
let listener = match listener_res {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(error) => {
|
||||||
|
abandon_workers(&core, control_socket, workers);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
// Owner-only before anything can connect: a peer on this socket commands the
|
// Owner-only before anything can connect: a peer on this socket commands the
|
||||||
// daemon's authenticated session without a credential of its own (B6).
|
// daemon's authenticated session without a credential of its own (B6).
|
||||||
if let Err(e) = pdfs_core::config::restrict_socket(control_socket) {
|
if let Err(e) = pdfs_core::config::restrict_socket(control_socket) {
|
||||||
error!(error = %e, "cannot restrict control socket permissions; refusing to serve");
|
error!(error = %e, "cannot restrict control socket permissions; refusing to serve");
|
||||||
let _ = std::fs::remove_file(control_socket);
|
abandon_workers(&core, control_socket, workers);
|
||||||
return Err(std::io::Error::other(format!(
|
return Err(std::io::Error::other(format!(
|
||||||
"control socket permissions: {e}"
|
"control socket permissions: {e}"
|
||||||
)));
|
)));
|
||||||
@@ -623,7 +649,7 @@ pub(crate) fn mount_with(
|
|||||||
let bg = match spawn_session(&core, mountpoint, root) {
|
let bg = match spawn_session(&core, mountpoint, root) {
|
||||||
Ok(session) => session,
|
Ok(session) => session,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = std::fs::remove_file(control_socket);
|
abandon_workers(&core, control_socket, workers);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -643,7 +669,7 @@ pub(crate) fn mount_with(
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
// A failure here abandons the mount, so the background threads
|
// A failure here abandons the mount, so the background threads
|
||||||
// started above have to be told to stop with it.
|
// started above have to be told to stop with it.
|
||||||
stop_workers(&core, control_socket, workers);
|
abandon_workers(&core, control_socket, workers);
|
||||||
let secondaries = core.mounts.lock().close_and_drain();
|
let secondaries = core.mounts.lock().close_and_drain();
|
||||||
for (id, mount) in secondaries {
|
for (id, mount) in secondaries {
|
||||||
if let Err(unmount_error) = mount.teardown() {
|
if let Err(unmount_error) = mount.teardown() {
|
||||||
@@ -693,8 +719,12 @@ pub(crate) fn mount_with(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The mount is live and the control socket answers: this is the moment the
|
// The mount is live and the control socket answers: this is the moment the
|
||||||
// unit is actually usable, which is what `Type=notify` reports.
|
// unit is actually usable, which is what `Type=notify` reports. Whatever
|
||||||
|
// failure an earlier attempt recorded for the front ends is over.
|
||||||
systemd::ready();
|
systemd::ready();
|
||||||
|
if let Some(state_dir) = control_socket.parent() {
|
||||||
|
pdfs_core::mountfail::clear(state_dir);
|
||||||
|
}
|
||||||
|
|
||||||
// Re-establish on-demand mounts only after the primary session is live.
|
// Re-establish on-demand mounts only after the primary session is live.
|
||||||
// Keep the worker so shutdown can close publication, wait for any in-flight
|
// Keep the worker so shutdown can close publication, wait for any in-flight
|
||||||
@@ -860,6 +890,43 @@ fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::Jo
|
|||||||
// Say so before the joins: a teardown that takes a while must not be read as
|
// Say so before the joins: a teardown that takes a while must not be read as
|
||||||
// a missed watchdog ping and turned into a kill.
|
// a missed watchdog ping and turned into a kill.
|
||||||
systemd::stopping();
|
systemd::stopping();
|
||||||
|
halt_workers(core, control_socket, workers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the threads a mount attempt started before it failed.
|
||||||
|
///
|
||||||
|
/// The early returns used to leave them running: every retry started another
|
||||||
|
/// set beside the last one's, and their `Core` kept the database open, so the
|
||||||
|
/// next attempt could fail on its lock instead of the real error
|
||||||
|
/// (`docs/BUGS.md` B264). Unlike [`stop_workers`] this does not tell systemd
|
||||||
|
/// the daemon is stopping, because it is about to try again, and a manager
|
||||||
|
/// told so would wait for the process to exit and then kill it.
|
||||||
|
fn abandon_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
||||||
|
halt_workers(core, control_socket, workers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep a worker the mount just spawned, or, if the spawn failed, stop the
|
||||||
|
/// ones already running and hand the error back.
|
||||||
|
fn keep_or_abandon(
|
||||||
|
core: &Core,
|
||||||
|
control_socket: &Path,
|
||||||
|
workers: &mut Vec<std::thread::JoinHandle<()>>,
|
||||||
|
spawned: std::io::Result<std::thread::JoinHandle<()>>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
match spawned {
|
||||||
|
Ok(handle) => {
|
||||||
|
workers.push(handle);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
abandon_workers(core, control_socket, std::mem::take(workers));
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal every worker to stop, then join them up to [`WORKER_JOIN_DEADLINE`].
|
||||||
|
fn halt_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
||||||
core.shutdown.stop();
|
core.shutdown.stop();
|
||||||
core.wake_drain();
|
core.wake_drain();
|
||||||
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
||||||
|
|||||||
@@ -1344,7 +1344,7 @@ impl Core {
|
|||||||
let next = |current| if current == u64::MAX { 1 } else { current + 1 };
|
let next = |current| if current == u64::MAX { 1 } else { current + 1 };
|
||||||
let previous = self
|
let previous = self
|
||||||
.file_thumb_generation
|
.file_thumb_generation
|
||||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
|
.try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
|
||||||
Some(next(current))
|
Some(next(current))
|
||||||
})
|
})
|
||||||
.expect("generation update always succeeds");
|
.expect("generation update always succeeds");
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ impl Core {
|
|||||||
&self,
|
&self,
|
||||||
items: &[pdfs_core::control::RestoreItem],
|
items: &[pdfs_core::control::RestoreItem],
|
||||||
device: Option<&str>,
|
device: Option<&str>,
|
||||||
) -> CoreResult<String> {
|
) -> CoreResult<RestoreOutcome> {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Err(CoreError::invalid("nothing to restore"));
|
return Err(CoreError::invalid("nothing to restore"));
|
||||||
}
|
}
|
||||||
@@ -434,10 +434,19 @@ impl Core {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let id = self
|
let id = match self.db.sync_folder_add(
|
||||||
.db
|
&local.to_string_lossy(),
|
||||||
.sync_folder_add(&local.to_string_lossy(), &item.remote_uid, &device.share_id)
|
&item.remote_uid,
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
&device.share_id,
|
||||||
|
) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(e) => {
|
||||||
|
// The folders restored before this one still need their
|
||||||
|
// watches.
|
||||||
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
|
return Err(CoreError::internal(format!("db: {e:?}")));
|
||||||
|
}
|
||||||
|
};
|
||||||
// `ondemand` is applied through the normal request path, which
|
// `ondemand` is applied through the normal request path, which
|
||||||
// mounts the FUSE session and handles the folder being busy — the
|
// mounts the FUSE session and handles the folder being busy — the
|
||||||
// restore does not get its own copy of that logic.
|
// restore does not get its own copy of that logic.
|
||||||
@@ -454,12 +463,35 @@ impl Core {
|
|||||||
for reason in &skipped {
|
for reason in &skipped {
|
||||||
info!(reason, "restore skipped a folder");
|
info!(reason, "restore skipped a folder");
|
||||||
}
|
}
|
||||||
let mut message = format!("restored {restored} folder(s)");
|
|
||||||
if !skipped.is_empty() {
|
|
||||||
message.push_str(&format!("; skipped: {}", skipped.join(", ")));
|
|
||||||
}
|
|
||||||
info!(restored, skipped = skipped.len(), "restore complete");
|
info!(restored, skipped = skipped.len(), "restore complete");
|
||||||
Ok(message)
|
Ok(RestoreOutcome { restored, skipped })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a restore did: how many folders it attached, and why it left out the
|
||||||
|
/// others.
|
||||||
|
pub(crate) struct RestoreOutcome {
|
||||||
|
pub(crate) restored: usize,
|
||||||
|
pub(crate) skipped: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RestoreOutcome {
|
||||||
|
/// The activity entry's headline.
|
||||||
|
pub(crate) fn summary(&self) -> String {
|
||||||
|
match (self.restored, self.skipped.len()) {
|
||||||
|
(n, 0) => format!("restored {n} folder(s)"),
|
||||||
|
(0, k) => format!("restored no folders; skipped {k}"),
|
||||||
|
(n, k) => format!("restored {n} folder(s); skipped {k}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the restore did all it was asked. A skipped folder is a
|
||||||
|
/// failure the user has to see: the reply went out as "restoring folders"
|
||||||
|
/// before any work, and a restore that skipped every folder used to be
|
||||||
|
/// logged as a success, so nothing anywhere said that nothing would
|
||||||
|
/// download (`docs/BUGS.md` B269).
|
||||||
|
pub(crate) fn complete(&self) -> bool {
|
||||||
|
self.skipped.is_empty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,22 @@ impl Daemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether nothing is queued and no upload is still landing, as `pdfs
|
||||||
|
/// status` counts it. An empty [`Self::pending`] is not enough before a
|
||||||
|
/// read that needs the cache: the op goes before the uploaded bytes are
|
||||||
|
/// cached, so a test that went offline at once raced the drain.
|
||||||
|
pub(crate) fn drained(&self) -> bool {
|
||||||
|
self.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
&& matches!(
|
||||||
|
self.request(&Request::Status),
|
||||||
|
Ok(Response::Status {
|
||||||
|
pending_uploads: 0,
|
||||||
|
pending_changes: 0,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the mount believes Proton is reachable, as `pdfs status` shows
|
/// Whether the mount believes Proton is reachable, as `pdfs status` shows
|
||||||
/// it.
|
/// it.
|
||||||
pub(crate) fn online(&self) -> io::Result<bool> {
|
pub(crate) fn online(&self) -> io::Result<bool> {
|
||||||
@@ -393,6 +409,43 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A folder with files in it is not mounted over, and its files stay where
|
||||||
|
/// they are; once it is empty, the mount comes up (B277, #67).
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_mount_over_local_files_is_refused_and_leaves_them_alone() {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("not-empty");
|
||||||
|
let mountpoint = dir.join("mnt");
|
||||||
|
std::fs::create_dir_all(mountpoint.join("Work")).unwrap();
|
||||||
|
std::fs::write(mountpoint.join("notes.txt"), b"local only").unwrap();
|
||||||
|
|
||||||
|
let error = Daemon::start(&dir, drive.client(1, Faults::lan()))
|
||||||
|
.err()
|
||||||
|
.expect("the mount must be refused");
|
||||||
|
let found = error
|
||||||
|
.get_ref()
|
||||||
|
.and_then(|inner| inner.downcast_ref::<pdfs_core::mountpoint::NotEmpty>())
|
||||||
|
.unwrap_or_else(|| panic!("refused for another reason: {error}"));
|
||||||
|
assert_eq!(found.entries, ["Work", "notes.txt"]);
|
||||||
|
assert!(!is_mounted(&mountpoint));
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(mountpoint.join("notes.txt")).unwrap(),
|
||||||
|
b"local only"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
drive.tree().is_empty(),
|
||||||
|
"nothing went to Drive: {:?}",
|
||||||
|
drive.tree()
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_file(mountpoint.join("notes.txt")).unwrap();
|
||||||
|
std::fs::remove_dir(mountpoint.join("Work")).unwrap();
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
/// Write `bytes` at `offset` into an existing file, without truncating it.
|
/// Write `bytes` at `offset` into an existing file, without truncating it.
|
||||||
fn write_at(path: &Path, offset: u64, bytes: &[u8]) -> io::Result<()> {
|
fn write_at(path: &Path, offset: u64, bytes: &[u8]) -> io::Result<()> {
|
||||||
use std::os::unix::fs::FileExt;
|
use std::os::unix::fs::FileExt;
|
||||||
@@ -565,7 +618,7 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::write(&path, b"0123456789").unwrap();
|
std::fs::write(&path, b"0123456789").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
daemon.drained()
|
||||||
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
|
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
|
||||||
}));
|
}));
|
||||||
daemon.client.set_online(false);
|
daemon.client.set_online(false);
|
||||||
@@ -592,14 +645,22 @@ mod tests {
|
|||||||
daemon.pending(),
|
daemon.pending(),
|
||||||
drive.tree()
|
drive.tree()
|
||||||
);
|
);
|
||||||
// The bytes kept for the handle went once the trash landed with it closed.
|
// The bytes kept for the handle went once the trash landed with it
|
||||||
let cached: Vec<_> = std::fs::read_dir(dir.join("cache"))
|
// closed. The queue empties as the trash's row goes, a moment before
|
||||||
|
// the eviction, so this waits too.
|
||||||
|
let cached = || -> Vec<_> {
|
||||||
|
std::fs::read_dir(dir.join("cache"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
||||||
.map(|e| e.file_name())
|
.map(|e| e.file_name())
|
||||||
.collect();
|
.collect()
|
||||||
assert!(cached.is_empty(), "{cached:?}");
|
};
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || cached().is_empty()),
|
||||||
|
"{:?}",
|
||||||
|
cached()
|
||||||
|
);
|
||||||
|
|
||||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
@@ -684,6 +745,55 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_new_file_deleted_while_open_as_its_create_lands_still_reads() {
|
||||||
|
// The create's op was retired and the tree still had the placeholder.
|
||||||
|
// The queue read empty, and the delete trashed the node by its real
|
||||||
|
// uid, which the tree did not have yet: it took the open file for
|
||||||
|
// closed and dropped the bytes its handle reads (B257).
|
||||||
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("delete-open-as-create-lands");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let path = daemon.mountpoint.join("f.txt");
|
||||||
|
|
||||||
|
let landing = daemon.client.hold_next_landing_of_any();
|
||||||
|
std::fs::write(&path, b"0123456789").unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
|
||||||
|
assert!(!daemon.drained(), "the landing create is counted");
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap();
|
||||||
|
drop(landing);
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
|
||||||
|
|
||||||
|
daemon.client.set_online(false);
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
file.seek(SeekFrom::End(0)).unwrap();
|
||||||
|
file.write_all(b"ab").unwrap();
|
||||||
|
let mut read = Vec::new();
|
||||||
|
file.seek(SeekFrom::Start(0)).unwrap();
|
||||||
|
file.read_to_end(&mut read).unwrap();
|
||||||
|
assert_eq!(read, b"0123456789ab");
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
daemon.client.set_online(true);
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.drained() && drive.tree().is_empty()
|
||||||
|
}),
|
||||||
|
"{:?} {:?}",
|
||||||
|
daemon.pending(),
|
||||||
|
drive.tree()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
fn a_restart_does_not_bring_back_the_write_of_a_file_deleted_while_open() {
|
fn a_restart_does_not_bring_back_the_write_of_a_file_deleted_while_open() {
|
||||||
@@ -698,7 +808,7 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::write(&path, b"0123456789").unwrap();
|
std::fs::write(&path, b"0123456789").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
daemon.drained()
|
||||||
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
|
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
|
||||||
}));
|
}));
|
||||||
daemon.client.set_online(false);
|
daemon.client.set_online(false);
|
||||||
@@ -1998,6 +2108,21 @@ mod tests {
|
|||||||
daemon.write_new(&path, b"first").unwrap();
|
daemon.write_new(&path, b"first").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
||||||
std::fs::write(&path, b"second").unwrap();
|
std::fs::write(&path, b"second").unwrap();
|
||||||
|
// The kernel sends the release after `close` returns. Attaching the
|
||||||
|
// bytes resets the create's attempts, so a release that came after the
|
||||||
|
// lost reply had it retried at once, and the retry landed before
|
||||||
|
// anything here saw it fail. The bytes it replaced go only once they
|
||||||
|
// are attached.
|
||||||
|
let staged = dir.join("cache").join("staging");
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
let blobs: Vec<Vec<u8>> = std::fs::read_dir(&staged)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter_map(|e| std::fs::read(e.path()).ok())
|
||||||
|
.collect();
|
||||||
|
blobs.iter().any(|b| b == b"second") && !blobs.iter().any(|b| b == b"first")
|
||||||
|
}));
|
||||||
drop(held);
|
drop(held);
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
daemon
|
daemon
|
||||||
|
|||||||
@@ -1162,14 +1162,22 @@ impl Core {
|
|||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.drive.enumerate_folder_children_node_uids(folder))
|
.block_on(self.drive.enumerate_folder_children_node_uids(folder))
|
||||||
.map_err(|e| format!("enumerate {folder}: {e}"))?;
|
.map_err(|e| format!("enumerate {folder}: {e}"));
|
||||||
|
let uids = match unlisted_gap(walk, prefix, uids)? {
|
||||||
|
Some(uids) => uids,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let nodes = self
|
let nodes = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.drive.enumerate_nodes_light(&uids))
|
.block_on(self.drive.enumerate_nodes_light(&uids))
|
||||||
.map_err(|e| format!("resolve nodes: {e}"))?;
|
.map_err(|e| format!("resolve nodes: {e}"));
|
||||||
|
let nodes = match unlisted_gap(walk, prefix, nodes)? {
|
||||||
|
Some(nodes) => nodes,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
// The SDK leaves out a node it cannot decrypt, and says so only in its
|
// The SDK leaves out a node it cannot decrypt, and says so only in its
|
||||||
// log. Its name went with it, so all this can record is the folder.
|
// log. Its name went with it, so all this can record is the folder.
|
||||||
if nodes.len() < uids.len() {
|
if nodes.len() < uids.len() {
|
||||||
@@ -1213,10 +1221,14 @@ impl Core {
|
|||||||
let sized: HashMap<String, (i64, Option<String>)> = if stale.is_empty() {
|
let sized: HashMap<String, (i64, Option<String>)> = if stale.is_empty() {
|
||||||
HashMap::new()
|
HashMap::new()
|
||||||
} else {
|
} else {
|
||||||
self.rt
|
let full = self
|
||||||
|
.rt
|
||||||
.block_on(self.drive.enumerate_nodes(&stale))
|
.block_on(self.drive.enumerate_nodes(&stale))
|
||||||
.map_err(|e| format!("resolve nodes: {e}"))?
|
.map_err(|e| format!("resolve nodes: {e}"));
|
||||||
.iter()
|
let Some(full) = unlisted_gap(walk, prefix, full)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
full.iter()
|
||||||
.map(|n| {
|
.map(|n| {
|
||||||
(
|
(
|
||||||
n.uid.to_string(),
|
n.uid.to_string(),
|
||||||
@@ -2034,6 +2046,34 @@ struct RemoteWalk<'a> {
|
|||||||
gaps: &'a mut RemoteGaps,
|
gaps: &'a mut RemoteGaps,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A listing call's result in [`Core::walk_remote`]. A failure below the root
|
||||||
|
/// records `prefix` as unlisted and returns `None`, so the walk goes on
|
||||||
|
/// without that folder. A failure at the root still fails the pass, since
|
||||||
|
/// nothing would be left to compare.
|
||||||
|
///
|
||||||
|
/// One listing error, after the SDK's own retries, used to fail the whole
|
||||||
|
/// walk. Nothing is applied before the walk ends, so on a link that drops a
|
||||||
|
/// request now and then a large first sync started over from nothing on
|
||||||
|
/// every pass and never got past it (`docs/BUGS.md` B271).
|
||||||
|
fn unlisted_gap<T>(
|
||||||
|
walk: &mut RemoteWalk<'_>,
|
||||||
|
prefix: &str,
|
||||||
|
result: Result<T, String>,
|
||||||
|
) -> Result<Option<T>, PassAbort> {
|
||||||
|
match result {
|
||||||
|
Ok(value) => Ok(Some(value)),
|
||||||
|
Err(error) if prefix.is_empty() => Err(error.into()),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(
|
||||||
|
folder = prefix,
|
||||||
|
error, "sync: a folder on Drive could not be listed; leaving it out of this pass"
|
||||||
|
);
|
||||||
|
walk.gaps.unlisted(prefix);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the file at `path` is no longer what the scan saw as `planned`.
|
/// Whether the file at `path` is no longer what the scan saw as `planned`.
|
||||||
/// A file that is gone has nothing left to lose, so it has not changed.
|
/// A file that is gone has nothing left to lose, so it has not changed.
|
||||||
fn changed_since_scan(path: &Path, planned: &LocalItem) -> bool {
|
fn changed_since_scan(path: &Path, planned: &LocalItem) -> bool {
|
||||||
@@ -2827,7 +2867,7 @@ mod tests {
|
|||||||
/// from being read as "the user deleted everything" and trashing the folder
|
/// from being read as "the user deleted everything" and trashing the folder
|
||||||
/// on Drive.
|
/// on Drive.
|
||||||
#[test]
|
#[test]
|
||||||
fn guard_local_wipe_blocks_only_a_total_disappearance() {
|
fn guard_local_wipe_blocks_a_total_disappearance() {
|
||||||
let base: HashMap<String, ()> = ["a.txt", "b.txt", "d/c.txt"]
|
let base: HashMap<String, ()> = ["a.txt", "b.txt", "d/c.txt"]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| ((*r).to_string(), ()))
|
.map(|r| ((*r).to_string(), ()))
|
||||||
@@ -2841,7 +2881,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
guard_local_wipe(&base, &one).is_ok(),
|
guard_local_wipe(&base, &one).is_ok(),
|
||||||
"one survivor means the tree is readable; the rest are real deletions"
|
"in a small folder, one survivor means the tree is readable; the rest are real deletions"
|
||||||
);
|
);
|
||||||
// A one-entry folder can disappear because its mount or parent became
|
// A one-entry folder can disappear because its mount or parent became
|
||||||
// unavailable just as easily as a larger tree can.
|
// unavailable just as easily as a larger tree can.
|
||||||
|
|||||||
@@ -180,18 +180,42 @@ pub(super) fn local_deletions<B, L>(
|
|||||||
missing
|
missing
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refuse to run a pass whose local side has vanished in its entirety.
|
/// How many tracked paths a folder needs before a mass disappearance, short of
|
||||||
|
/// a total one, trips a wipe guard. Below this, a few survivors say little.
|
||||||
|
const MASS_LOSS_MIN_PATHS: usize = 20;
|
||||||
|
|
||||||
|
/// The share of a large folder's tracked paths that may vanish in one pass, in
|
||||||
|
/// tenths, before a wipe guard stops it: 9 of 10 and up is refused.
|
||||||
|
const MASS_LOSS_TENTHS: usize = 9;
|
||||||
|
|
||||||
|
/// Whether `missing` of `total` tracked paths gone at once looks like a lost
|
||||||
|
/// tree rather than deletions. All of them always does. Most of a large
|
||||||
|
/// folder does too: one surviving file used to clear the guard, so a
|
||||||
|
/// half-mounted disk, or a script that emptied the folder and wrote one file
|
||||||
|
/// back, trashed everything else on Drive (B270).
|
||||||
|
fn looks_like_a_wipe(missing: usize, total: usize) -> bool {
|
||||||
|
total > 0
|
||||||
|
&& (missing == total
|
||||||
|
|| (total >= MASS_LOSS_MIN_PATHS && missing * 10 >= total * MASS_LOSS_TENTHS))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse to run a pass whose local side has vanished in its entirety, or
|
||||||
|
/// nearly so ([`looks_like_a_wipe`]).
|
||||||
///
|
///
|
||||||
/// When every baseline path is absent locally, the likely cause is an unavailable
|
/// When the synced paths are all or nearly all absent locally, the likely
|
||||||
/// mount or unreadable folder rather than a deliberate whole-tree deletion.
|
/// cause is an unavailable mount or unreadable folder rather than a deliberate
|
||||||
/// One surviving path is enough to clear the guard.
|
/// deletion of the whole tree.
|
||||||
pub(super) fn guard_local_wipe<B, L>(
|
pub(super) fn guard_local_wipe<B, L>(
|
||||||
baseline: &HashMap<String, B>,
|
baseline: &HashMap<String, B>,
|
||||||
local: &HashMap<String, L>,
|
local: &HashMap<String, L>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
|
let missing = baseline
|
||||||
|
.keys()
|
||||||
|
.filter(|rel| !local.contains_key(*rel))
|
||||||
|
.count();
|
||||||
|
if looks_like_a_wipe(missing, baseline.len()) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"every one of the {} synced paths is missing locally; refusing to trash \
|
"{missing} of the {} synced paths are missing locally; refusing to trash \
|
||||||
them on Drive. Check that the folder is mounted and readable; to download \
|
them on Drive. Check that the folder is mounted and readable; to download \
|
||||||
them again, sync the folder from scratch.",
|
them again, sync the folder from scratch.",
|
||||||
baseline.len()
|
baseline.len()
|
||||||
@@ -200,20 +224,24 @@ pub(super) fn guard_local_wipe<B, L>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refuse to run a pass whose remote side has vanished in its entirety: the
|
/// Refuse to run a pass whose remote side has vanished in its entirety, or
|
||||||
/// remote twin of [`guard_local_wipe`].
|
/// nearly so: the remote twin of [`guard_local_wipe`].
|
||||||
///
|
///
|
||||||
/// An empty or truncated remote walk reads to the planner as "Drive deleted
|
/// An empty or truncated remote walk reads to the planner as "Drive deleted
|
||||||
/// everything", which a pass answers by deleting the local copies. When not
|
/// everything", which a pass answers by deleting the local copies. When all or
|
||||||
/// one synced path is left on Drive, a failed listing is far likelier than a
|
/// nearly all synced paths are gone from Drive at once, a failed listing is far
|
||||||
/// deliberate wipe of the whole folder, so the pass stops instead.
|
/// likelier than a deliberate wipe of the folder, so the pass stops instead.
|
||||||
pub(super) fn guard_remote_wipe<B, R>(
|
pub(super) fn guard_remote_wipe<B, R>(
|
||||||
baseline: &HashMap<String, B>,
|
baseline: &HashMap<String, B>,
|
||||||
remote: &HashMap<String, R>,
|
remote: &HashMap<String, R>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if !baseline.is_empty() && baseline.keys().all(|rel| !remote.contains_key(rel)) {
|
let missing = baseline
|
||||||
|
.keys()
|
||||||
|
.filter(|rel| !remote.contains_key(*rel))
|
||||||
|
.count();
|
||||||
|
if looks_like_a_wipe(missing, baseline.len()) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"every one of the {} synced paths is missing on Proton Drive; refusing to \
|
"{missing} of the {} synced paths are missing on Proton Drive; refusing to \
|
||||||
delete them here. If they were deleted on purpose, remove the folder from sync; \
|
delete them here. If they were deleted on purpose, remove the folder from sync; \
|
||||||
to upload them again, sync the folder from scratch.",
|
to upload them again, sync the folder from scratch.",
|
||||||
baseline.len()
|
baseline.len()
|
||||||
@@ -236,6 +264,9 @@ pub(super) struct RemoteGaps {
|
|||||||
short: HashSet<String>,
|
short: HashSet<String>,
|
||||||
/// Paths seen, but whose entry could not be read.
|
/// Paths seen, but whose entry could not be read.
|
||||||
unread: HashSet<String>,
|
unread: HashSet<String>,
|
||||||
|
/// Folders whose listing failed outright, after the SDK's retries. Nothing
|
||||||
|
/// under one is known, so all of it is left out of the pass (B271).
|
||||||
|
unlisted: HashSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RemoteGaps {
|
impl RemoteGaps {
|
||||||
@@ -249,8 +280,13 @@ impl RemoteGaps {
|
|||||||
self.unread.insert(rel);
|
self.unread.insert(rel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The listing of `folder` failed, so nothing under it was walked.
|
||||||
|
pub(super) fn unlisted(&mut self, folder: &str) {
|
||||||
|
self.unlisted.insert(folder.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn is_empty(&self) -> bool {
|
pub(super) fn is_empty(&self) -> bool {
|
||||||
self.short.is_empty() && self.unread.is_empty()
|
self.short.is_empty() && self.unread.is_empty() && self.unlisted.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `rel` may be missing from `remote` only because the walk could
|
/// Whether `rel` may be missing from `remote` only because the walk could
|
||||||
@@ -272,7 +308,7 @@ impl RemoteGaps {
|
|||||||
let mut at = parent_rel(rel);
|
let mut at = parent_rel(rel);
|
||||||
loop {
|
loop {
|
||||||
if remote_dirs.contains_key(at) {
|
if remote_dirs.contains_key(at) {
|
||||||
return self.short.contains(at);
|
return self.short.contains(at) || self.unlisted.contains(at);
|
||||||
}
|
}
|
||||||
if at.is_empty() {
|
if at.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
@@ -295,6 +331,9 @@ impl RemoteGaps {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|rel| format!("{rel} (cannot be read on Proton Drive)")),
|
.map(|rel| format!("{rel} (cannot be read on Proton Drive)")),
|
||||||
)
|
)
|
||||||
|
.chain(self.unlisted.iter().map(|rel| {
|
||||||
|
format!("{rel} (could not be listed on Proton Drive; tried again next pass)")
|
||||||
|
}))
|
||||||
.collect();
|
.collect();
|
||||||
lines.sort_unstable();
|
lines.sort_unstable();
|
||||||
lines
|
lines
|
||||||
@@ -805,7 +844,7 @@ mod file_plan_tests {
|
|||||||
/// An empty or failed remote listing must not read as "Drive deleted
|
/// An empty or failed remote listing must not read as "Drive deleted
|
||||||
/// everything" and delete the local copies.
|
/// everything" and delete the local copies.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_remote_wipe_guard_blocks_only_a_total_disappearance() {
|
fn the_remote_wipe_guard_blocks_a_total_disappearance() {
|
||||||
let base = HashMap::from([("a".to_string(), ()), ("b".to_string(), ())]);
|
let base = HashMap::from([("a".to_string(), ()), ("b".to_string(), ())]);
|
||||||
let none: HashMap<String, RemoteItem> = HashMap::new();
|
let none: HashMap<String, RemoteItem> = HashMap::new();
|
||||||
assert!(guard_remote_wipe(&base, &none).is_err());
|
assert!(guard_remote_wipe(&base, &none).is_err());
|
||||||
@@ -815,6 +854,37 @@ mod file_plan_tests {
|
|||||||
assert!(guard_remote_wipe(&empty, &none).is_ok());
|
assert!(guard_remote_wipe(&empty, &none).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One path left of a large folder no longer clears either guard: most
|
||||||
|
/// of it gone at once is a lost tree, not a cleanup (B270).
|
||||||
|
#[test]
|
||||||
|
fn one_survivor_of_a_large_folder_does_not_clear_the_wipe_guards() {
|
||||||
|
let base: HashMap<String, ()> = (0..40).map(|n| (format!("f{n}.txt"), ())).collect();
|
||||||
|
let survivors = |n: usize| -> HashMap<String, ()> {
|
||||||
|
(0..n).map(|n| (format!("f{n}.txt"), ())).collect()
|
||||||
|
};
|
||||||
|
assert!(guard_local_wipe(&base, &survivors(1)).is_err());
|
||||||
|
assert!(guard_remote_wipe(&base, &survivors(1)).is_err());
|
||||||
|
// 36 of 40 gone is nine tenths: still refused.
|
||||||
|
assert!(guard_local_wipe(&base, &survivors(4)).is_err());
|
||||||
|
// 35 of 40 gone is under nine tenths: a pass may delete that much.
|
||||||
|
assert!(guard_local_wipe(&base, &survivors(5)).is_ok());
|
||||||
|
assert!(guard_remote_wipe(&base, &survivors(5)).is_ok());
|
||||||
|
let reason = guard_local_wipe(&base, &survivors(1)).unwrap_err();
|
||||||
|
assert!(reason.starts_with("39 of the 40 synced paths"), "{reason}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small folder still loses most of its files to an ordinary cleanup, so
|
||||||
|
/// only a total loss stops it.
|
||||||
|
#[test]
|
||||||
|
fn a_small_folder_trips_the_wipe_guards_only_when_everything_is_gone() {
|
||||||
|
let base: HashMap<String, ()> = (0..19).map(|n| (format!("f{n}.txt"), ())).collect();
|
||||||
|
let one: HashMap<String, ()> = [("f0.txt".to_string(), ())].into_iter().collect();
|
||||||
|
assert!(guard_local_wipe(&base, &one).is_ok());
|
||||||
|
assert!(guard_remote_wipe(&base, &one).is_ok());
|
||||||
|
let none: HashMap<String, ()> = HashMap::new();
|
||||||
|
assert!(guard_local_wipe(&base, &none).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
/// A node that failed to decrypt has no name, so everything a short
|
/// A node that failed to decrypt has no name, so everything a short
|
||||||
/// listing did not return is left out, and nothing else.
|
/// listing did not return is left out, and nothing else.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -844,6 +914,32 @@ mod file_plan_tests {
|
|||||||
assert!(gaps.hides("top.txt", &remote, &dirs));
|
assert!(gaps.hides("top.txt", &remote, &dirs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A folder whose listing failed was walked no further: nothing under it
|
||||||
|
/// reads as deleted on Drive, and a sibling that listed still does
|
||||||
|
/// (`docs/BUGS.md` B271).
|
||||||
|
#[test]
|
||||||
|
fn a_folder_that_failed_to_list_hides_everything_under_it() {
|
||||||
|
let remote = HashMap::from([
|
||||||
|
("docs".to_string(), folder()),
|
||||||
|
("pics".to_string(), folder()),
|
||||||
|
]);
|
||||||
|
let dirs = HashMap::from([
|
||||||
|
(String::new(), uid("root")),
|
||||||
|
("docs".to_string(), uid("docs")),
|
||||||
|
("pics".to_string(), uid("pics")),
|
||||||
|
]);
|
||||||
|
let mut gaps = RemoteGaps::default();
|
||||||
|
gaps.unlisted("docs");
|
||||||
|
assert!(gaps.hides("docs/a.txt", &remote, &dirs));
|
||||||
|
assert!(gaps.hides("docs/sub/deep.txt", &remote, &dirs));
|
||||||
|
assert!(!gaps.hides("docs", &remote, &dirs));
|
||||||
|
assert!(!gaps.hides("pics/gone.jpg", &remote, &dirs));
|
||||||
|
assert_eq!(
|
||||||
|
gaps.report(),
|
||||||
|
["docs (could not be listed on Proton Drive; tried again next pass)"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_file_whose_size_could_not_be_read_is_hidden() {
|
fn a_file_whose_size_could_not_be_read_is_hidden() {
|
||||||
let remote: HashMap<String, RemoteItem> = HashMap::new();
|
let remote: HashMap<String, RemoteItem> = HashMap::new();
|
||||||
|
|||||||
@@ -64,11 +64,19 @@ fn send_abstract(socket: &UnixDatagram, name: &str, message: &str) -> bool {
|
|||||||
/// so `systemctl --user start` returns when the mount actually works rather than
|
/// so `systemctl --user start` returns when the mount actually works rather than
|
||||||
/// when the process exists.
|
/// when the process exists.
|
||||||
pub(crate) fn ready() {
|
pub(crate) fn ready() {
|
||||||
if notify("READY=1\n") {
|
if notify("READY=1\nSTATUS=Mounted\n") {
|
||||||
debug!("notified systemd: ready");
|
debug!("notified systemd: ready");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the one-line status `systemctl --user status` shows for the unit, such
|
||||||
|
/// as why the mount keeps failing (`docs/BUGS.md` B263).
|
||||||
|
pub fn status(text: &str) {
|
||||||
|
// A newline would end the `STATUS=` line and start another variable.
|
||||||
|
let line = text.replace('\n', " ");
|
||||||
|
notify(&format!("STATUS={line}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
/// Tell the manager the daemon is still healthy.
|
/// Tell the manager the daemon is still healthy.
|
||||||
pub(crate) fn watchdog_ping() {
|
pub(crate) fn watchdog_ping() {
|
||||||
let _ = notify("WATCHDOG=1\n");
|
let _ = notify("WATCHDOG=1\n");
|
||||||
|
|||||||
@@ -40,4 +40,4 @@ pangocairo = "0.22"
|
|||||||
# Map tiles for the opt-in online map. The same build proton-sdk already pulls in.
|
# Map tiles for the opt-in online map. The same build proton-sdk already pulls in.
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots"] }
|
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots"] }
|
||||||
# The system libintl (glibc) rather than a bundled gettext build.
|
# The system libintl (glibc) rather than a bundled gettext build.
|
||||||
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
gettext-rs = { version = "0.8", features = ["gettext-system"] }
|
||||||
@@ -125,6 +125,9 @@ struct Ui {
|
|||||||
/// Whether this run already asked for the mailbox password of a locked
|
/// Whether this run already asked for the mailbox password of a locked
|
||||||
/// account, so a refresh never re-opens a dialog the user cancelled.
|
/// account, so a refresh never re-opens a dialog the user cancelled.
|
||||||
unlock_offered: Cell<bool>,
|
unlock_offered: Cell<bool>,
|
||||||
|
/// The `since` of the run of mount failures whose "folder isn't empty"
|
||||||
|
/// dialog this run already showed, so a page reload never shows it again.
|
||||||
|
not_empty_offered: Cell<i64>,
|
||||||
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
|
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
|
||||||
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
||||||
mounted: RefCell<bool>,
|
mounted: RefCell<bool>,
|
||||||
@@ -403,6 +406,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
opening: RefCell::new(HashSet::new()),
|
opening: RefCell::new(HashSet::new()),
|
||||||
session: RefCell::new(startup_session),
|
session: RefCell::new(startup_session),
|
||||||
unlock_offered: Cell::new(false),
|
unlock_offered: Cell::new(false),
|
||||||
|
not_empty_offered: Cell::new(0),
|
||||||
mounted: RefCell::new(false),
|
mounted: RefCell::new(false),
|
||||||
sidebar: sidebar_list.clone(),
|
sidebar: sidebar_list.clone(),
|
||||||
nav: split.clone(),
|
nav: split.clone(),
|
||||||
@@ -1797,13 +1801,52 @@ pub(crate) fn service_unreachable(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Running but not answering may be a mount that fails on every try,
|
||||||
|
// which never opens the socket. Its reason is in a file the daemon
|
||||||
|
// keeps for this (B263); "Connecting…" alone looked like a slow start
|
||||||
|
// for as long as it lasted.
|
||||||
|
let state_dir = ui.dirs.state_dir();
|
||||||
|
let failure = gio::spawn_blocking(move || pdfs_core::mountfail::read(&state_dir))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
match failure {
|
||||||
|
Some(failure) if failure.not_empty.is_some() => {
|
||||||
|
let found = failure.not_empty.as_ref().expect("checked above");
|
||||||
status(
|
status(
|
||||||
|
&ui,
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
&gettext("Can't open your Drive"),
|
||||||
|
// Translators: {path} is the folder My files is mounted at.
|
||||||
|
&gettext_f(
|
||||||
|
"“{path}” already has files in it, and My files would hide them. Proton Drive tries again on its own once the folder is empty. You can also choose another folder for My files in Preferences.",
|
||||||
|
&[("path", &found.path.display().to_string())],
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if ui.not_empty_offered.replace(failure.since) != failure.since {
|
||||||
|
offer_not_empty(&ui, found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(failure) => status(
|
||||||
|
&ui,
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
&gettext("Can't open your Drive"),
|
||||||
|
// Translators: {error} is an error message, in English.
|
||||||
|
&gettext_f(
|
||||||
|
"The Proton Drive service keeps failing to start and tries again on its own. The last error was: {error}",
|
||||||
|
&[("error", &failure.error)],
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
None => status(
|
||||||
&ui,
|
&ui,
|
||||||
"network-idle-symbolic",
|
"network-idle-symbolic",
|
||||||
&gettext("Connecting…"),
|
&gettext("Connecting…"),
|
||||||
&gettext("Waiting for the Proton Drive service to start."),
|
&gettext("Waiting for the Proton Drive service to start."),
|
||||||
false,
|
false,
|
||||||
);
|
),
|
||||||
|
}
|
||||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||||
if ui.stack.visible_child_name().as_deref() == Some(page) {
|
if ui.stack.visible_child_name().as_deref() == Some(page) {
|
||||||
reload(&ui);
|
reload(&ui);
|
||||||
@@ -1812,6 +1855,37 @@ pub(crate) fn service_unreachable(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tell the user the daemon will not mount My files over a folder with files
|
||||||
|
/// in it (B277), and offer the two ways out: open the folder to move them, or
|
||||||
|
/// choose another one.
|
||||||
|
fn offer_not_empty(ui: &Rc<Ui>, found: &pdfs_core::mountpoint::NotEmpty) {
|
||||||
|
let path = found.path.display().to_string();
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Your Proton Drive Folder Isn't Empty"))
|
||||||
|
// Translators: {path} is the folder My files is mounted at; {n} is how
|
||||||
|
// many files and folders are already in it.
|
||||||
|
.body(ngettext_f(
|
||||||
|
"“{path}” already has {n} item in it. My files would hide it, so Proton Drive does not open there. Move the item to another folder, or choose another folder for My files.",
|
||||||
|
"“{path}” already has {n} items in it. My files would hide them, so Proton Drive does not open there. Move the items to another folder, or choose another folder for My files.",
|
||||||
|
found.count as u64,
|
||||||
|
&[("path", &path)],
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("close", &gettext("Not Now"));
|
||||||
|
dialog.add_response("open", &gettext("Open Folder"));
|
||||||
|
dialog.add_response("choose", &gettext("Choose Another Folder…"));
|
||||||
|
dialog.set_response_appearance("choose", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("choose"));
|
||||||
|
dialog.set_close_response("close");
|
||||||
|
let ui_response = ui.clone();
|
||||||
|
dialog.connect_response(None, move |_, response| match response {
|
||||||
|
"open" => open_path(&path),
|
||||||
|
"choose" => prompt_mountpoint(&ui_response),
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
dialog.present(ui_window(ui).as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
/// How long a copy button shows its check after copying.
|
/// How long a copy button shows its check after copying.
|
||||||
const COPIED_FEEDBACK: Duration = Duration::from_millis(1500);
|
const COPIED_FEEDBACK: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
|
|||||||
@@ -806,7 +806,8 @@ pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Prompt for a new mountpoint folder, persist it to config, and offer to restart
|
/// Prompt for a new mountpoint folder, persist it to config, and offer to restart
|
||||||
/// the mount service so the daemon picks it up.
|
/// the mount service so the daemon picks it up. A folder with files in it is
|
||||||
|
/// refused: the mount would hide them (B277).
|
||||||
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
let dialog = gtk4::FileDialog::builder()
|
let dialog = gtk4::FileDialog::builder()
|
||||||
@@ -816,20 +817,64 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
|||||||
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
|
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||||
let Ok(folder) = res else { return };
|
let Ok(folder) = res else { return };
|
||||||
let Some(path) = folder.path() else { return };
|
let Some(path) = folder.path() else { return };
|
||||||
|
// The folder My files is mounted at lists Drive's files, not its own,
|
||||||
|
// so picking it again is not checked.
|
||||||
|
if path == ui.dirs.resolved_mountpoint(&ui.dirs.load_config()) {
|
||||||
|
use_mountpoint(&ui, &path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let checked = path.clone();
|
||||||
|
let found = gio::spawn_blocking(move || pdfs_core::mountpoint::not_empty(&checked))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| Err(std::io::Error::other("the check did not finish")));
|
||||||
|
match found {
|
||||||
|
Ok(None) => use_mountpoint(&ui, &path),
|
||||||
|
Ok(Some(found)) => refuse_mountpoint(&ui, &found),
|
||||||
|
Err(e) => toast_error(&ui, &gettext("Couldn't read the folder"), &e.to_string()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explain why the folder `found` cannot hold My files.
|
||||||
|
fn refuse_mountpoint(ui: &Rc<Ui>, found: &pdfs_core::mountpoint::NotEmpty) {
|
||||||
|
let path = found.path.display().to_string();
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Choose an Empty Folder"))
|
||||||
|
// Translators: {path} is the folder the user picked; {n} is how many
|
||||||
|
// files and folders are already in it.
|
||||||
|
.body(ngettext_f(
|
||||||
|
"“{path}” already has {n} item in it. My files would hide it while Proton Drive runs, and it would not be uploaded.",
|
||||||
|
"“{path}” already has {n} items in it. My files would hide them while Proton Drive runs, and they would not be uploaded.",
|
||||||
|
found.count as u64,
|
||||||
|
&[("path", &path)],
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("close", &gettext("Close"));
|
||||||
|
dialog.set_default_response(Some("close"));
|
||||||
|
dialog.set_close_response("close");
|
||||||
|
dialog.present(ui_window(ui).as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `path` the folder My files is mounted at, and offer the restart that
|
||||||
|
/// moves it there.
|
||||||
|
fn use_mountpoint(ui: &Rc<Ui>, path: &std::path::Path) {
|
||||||
let path_str = path.display().to_string();
|
let path_str = path.display().to_string();
|
||||||
|
|
||||||
// Persist the choice to config so the next mount uses it.
|
// Persist the choice to config so the next mount uses it.
|
||||||
let mut config = ui.dirs.load_config();
|
let mut config = ui.dirs.load_config();
|
||||||
config.mountpoint = Some(path_str.clone());
|
config.mountpoint = Some(path_str.clone());
|
||||||
if let Err(e) = ui.dirs.save_config(&config) {
|
if let Err(e) = ui.dirs.save_config(&config) {
|
||||||
toast_error(&ui, &gettext("Couldn't save the folder"), &e.to_string());
|
toast_error(ui, &gettext("Couldn't save the folder"), &e.to_string());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.status.mountpoint_row.set_subtitle(&path_str);
|
ui.status.mountpoint_row.set_subtitle(&path_str);
|
||||||
// The Locations page shows the same path as a row title. Any other page
|
// The Locations page shows the same path as a row title. Any other page
|
||||||
// reads it again on the next visit.
|
// reads it again on the next visit.
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
||||||
load_locations(&ui);
|
load_locations(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
||||||
@@ -852,8 +897,7 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
|||||||
restart_service_then(&ui_restart, |_| {});
|
restart_service_then(&ui_restart, |_| {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
confirm.present(ui_window(&ui).as_ref());
|
confirm.present(ui_window(ui).as_ref());
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] /
|
/// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] /
|
||||||
@@ -1343,12 +1387,39 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
|||||||
/// read-out so the page doesn't flicker on a blip.
|
/// read-out so the page doesn't flicker on a blip.
|
||||||
pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
||||||
set_mounted(ui, false);
|
set_mounted(ui, false);
|
||||||
paint_sync_status(ui, SyncState::Disconnected);
|
|
||||||
for r in ui.status.pin_rows.borrow().iter() {
|
for r in ui.status.pin_rows.borrow().iter() {
|
||||||
if let Some(b) = &r.unpin {
|
if let Some(b) = &r.unpin {
|
||||||
b.set_sensitive(false);
|
b.set_sensitive(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A running daemon whose mount fails on every try never answers either,
|
||||||
|
// and "isn't running" would be wrong for it (B263, B277). The pages read
|
||||||
|
// the same failure record.
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let failure = if service_down().await {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let state_dir = ui.dirs.state_dir();
|
||||||
|
gio::spawn_blocking(move || pdfs_core::mountfail::read(&state_dir))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
};
|
||||||
|
// The daemon may have answered while this looked.
|
||||||
|
if *ui.mounted.borrow() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
paint_sync_status(
|
||||||
|
&ui,
|
||||||
|
match failure {
|
||||||
|
Some(failure) => SyncState::CannotOpen {
|
||||||
|
not_empty: failure.not_empty.is_some(),
|
||||||
|
},
|
||||||
|
None => SyncState::Disconnected,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the sidebar's status strip reports, most urgent first.
|
/// What the sidebar's status strip reports, most urgent first.
|
||||||
@@ -1392,6 +1463,11 @@ pub(crate) enum SyncState {
|
|||||||
},
|
},
|
||||||
/// No mount daemon answered: still starting, or the service is down.
|
/// No mount daemon answered: still starting, or the service is down.
|
||||||
Disconnected,
|
Disconnected,
|
||||||
|
/// The service runs, but its mount fails on every try. `not_empty` when
|
||||||
|
/// it refuses a folder that already has files in it.
|
||||||
|
CannotOpen {
|
||||||
|
not_empty: bool,
|
||||||
|
},
|
||||||
/// The daemon is running but didn't answer in time.
|
/// The daemon is running but didn't answer in time.
|
||||||
NotResponding,
|
NotResponding,
|
||||||
}
|
}
|
||||||
@@ -1400,7 +1476,10 @@ pub(crate) enum SyncState {
|
|||||||
/// one-line state, and a detail line.
|
/// one-line state, and a detail line.
|
||||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||||
let paused = matches!(state, SyncState::Paused { .. });
|
let paused = matches!(state, SyncState::Paused { .. });
|
||||||
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
let connected = !matches!(
|
||||||
|
state,
|
||||||
|
SyncState::Disconnected | SyncState::CannotOpen { .. } | SyncState::NotResponding
|
||||||
|
);
|
||||||
let expired = matches!(state, SyncState::SessionExpired { .. });
|
let expired = matches!(state, SyncState::SessionExpired { .. });
|
||||||
let outdated = matches!(state, SyncState::Outdated { .. });
|
let outdated = matches!(state, SyncState::Outdated { .. });
|
||||||
let (icon, class, title, detail) = match state {
|
let (icon, class, title, detail) = match state {
|
||||||
@@ -1492,6 +1571,16 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
|||||||
gettext("Not connected"),
|
gettext("Not connected"),
|
||||||
Some(gettext("Proton Drive isn't running")),
|
Some(gettext("Proton Drive isn't running")),
|
||||||
),
|
),
|
||||||
|
SyncState::CannotOpen { not_empty } => (
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
Some("warning"),
|
||||||
|
gettext("Can't open your Drive"),
|
||||||
|
Some(if not_empty {
|
||||||
|
gettext("Your Proton Drive folder isn't empty")
|
||||||
|
} else {
|
||||||
|
gettext("Proton Drive keeps trying to start")
|
||||||
|
}),
|
||||||
|
),
|
||||||
SyncState::NotResponding => (
|
SyncState::NotResponding => (
|
||||||
"dialog-warning-symbolic",
|
"dialog-warning-symbolic",
|
||||||
Some("warning"),
|
Some("warning"),
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ pub(crate) fn init() {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|dirs| dirs.load_config().language)
|
.and_then(|dirs| dirs.load_config().language)
|
||||||
.filter(|code| LANGUAGES.iter().any(|(known, _)| known == code));
|
.filter(|code| LANGUAGES.iter().any(|(known, _)| known == code));
|
||||||
gettextrs::setlocale(LocaleCategory::LcAll, "");
|
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
|
||||||
|
unsafe { gettextrs::setlocale(LocaleCategory::LcAll, "") };
|
||||||
if let Some(code) = language {
|
if let Some(code) = language {
|
||||||
apply_language(&code);
|
apply_language(&code);
|
||||||
}
|
}
|
||||||
@@ -97,14 +98,17 @@ pub(crate) fn country_name(code: &str) -> String {
|
|||||||
fn apply_language(code: &str) {
|
fn apply_language(code: &str) {
|
||||||
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
|
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
|
||||||
unsafe { std::env::set_var("LANGUAGE", code) };
|
unsafe { std::env::set_var("LANGUAGE", code) };
|
||||||
let current = gettextrs::setlocale(LocaleCategory::LcMessages, "")
|
// SAFETY: as above, still single-threaded.
|
||||||
|
let current = unsafe { gettextrs::setlocale(LocaleCategory::LcMessages, "") }
|
||||||
.map(|name| String::from_utf8_lossy(&name).into_owned())
|
.map(|name| String::from_utf8_lossy(&name).into_owned())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !is_c_locale(¤t) {
|
if !is_c_locale(¤t) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for candidate in message_locales(code) {
|
for candidate in message_locales(code) {
|
||||||
if gettextrs::setlocale(LocaleCategory::LcMessages, candidate.as_str()).is_some() {
|
// SAFETY: as above, still single-threaded.
|
||||||
|
if unsafe { gettextrs::setlocale(LocaleCategory::LcMessages, candidate.as_str()) }.is_some()
|
||||||
|
{
|
||||||
// SAFETY: as above, still single-threaded.
|
// SAFETY: as above, still single-threaded.
|
||||||
unsafe { std::env::set_var("LC_MESSAGES", &candidate) };
|
unsafe { std::env::set_var("LC_MESSAGES", &candidate) };
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -194,6 +194,24 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
),
|
),
|
||||||
Phase::Attention,
|
Phase::Attention,
|
||||||
),
|
),
|
||||||
|
// A daemon whose mount fails on every try never opens the
|
||||||
|
// socket; the app says why (B263).
|
||||||
|
Ok(s)
|
||||||
|
if socket
|
||||||
|
.parent()
|
||||||
|
.and_then(pdfs_core::mountfail::read)
|
||||||
|
.is_some() =>
|
||||||
|
{
|
||||||
|
(
|
||||||
|
// Translators: {username} is the Proton account name. The
|
||||||
|
// service keeps failing to start; the app shows the error.
|
||||||
|
gettext_f(
|
||||||
|
"Signed in as {username} — can't open your Drive",
|
||||||
|
&[("username", &s.username)],
|
||||||
|
),
|
||||||
|
Phase::Attention,
|
||||||
|
)
|
||||||
|
}
|
||||||
Ok(s) => (
|
Ok(s) => (
|
||||||
// Translators: {username} is the Proton account name.
|
// Translators: {username} is the Proton account name.
|
||||||
gettext_f(
|
gettext_f(
|
||||||
|
|||||||
+322
-42
@@ -68,16 +68,9 @@ original wording.
|
|||||||
|
|
||||||
| Entry | Title | What is left |
|
| Entry | Title | What is left |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| [B275](#b275--on-a-slow-link-a-files-last-write-can-land-only-in-a-conflict-copy) | On a slow link, a file's last write can land only in a conflict copy | Cause not found; seen once in CI |
|
||||||
| [B273](#b273--a-restore-proposes-another-computers-paths-when-the-two-share-a-hostname) | A restore proposes another computer's paths when the two share a hostname | Not fixed |
|
| [B273](#b273--a-restore-proposes-another-computers-paths-when-the-two-share-a-hostname) | A restore proposes another computer's paths when the two share a hostname | Not fixed |
|
||||||
| [B271](#b271--one-failed-listing-makes-a-large-first-sync-start-over-from-nothing) | One failed listing makes a large first sync start over from nothing | Not fixed |
|
|
||||||
| [B270](#b270--one-file-left-in-a-mirror-folder-clears-the-wipe-guard-for-all-the-others) | One file left in a mirror folder clears the wipe guard for all the others | Not fixed |
|
|
||||||
| [B269](#b269--a-restore-that-skipped-every-folder-reports-success) | A restore that skipped every folder reports success | Not fixed |
|
|
||||||
| [B268](#b268--pdfs-diagnose-says-no-problems-found-while-sync-is-stuck) | `pdfs diagnose` says "No problems found" while sync is stuck | Not fixed |
|
|
||||||
| [B267](#b267--a-key-failure-at-a-start-from-a-warm-cache-shows-as-offline-for-good) | A key failure at a start from a warm cache shows as offline for good | Not fixed |
|
| [B267](#b267--a-key-failure-at-a-start-from-a-warm-cache-shows-as-offline-for-good) | A key failure at a start from a warm cache shows as offline for good | Not fixed |
|
||||||
| [B266](#b266--pin-unpin-search-and-node-loads-report-a-database-error-as-offline) | Pin, unpin, search and node loads report a database error as "offline" | Not fixed |
|
|
||||||
| [B265](#b265--address-keys-whose-token-is-for-an-inactive-account-key-never-open) | Address keys whose token is for an inactive account key never open | Needs an SDK change; not reproduced |
|
|
||||||
| [B264](#b264--a-mount-that-fails-after-starting-its-workers-leaves-them-running) | A mount that fails after starting its workers leaves them running | Not fixed |
|
|
||||||
| [B263](#b263--a-mount-that-can-never-succeed-retries-every-5-s-and-tells-only-the-journal) | A mount that can never succeed retries every 5 s and tells only the journal | Not fixed |
|
|
||||||
| [B134](#b134--a-file-deleted-online-while-the-drain-checks-its-write-is-kept-as-a-conflict-copy) | A file deleted online while the drain checks its write is kept as a conflict copy | Only with `local_first` off |
|
| [B134](#b134--a-file-deleted-online-while-the-drain-checks-its-write-is-kept-as-a-conflict-copy) | A file deleted online while the drain checks its write is kept as a conflict copy | Only with `local_first` off |
|
||||||
| [B91](#b91--the-daemon-hung-overnight-and-nothing-could-say-what-it-was-waiting-on) | The daemon hung overnight, and nothing could say what it was waiting on | The cause is not diagnosed; the daemon now reports where it is stuck and is restarted |
|
| [B91](#b91--the-daemon-hung-overnight-and-nothing-could-say-what-it-was-waiting-on) | The daemon hung overnight, and nothing could say what it was waiting on | The cause is not diagnosed; the daemon now reports where it is stuck and is restarted |
|
||||||
| [B67](#b67--fresh-state-restore-can-silently-select-a-new-device-high-22) | Fresh-state restore can silently select a new device (HIGH-22) | An ambiguous device after a loss of local state is not asked about |
|
| [B67](#b67--fresh-state-restore-can-silently-select-a-new-device-high-22) | Fresh-state restore can silently select a new device (HIGH-22) | An ambiguous device after a loss of local state is not asked about |
|
||||||
@@ -90,6 +83,10 @@ original wording.
|
|||||||
|
|
||||||
| Entry | Title | What is left |
|
| Entry | Title | What is left |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| [B270](#b270--one-file-left-in-a-mirror-folder-clears-the-wipe-guard-for-all-the-others) | One file left in a mirror folder clears the wipe guard for all the others | A loss under nine in ten still goes through; no choice offered in the app |
|
||||||
|
| [B269](#b269--a-restore-that-skipped-every-folder-reports-success) | A restore that skipped every folder reports success | The app shows the result only in the activity feed; no offer to replace or reset the folder in the way |
|
||||||
|
| [B268](#b268--pdfs-diagnose-says-no-problems-found-while-sync-is-stuck) | `pdfs diagnose` says "No problems found" while sync is stuck | The sync engine's state is not checked |
|
||||||
|
| [B263](#b263--a-mount-that-can-never-succeed-retries-every-5-s-and-tells-only-the-journal) | A mount that can never succeed retries every 5 s and tells only the journal | Errors no retry fixes back off like network errors |
|
||||||
| [B262](#b262--watching-a-large-synced-folder-holds-up-the-sync-engine-and-a-failed-watch-is-never-retried) | Watching a large synced folder holds up the sync engine, and a failed watch is never retried | The first pass still waits for the watch; a failed watch is not shown in the app |
|
| [B262](#b262--watching-a-large-synced-folder-holds-up-the-sync-engine-and-a-failed-watch-is-never-retried) | Watching a large synced folder holds up the sync engine, and a failed watch is never retried | The first pass still waits for the watch; a failed watch is not shown in the app |
|
||||||
| [B259](#b259--the-sync-engine-can-die-or-hang-and-nothing-shows-it-until-a-restart) | The sync engine can die or hang, and nothing shows it until a restart | No pass deadline; an ended engine is reported, not restarted; the trigger in #53 is not known |
|
| [B259](#b259--the-sync-engine-can-die-or-hang-and-nothing-shows-it-until-a-restart) | The sync engine can die or hang, and nothing shows it until a restart | No pass deadline; an ended engine is reported, not restarted; the trigger in #53 is not known |
|
||||||
| [B199](#b199--a-stopped-share-stays-on-the-shared-by-me-page-for-minutes) | A stopped share stays on the Shared by me page for minutes | The share itself is not deleted; the SDK has no call for it |
|
| [B199](#b199--a-stopped-share-stays-on-the-shared-by-me-page-for-minutes) | A stopped share stays on the Shared by me page for minutes | The share itself is not deleted; the SDK has no call for it |
|
||||||
@@ -112,12 +109,17 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [B261](#b261--a-folder-that-has-never-synced-reads-up-to-date) | A folder that has never synced reads "up to date" |
|
| [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 |
|
||||||
| [B272](#b272--after-a-folder-is-removed-and-added-again-its-file-events-go-to-the-old-id) | After a folder is removed and added again, its file events go to the old id |
|
| [B272](#b272--after-a-folder-is-removed-and-added-again-its-file-events-go-to-the-old-id) | After a folder is removed and added again, its file events go to the old id |
|
||||||
|
| [B271](#b271--one-failed-listing-makes-a-large-first-sync-start-over-from-nothing) | One failed listing makes a large first sync start over from nothing |
|
||||||
|
| [B266](#b266--pin-unpin-search-and-node-loads-report-a-database-error-as-offline) | Pin, unpin, search and node loads report a database error as "offline" |
|
||||||
|
| [B265](#b265--address-keys-whose-token-is-for-an-inactive-account-key-never-open) | Address keys whose token is for an inactive account key never open |
|
||||||
|
| [B264](#b264--a-mount-that-fails-after-starting-its-workers-leaves-them-running) | A mount that fails after starting its workers leaves them running |
|
||||||
|
| [B261](#b261--a-folder-that-has-never-synced-reads-up-to-date) | A folder that has never synced reads "up to date" |
|
||||||
| [B260](#b260--the-poll-pushes-a-new-folders-first-sync-behind-every-other-folder) | The poll pushes a new folder's first sync behind every other folder |
|
| [B260](#b260--the-poll-pushes-a-new-folders-first-sync-behind-every-other-folder) | The poll pushes a new folder's first sync behind every other folder |
|
||||||
| [B258](#b258--journalctl--p-warning-shows-nothing-whatever-the-daemon-logged) | `journalctl -p warning` shows nothing, whatever the daemon logged |
|
| [B257](#b257--deleting-a-new-file-while-it-is-open-can-drop-the-bytes-its-handle-reads) | Deleting a new file while it is open can drop the bytes its handle reads |
|
||||||
| [B256](#b256--pdfs-copy-right-after-a-save-copies-the-old-version) | `pdfs copy` right after a save copies the old version |
|
|
||||||
| [B255](#b255--a-synced-folder-whose-files-are-all-gone-here-is-stuck-for-good) | A synced folder whose files are all gone here is stuck for good |
|
|
||||||
| [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives |
|
| [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives |
|
||||||
| [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked |
|
| [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked |
|
||||||
| [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile |
|
| [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile |
|
||||||
@@ -292,6 +294,10 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [B276](#b276--a-stop-can-end-with-a-panic-in-a-thread-the-stop-never-waited-for) | A stop can end with a panic in a thread the stop never waited for |
|
||||||
|
| [B258](#b258--journalctl--p-warning-shows-nothing-whatever-the-daemon-logged) | `journalctl -p warning` shows nothing, whatever the daemon logged |
|
||||||
|
| [B256](#b256--pdfs-copy-right-after-a-save-copies-the-old-version) | `pdfs copy` right after a save copies the old version |
|
||||||
|
| [B255](#b255--a-synced-folder-whose-files-are-all-gone-here-is-stuck-for-good) | A synced folder whose files are all gone here is stuck for good |
|
||||||
| [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives |
|
| [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives |
|
||||||
| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later |
|
| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later |
|
||||||
| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English |
|
| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English |
|
||||||
@@ -375,6 +381,177 @@ a live account since.
|
|||||||
|
|
||||||
Newest first.
|
Newest first.
|
||||||
|
|
||||||
|
### B278 — `sync list` and `devices list` show the last sync as epoch seconds
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-02, issue #46. The reporter's `pdfs sync list` on 3.6.0 read
|
||||||
|
`synced: 1793570850`, a pass that finished at 2026-11-01 22:07 UTC.
|
||||||
|
**Where:** `cmd_sync` and `cmd_devices` in `crates/pdfs-cli/src/main.rs`
|
||||||
|
|
||||||
|
**Repro:** Let a synced folder finish one pass and run `pdfs sync list`, or `pdfs devices list`
|
||||||
|
on an account with a device that synced.
|
||||||
|
|
||||||
|
**Cause.** Both commands printed `last_sync` with `to_string()`. The activity feed already
|
||||||
|
formats the same epoch seconds with `format_epoch`.
|
||||||
|
|
||||||
|
**Fix.** Both columns go through `last_synced`, which prints `MM-DD HH:MM` in UTC like
|
||||||
|
`pdfs activity`, and `never` for 0 or no value. `--json` output is unchanged.
|
||||||
|
|
||||||
|
**Test:** `last_synced_shows_a_time_not_epoch_seconds` in `crates/pdfs-cli/src/main.rs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B277 — My files mounted over a folder with files in it, and hid them
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-31, issue #67. The reporter chose an existing working folder as the My files
|
||||||
|
location. Its folders disappeared and looked deleted; they came back when the service stopped.
|
||||||
|
**Where:** `mount_with` and `check_mountpoint` in `crates/pdfs-fuse/src/mount.rs`;
|
||||||
|
`crates/pdfs-core/src/mountpoint.rs`; `prompt_mountpoint` in
|
||||||
|
`crates/pdfs-gui/src/app/pages/status.rs`
|
||||||
|
|
||||||
|
**Repro:** Stop the service. Put a file and a folder in `~/ProtonDrive`, or choose a folder with
|
||||||
|
files in it under Preferences → General → Location. Start the service. The files are hidden
|
||||||
|
under the mount: not uploaded, not in the queue, and back only once the service stops.
|
||||||
|
|
||||||
|
**Cause.** FUSE mounts over a folder that is not empty without a word, and nothing checked the
|
||||||
|
folder first. `mount_once` only created it. On-demand synced folders were already checked when
|
||||||
|
they were mounted again after a restart (`restore_ondemand_mounts`); My files was not. The
|
||||||
|
folder picker took any folder.
|
||||||
|
|
||||||
|
**Fix.**
|
||||||
|
|
||||||
|
- The daemon refuses to mount My files over a folder with anything in it, hidden files
|
||||||
|
included, before it fetches the root or starts a worker. A dead mount is cleared first, so it
|
||||||
|
can be listed. One of this client's own live mounts is left to the mount, which clears it as
|
||||||
|
before.
|
||||||
|
- The refusal is a mount failure like any other: it backs off and tries again, so emptying the
|
||||||
|
folder is enough. The failure record now carries the folder and its first entries
|
||||||
|
(`MountFailure::not_empty`), which an older reader ignores.
|
||||||
|
- The app explains it in the user's language, and offers to open the folder or choose another
|
||||||
|
one. The folder picker refuses a folder with files in it.
|
||||||
|
|
||||||
|
Merging an existing folder into My files is not done here. #67 proposes `pdfs sync attach` for
|
||||||
|
that, as a separate change.
|
||||||
|
|
||||||
|
**Test:** `a_folder_with_files_is_refused_and_named`, `a_hidden_file_alone_is_enough_to_refuse`,
|
||||||
|
`an_empty_or_missing_folder_takes_the_mount` in `crates/pdfs-core/src/mountpoint.rs`;
|
||||||
|
`a_failure_an_older_daemon_wrote_still_reads` in `mountfail.rs`;
|
||||||
|
`a_mount_refused_over_files_is_recorded_as_such` in `crates/pdfs-cli/src/main.rs`; the
|
||||||
|
simulation `a_mount_over_local_files_is_refused_and_leaves_them_alone`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B276 — A stop can end with a panic in a thread the stop never waited for
|
||||||
|
|
||||||
|
**Status:** Fixed, checked on 2026-11-01 by an account run of `scripts/fuse-acceptance.sh
|
||||||
|
--journal-check --durability`: eight daemon restarts, and no panic in the journal
|
||||||
|
**Found:** 2026-10-31, by an account run of `scripts/fuse-acceptance.sh --journal-check
|
||||||
|
--durability` against the audit branches. Every case passed, and the run still failed. During
|
||||||
|
the restart in the durability case on My files, the journal had three
|
||||||
|
`ERROR fuser::reply: Failed to send FUSE reply: No such file or directory (os error 2)` lines,
|
||||||
|
and after them a panic that the check did not report:
|
||||||
|
|
||||||
|
```
|
||||||
|
thread '<unnamed>' (1536340) panicked at .../tokio-1.53.1/src/runtime/time/entry.rs:539:9:
|
||||||
|
A Tokio 1.x context was found, but it is being shutdown.
|
||||||
|
```
|
||||||
|
|
||||||
|
The same panic is in the journal of a 3.6.0 stop on 2026-10-28, twice. B91 first saw it in a
|
||||||
|
meta worker.
|
||||||
|
|
||||||
|
**Where:** `mount_once` and `release_runtime` in `crates/pdfs-cli/src/main.rs`;
|
||||||
|
`JournalWatch` and `journal_problems` in `scripts/fuse-acceptance.py`.
|
||||||
|
|
||||||
|
**Cause.** A stop joins the drain workers, the sync engine and the FUSE worker pool, and then
|
||||||
|
`mount_once` shut the tokio runtime down. Other threads also use the runtime and are never
|
||||||
|
joined: a size lookup for a listing, a profile backup, a control request, a database vacuum.
|
||||||
|
A request still in progress when the stop began can start one of them. The thread id here is
|
||||||
|
nine below the next daemon's pid, so this thread started during the stop. Once the runtime is
|
||||||
|
shut down, that thread's next timer panics. Every call to Drive has a timer, because each one is
|
||||||
|
bounded.
|
||||||
|
|
||||||
|
The three `fuser::reply` lines are part of a correct stop. The stop aborts each mount's FUSE
|
||||||
|
connection so the unmount cannot hang. A request that was still running replies after that,
|
||||||
|
and the kernel answers a reply on an aborted connection with `ENOENT`.
|
||||||
|
|
||||||
|
The journal check matched only the `ERROR` token. A panic goes to stderr with no tracing level,
|
||||||
|
so the check did not see it.
|
||||||
|
|
||||||
|
**Fix.**
|
||||||
|
|
||||||
|
- On a stop, `release_runtime` leaves the runtime running for the process to end with, instead
|
||||||
|
of shutting it down. The process exits right after, so a thread the stop did not wait for
|
||||||
|
can finish its call or be ended by the exit, but it cannot panic. A mount that is about to
|
||||||
|
be tried again still shuts its runtime down with the 2 s limit from B165, because a new
|
||||||
|
runtime replaces it.
|
||||||
|
- The journal check counts `panicked at` lines.
|
||||||
|
- The journal check ignores the `ENOENT` reply error between the connection abort and the end
|
||||||
|
of that stop ("daemon stopping", or "control socket listening" for a mount that comes back).
|
||||||
|
At any other time the error still counts.
|
||||||
|
|
||||||
|
Making the bounded call helpers refuse new calls once the stop flag is set was rejected. The
|
||||||
|
stop sets that flag before it waits up to 10 s for the drain workers, so the refusal would cut
|
||||||
|
off the second half of an upload the stop is waiting for.
|
||||||
|
|
||||||
|
**Test:** `a_thread_left_running_at_a_stop_can_still_use_the_runtime` and
|
||||||
|
`a_mount_tried_again_shuts_its_runtime_down` in `crates/pdfs-cli/src/main.rs`. With the old
|
||||||
|
shutdown, the first one fails with the same panic at `entry.rs:539`. The new journal
|
||||||
|
filter was run on the journal of the run that found this: the three reply errors are no longer
|
||||||
|
counted, and the panic is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B275 — On a slow link, a file's last write can land only in a conflict copy
|
||||||
|
|
||||||
|
**Status:** Open
|
||||||
|
**Found:** 2026-10-31, CI on #60: `one_client_on_a_slow_link`, profile `one-client-wifi` seed 2,
|
||||||
|
failed with `Drive does not hold what the clients wrote, 120s after the links came up`. The
|
||||||
|
same seed passed 4 times locally, and #60 changes no daemon logic, so the failure depends on
|
||||||
|
timing.
|
||||||
|
**Where:** not located; the drain's conflict check in `crates/pdfs-fuse/src/drain.rs` is the
|
||||||
|
first suspect
|
||||||
|
|
||||||
|
**Repro:** Only the CI run above. The last steps created `x/z/y/a.txt` (37 bytes), wrote 42
|
||||||
|
bytes into it at offset 20, and brought every link up. Drive then held the file at its first
|
||||||
|
37 bytes, and the full content was in `x/z/y/a (sync-conflict …).txt`. No other client wrote
|
||||||
|
to it.
|
||||||
|
|
||||||
|
**Cause.** Not known. Nothing was lost, since the conflict copy holds the bytes, but the file
|
||||||
|
conflicted with its own create. B183 and B184 found the same symptom after a lost create
|
||||||
|
reply.
|
||||||
|
|
||||||
|
**Fix.** Not done. Needs a reproduction: run the seed in a loop under load and keep the state
|
||||||
|
directory of a failing run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B274 — Opening a file for writing fails with EIO while its queued revision lands
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-31, CI: the simulation
|
||||||
|
`a_file_whose_upload_was_not_read_back_takes_two_writes_at_once` failed once with
|
||||||
|
`Input/output error` on a write, after `copy queued revision into write scratch failed … No
|
||||||
|
such file or directory`. It did not fail in 54 local runs.
|
||||||
|
**Where:** the write open in `crates/pdfs-fuse/src/filesystem.rs`
|
||||||
|
|
||||||
|
**Repro:** Write a file, and open it for writing again at the moment its queued revision
|
||||||
|
finishes uploading. The open fails with EIO, and the program sees its save fail.
|
||||||
|
|
||||||
|
**Cause.** The open samples the queued revision, then copies its blob into the new handle's
|
||||||
|
scratch file. The drain lets go of the pending entry and then deletes the blob when the upload
|
||||||
|
lands. A blob deleted between the sample and the copy failed the open, although the re-check
|
||||||
|
just after the copy already retries a revision that changed during it.
|
||||||
|
|
||||||
|
**Fix.** A failed copy checks whether the pending entry still names that blob. When it does
|
||||||
|
not, the revision landed or was replaced, and the open goes around again with the base the
|
||||||
|
file has now, as the re-check does. Since the drain drops the entry before the blob, a blob
|
||||||
|
that is gone and still named is a real failure and still returns EIO.
|
||||||
|
|
||||||
|
**Test:** none deterministic; the simulation above covers the window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### B273 — A restore proposes another computer's paths when the two share a hostname
|
### B273 — A restore proposes another computer's paths when the two share a hostname
|
||||||
|
|
||||||
**Status:** Open
|
**Status:** Open
|
||||||
@@ -416,10 +593,12 @@ folder list has now.
|
|||||||
|
|
||||||
### B271 — One failed listing makes a large first sync start over from nothing
|
### B271 — One failed listing makes a large first sync start over from nothing
|
||||||
|
|
||||||
**Status:** Open
|
**Status:** Fixed, not verified live
|
||||||
**Found:** 2026-10-31, code audit after issue #46: a folder of about 19,900 items restored on a
|
**Found:** 2026-10-31, code audit after issue #46: a folder of about 19,900 items restored on a
|
||||||
second computer sat at `scanning…` with nothing downloaded, and the journal showed
|
second computer sat at `scanning…` with nothing downloaded, and the journal showed
|
||||||
`RequestTimeout (http 408)`.
|
`RequestTimeout (http 408)`. On 2026-11-01 the reporter's 3.6.0 journal showed the same pass
|
||||||
|
failing four times in four minutes with `reconcile failed id=1 error=enumerate …: HTTP transport
|
||||||
|
error`, after the laptop woke from sleep; a later pass finished.
|
||||||
**Where:** `walk_remote` in `crates/pdfs-fuse/src/sync.rs`
|
**Where:** `walk_remote` in `crates/pdfs-fuse/src/sync.rs`
|
||||||
|
|
||||||
**Repro:** Restore or add a mirror folder with tens of thousands of items on Drive, on a link
|
**Repro:** Restore or add a mirror folder with tens of thousands of items on Drive, on a link
|
||||||
@@ -433,14 +612,19 @@ the walk is done. Files that did download keep their baseline rows, so only the
|
|||||||
but on a first sync that is all of it. Progress is also counted only after a folder's full
|
but on a first sync that is all of it. Progress is also counted only after a folder's full
|
||||||
listing, so a large folder shows `scanning…` with 0 checked for a long time.
|
listing, so a large folder shows `scanning…` with 0 checked for a long time.
|
||||||
|
|
||||||
**Fix.** Not done. Record a failed subtree as a gap, as undecryptable nodes already are: no
|
**Fix.** A listing that fails below the sync root records that folder as a gap in
|
||||||
deletions under it, and the rest of the pass still applies.
|
`RemoteGaps`, next to the folders whose listing came back short. The planner deletes nothing
|
||||||
|
under a gap, the rest of the pass still applies, and the pass report names the folder as
|
||||||
|
tried again next pass. A failed listing of the root itself still fails the pass, since there is
|
||||||
|
nothing left to apply. The progress count inside one large folder is unchanged.
|
||||||
|
|
||||||
|
**Test:** `a_folder_that_failed_to_list_hides_everything_under_it` in `sync::planner`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### B270 — One file left in a mirror folder clears the wipe guard for all the others
|
### B270 — One file left in a mirror folder clears the wipe guard for all the others
|
||||||
|
|
||||||
**Status:** Open
|
**Status:** Partly fixed
|
||||||
**Found:** 2026-10-31, code audit after issue #46, whose reporter emptied a synced folder
|
**Found:** 2026-10-31, code audit after issue #46, whose reporter emptied a synced folder
|
||||||
several times.
|
several times.
|
||||||
**Where:** `guard_local_wipe` and `guard_remote_wipe` in `crates/pdfs-fuse/src/sync/planner.rs`
|
**Where:** `guard_local_wipe` and `guard_remote_wipe` in `crates/pdfs-fuse/src/sync/planner.rs`
|
||||||
@@ -454,14 +638,26 @@ that two computers sync, the other computer then deletes its copies too.
|
|||||||
path is enough to clear it, so it guards against an empty folder and nothing short of that.
|
path is enough to clear it, so it guards against an empty folder and nothing short of that.
|
||||||
The remote guard has the same shape.
|
The remote guard has the same shape.
|
||||||
|
|
||||||
**Fix.** Not done. Also refuse when most of a large baseline is missing at once, and leave the
|
**Fix.** Both guards also refuse a pass when nine in ten or more of the tracked paths of a
|
||||||
choice to the user: delete on the other side, or sync from scratch.
|
folder with at least 20 are missing at once (`looks_like_a_wipe`). The folder goes into error with
|
||||||
|
the count in its reason, `pdfs diagnose` reports it, and **Sync From Scratch…** brings the files
|
||||||
|
back. A smaller folder still stops only on a total loss, since an ordinary cleanup can delete most
|
||||||
|
of it.
|
||||||
|
|
||||||
|
**What is left.** A loss under nine in ten, such as a disk that is half there, still goes
|
||||||
|
through. The app does not offer the choice yet: confirm the deletions on the other side, or sync
|
||||||
|
from scratch.
|
||||||
|
|
||||||
|
**Test:** `one_survivor_of_a_large_folder_does_not_clear_the_wipe_guards`,
|
||||||
|
`a_small_folder_trips_the_wipe_guards_only_when_everything_is_gone` in
|
||||||
|
`crates/pdfs-fuse/src/sync/planner.rs`.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### B269 — A restore that skipped every folder reports success
|
### B269 — A restore that skipped every folder reports success
|
||||||
|
|
||||||
**Status:** Open
|
**Status:** Partly fixed
|
||||||
**Found:** 2026-10-31, code audit after issue #46: the journal showed `restore complete
|
**Found:** 2026-10-31, code audit after issue #46: the journal showed `restore complete
|
||||||
restored=0 skipped=1` several times, and the user saw nothing.
|
restored=0 skipped=1` several times, and the user saw nothing.
|
||||||
**Where:** the `RestoreSyncFolders` and `RestoreDeviceFolders` handler in
|
**Where:** the `RestoreSyncFolders` and `RestoreDeviceFolders` handler in
|
||||||
@@ -476,15 +672,20 @@ result, `restored 0 folder(s); skipped: …`, is then logged as a successful act
|
|||||||
reason is only in the activity text and the journal. When the database insert fails partway,
|
reason is only in the activity text and the journal. When the database insert fails partway,
|
||||||
the `?` also skips the `Rewatch` for the folders already restored.
|
the `?` also skips the `Rewatch` for the folders already restored.
|
||||||
|
|
||||||
**Fix.** Not done. Log a restore that skipped folders as a failure, name the reason in the app
|
**Fix.** `restore_sync_folders` returns what it restored and what it skipped, with the reason
|
||||||
and the CLI, and offer to replace or reset the folder in the way. Send the `Rewatch` on every
|
for each skip. The daemon logs a restore that skipped any folder as a failed activity, with the
|
||||||
exit path.
|
reasons as its detail, and warns in the journal. A database error partway now sends the
|
||||||
|
`Rewatch` for the folders already restored before it returns. `pdfs devices join` points to
|
||||||
|
`pdfs activity` for the result.
|
||||||
|
|
||||||
|
Left: the app shows the result only in the activity feed, and nothing offers to replace or reset
|
||||||
|
the folder in the way.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### B268 — `pdfs diagnose` says "No problems found" while sync is stuck
|
### B268 — `pdfs diagnose` says "No problems found" while sync is stuck
|
||||||
|
|
||||||
**Status:** Open
|
**Status:** Partly fixed. The sync engine's own state is not checked yet.
|
||||||
**Found:** 2026-10-31, issue #53: a folder that never synced, and `diagnose` found nothing.
|
**Found:** 2026-10-31, issue #53: a folder that never synced, and `diagnose` found nothing.
|
||||||
**Where:** `diagnose` in `crates/pdfs-cli/src/main.rs`
|
**Where:** `diagnose` in `crates/pdfs-cli/src/main.rs`
|
||||||
|
|
||||||
@@ -497,8 +698,16 @@ folders or for `Diagnostics`, so a folder in error and a stalled worker are not
|
|||||||
control socket file with no daemon behind it is also reported `[ok]`; only the next line says
|
control socket file with no daemon behind it is also reported `[ok]`; only the next line says
|
||||||
the daemon does not answer.
|
the daemon does not answer.
|
||||||
|
|
||||||
**Fix.** Not done. Warn on failing or parked writes, a pause, old staged data and folders in
|
**Fix.** `diagnose` warns about writes that keep failing (with the last error), a write that
|
||||||
error, and report the sync engine's state once B259 exposes it.
|
has waited a day or more to upload, a pause, and each synced folder in `error` or `conflict`
|
||||||
|
(`sync_health` and `folder_health` in `crates/pdfs-cli/src/main.rs`). A control socket file
|
||||||
|
that nothing listens on is a warning, not `[ok]`. Parked uploads are left out: a file still
|
||||||
|
wearing a download's temporary name is normal. Not done: the sync engine's state, which
|
||||||
|
`Diagnostics` reports once B259's change is in.
|
||||||
|
|
||||||
|
**Test:** `a_stuck_queue_or_a_pause_is_reported` and `a_synced_folder_in_error_is_reported` in
|
||||||
|
`pdfs-cli`. On 2026-10-31 the new `diagnose` against a healthy 3.6.0 daemon still reported
|
||||||
|
"No problems found".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -525,7 +734,7 @@ report the others as their own state through `Status`.
|
|||||||
|
|
||||||
### B266 — Pin, unpin, search and node loads report a database error as "offline"
|
### B266 — Pin, unpin, search and node loads report a database error as "offline"
|
||||||
|
|
||||||
**Status:** Open
|
**Status:** Fixed, not verified live
|
||||||
**Found:** 2026-10-31, code audit after issue #38. B205 fixed the photo upload path only.
|
**Found:** 2026-10-31, code audit after issue #38. B205 fixed the photo upload path only.
|
||||||
**Where:** `CoreError::from_api` in `crates/pdfs-core/src/error.rs`; its callers in
|
**Where:** `CoreError::from_api` in `crates/pdfs-core/src/error.rs`; its callers in
|
||||||
`crates/pdfs-fuse/src/lib.rs` (`pin`, `unpin`, the searches, `load node`)
|
`crates/pdfs-fuse/src/lib.rs` (`pin`, `unpin`, the searches, `load node`)
|
||||||
@@ -538,14 +747,17 @@ it was written for SDK calls, where that means a transport error. These callers
|
|||||||
`pdfs_core::Error` values from the cache and the database. A `ProtonError` wrapped in
|
`pdfs_core::Error` values from the cache and the database. A `ProtonError` wrapped in
|
||||||
`pdfs_core::Error::Proton` also misses the downcast, since the source chain is not walked.
|
`pdfs_core::Error::Proton` also misses the downcast, since the source chain is not walked.
|
||||||
|
|
||||||
**Fix.** Not done. Use `CoreError::internal` at the database callers, and walk the source
|
**Fix.** `from_api` walks the source chain. The first `ProtonError` or `pdfs_core::Error` in it
|
||||||
chain in `from_api` before falling back.
|
decides the kind, the same way `From<Error>` does, and only an error with neither, a transport
|
||||||
|
error, reads as offline. That covers every caller at once, the database ones included.
|
||||||
|
|
||||||
|
**Test:** `a_database_error_handed_to_from_api_is_not_offline` in `pdfs-core`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### B265 — Address keys whose token is for an inactive account key never open
|
### B265 — Address keys whose token is for an inactive account key never open
|
||||||
|
|
||||||
**Status:** Open
|
**Status:** Fixed, not verified live
|
||||||
**Found:** 2026-10-31, code audit after issue #40: a Duo account whose mount failed on every
|
**Found:** 2026-10-31, code audit after issue #40: a Duo account whose mount failed on every
|
||||||
start with `fetch My Files root: cryptography error: failed to decrypt: missing key`, after three
|
start with `fetch My Files root: cryptography error: failed to decrypt: missing key`, after three
|
||||||
`failed to decrypt address key token` warnings. The web app worked. B210's fix does not apply,
|
`failed to decrypt address key token` warnings. The web app worked. B210's fix does not apply,
|
||||||
@@ -562,14 +774,23 @@ already reports "token is for inactive account key", so the case is known. Separ
|
|||||||
`addresses()` fails as a whole through `?` when one address has no usable primary key, which
|
`addresses()` fails as a whole through `?` when one address has no usable primary key, which
|
||||||
takes the working addresses down with it.
|
takes the working addresses down with it.
|
||||||
|
|
||||||
**Fix.** Not done, and it belongs in the SDK. Use every account key that opens for decrypting
|
**Fix.** In `proton-sdk` 0.10.1 (narrrl/proton-sdk-rs#3). Tokens are tried with every account
|
||||||
tokens, and only the active ones for signing. Skip and log an address that cannot be opened.
|
key that opens, active ones first, and signing uses active keys only. An address that cannot be
|
||||||
|
opened is left out with a warning, and the others work; `addresses()` fails only when none
|
||||||
|
opens. When the left-out address is the default, `default_address` fails with the reason
|
||||||
|
rather than signing with another address. `key_report` walks the same chain, so `pdfs diagnose`
|
||||||
|
reports such a key as opening. Not verified against an account with an inactive key, since
|
||||||
|
none is at hand; issue #40's reporter can confirm.
|
||||||
|
|
||||||
|
**Test:** `an_address_key_whose_token_is_for_an_inactive_account_key_opens`,
|
||||||
|
`an_address_whose_keys_do_not_open_leaves_the_others_working` and
|
||||||
|
`an_account_whose_addresses_all_fail_to_open_is_an_error` in `proton-sdk`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### B264 — A mount that fails after starting its workers leaves them running
|
### B264 — A mount that fails after starting its workers leaves them running
|
||||||
|
|
||||||
**Status:** Open
|
**Status:** Fixed, not verified live
|
||||||
**Found:** 2026-10-31, code audit after issue #40.
|
**Found:** 2026-10-31, code audit after issue #40.
|
||||||
**Where:** `run_mount` in `crates/pdfs-fuse/src/mount.rs`
|
**Where:** `run_mount` in `crates/pdfs-fuse/src/mount.rs`
|
||||||
|
|
||||||
@@ -583,14 +804,20 @@ permission check, a `spawn_session` error, and a `?` on a later thread spawn) sk
|
|||||||
the `flock` held, every later attempt fails with "another daemon is using cache.db" instead
|
the `flock` held, every later attempt fails with "another daemon is using cache.db" instead
|
||||||
of the real error. That part is inferred from the code and not observed.
|
of the real error. That part is inferred from the code and not observed.
|
||||||
|
|
||||||
**Fix.** Not done. Bind the socket and check the mountpoint before any worker starts, and stop
|
**Fix.** Every early return in `mount` after the first worker starts stops the workers
|
||||||
the workers on every error path.
|
(`abandon_workers`, `keep_or_abandon` in `crates/pdfs-fuse/src/mount.rs`). Unlike a normal stop
|
||||||
|
it does not send `STOPPING=1`, because the daemon is about to retry and systemd would
|
||||||
|
otherwise wait for it to exit and then kill it. The control-thread failure path, which already
|
||||||
|
stopped its workers, no longer sends it either.
|
||||||
|
|
||||||
|
**Test:** none of its own; the simulations (`sim::daemon`, 55 passed) cover the normal start
|
||||||
|
and stop.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### B263 — A mount that can never succeed retries every 5 s and tells only the journal
|
### B263 — A mount that can never succeed retries every 5 s and tells only the journal
|
||||||
|
|
||||||
**Status:** Open
|
**Status:** Partly fixed. Errors that no retry fixes are not told apart from network errors yet.
|
||||||
**Found:** 2026-10-31, issue #40: the app showed "Connecting… waiting for the Proton Drive
|
**Found:** 2026-10-31, issue #40: the app showed "Connecting… waiting for the Proton Drive
|
||||||
service" for days, `pdfs diagnose` only reported the control socket missing, and the reporter
|
service" for days, `pdfs diagnose` only reported the control socket missing, and the reporter
|
||||||
uninstalled.
|
uninstalled.
|
||||||
@@ -607,9 +834,23 @@ four requests, so a broken account sends tens of thousands a day, which may also
|
|||||||
Proton's sign-in blocks (#39). The control socket is bound only after the mount succeeds, and
|
Proton's sign-in blocks (#39). The control socket is bound only after the mount succeeds, and
|
||||||
the error is written nowhere a front end can read it.
|
the error is written nowhere a front end can read it.
|
||||||
|
|
||||||
**Fix.** Not done. Back off exponentially, treat key, `KeysLocked` and 4xx errors as permanent
|
**Fix.**
|
||||||
with a long back-off, and write the last error where the app, the tray and `diagnose` can
|
- The retry doubles from 5 s to a 2-minute ceiling (`mount_retry_delay` in
|
||||||
show it (a state file, and `STATUS=` to systemd).
|
`crates/pdfs-cli/src/main.rs`), about 720 attempts a day instead of 17,000. The ceiling stays
|
||||||
|
at minutes because a cold start without a network fails the same way and has to mount soon
|
||||||
|
after the network returns.
|
||||||
|
- Each failure is written to `mount-failure.json` in the state directory
|
||||||
|
(`crates/pdfs-core/src/mountfail.rs`), with the error, since when it fails and the attempt
|
||||||
|
count. A mount that comes up removes it, and so does a new daemon.
|
||||||
|
- The app shows "Can't open your Drive" with the error and a Retry button instead of
|
||||||
|
"Connecting…". The tray says "Signed in as … — can't open your Drive". `pdfs diagnose` reports
|
||||||
|
`[FAIL] mount: failing for …`. `systemctl --user status` shows the error as the unit's status.
|
||||||
|
- Not done: key, `KeysLocked` and 4xx errors still retry like network errors, only less often.
|
||||||
|
|
||||||
|
**Test:** `a_failing_mount_backs_off_to_a_ceiling` in `pdfs-cli`;
|
||||||
|
`a_recorded_failure_reads_back_until_cleared` in `pdfs-core`. On 2026-10-31 a second
|
||||||
|
`pdfs daemon` beside the running one failed on the database lock three times in 18 s (at 0, 5
|
||||||
|
and 15 s) and recorded `attempts: 3` with the lock error.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -749,7 +990,8 @@ is why it helps.
|
|||||||
|
|
||||||
### B258 — `journalctl -p warning` shows nothing, whatever the daemon logged
|
### B258 — `journalctl -p warning` shows nothing, whatever the daemon logged
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, checked on 2026-11-01 on a live account: after an acceptance run,
|
||||||
|
`journalctl --user -u proton-drive.service -p warning` listed all 76 `WARN` lines the run logged
|
||||||
**Found:** 2026-10-31, issue #53: the reporter ran `journalctl --user -u proton-drive.service -b
|
**Found:** 2026-10-31, issue #53: the reporter ran `journalctl --user -u proton-drive.service -b
|
||||||
-p warning`, as `docs/TROUBLESHOOTING.md` tells them to, and got `-- No entries --`.
|
-p warning`, as `docs/TROUBLESHOOTING.md` tells them to, and got `-- No entries --`.
|
||||||
**Where:** `main` in `crates/pdfs-cli/src/main.rs`; `packaging/proton-drive.service`
|
**Where:** `main` in `crates/pdfs-cli/src/main.rs`; `packaging/proton-drive.service`
|
||||||
@@ -774,9 +1016,46 @@ are off. A terminal that merely inherited `JOURNAL_STREAM` keeps the plain forma
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### B256 — `pdfs copy` right after a save copies the old version
|
### B257 — Deleting a new file while it is open can drop the bytes its handle reads
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-27, the simulation
|
||||||
|
`a_file_deleted_offline_while_open_still_reads_and_takes_writes` failing once on CI with a cache
|
||||||
|
tag left behind, then on every run with a 50 ms pause added to `ContentCache::store_file`.
|
||||||
|
**Where:** `retire_create` in `crates/pdfs-fuse/src/drain.rs`; `queue_trash` and
|
||||||
|
`remove_replaced` in `crates/pdfs-fuse/src/lib.rs`; `trash_child` in
|
||||||
|
`crates/pdfs-fuse/src/filesystem.rs`; `ContentCache::reconcile` in `crates/pdfs-core/src/cache.rs`
|
||||||
|
|
||||||
|
**Repro:** Create a file in the mount, wait until `pdfs status` shows nothing queued, open it,
|
||||||
|
and delete it while the link is down. If the create was still landing, a partial write or a read
|
||||||
|
through the open handle fails with "Input/output error", and the cache can keep a tag with no
|
||||||
|
bytes until the next start.
|
||||||
|
|
||||||
|
**Cause.** A create that carries the file's bytes retires its op, then reads the node back,
|
||||||
|
caches the bytes and only then gives the tree the real uid. Nothing counted that window, so the
|
||||||
|
queue read empty while it ran, which B119 promises means the file reads without the network. A
|
||||||
|
delete in that window found the placeholder uid in the tree, and trashed the node by its real
|
||||||
|
uid. `queue_trash` asked the tree whether that uid was open, found no entry for it, and dropped
|
||||||
|
the cached bytes the open handle reads, which B186 had kept. When the cache store was running at
|
||||||
|
the same moment, its tag was written after the eviction, and nothing removed it.
|
||||||
|
|
||||||
|
**Fix.** The create's landing is counted like an upload's, from the op's retirement until the
|
||||||
|
tree has the real uid. The delete and the replace pass whether they found the file open, so
|
||||||
|
`queue_trash` no longer depends on the tree having the new uid. Opening the cache removes a tag
|
||||||
|
whose bytes are gone, which also covers a crash between an eviction's two removals.
|
||||||
|
|
||||||
|
**Test:** The simulation `a_new_file_deleted_while_open_as_its_create_lands_still_reads` holds a
|
||||||
|
create's landing and fails without the count. The simulation above passes again with the 50 ms
|
||||||
|
pause, since it now waits for `Daemon::drained`, which counts the landing. Unit test
|
||||||
|
`a_tag_without_its_blob_is_swept_at_open` in `crates/pdfs-core/src/cache.rs`. No test hits the
|
||||||
|
delete inside the cache store itself: the window is too short to hold from the simulation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B256 — `pdfs copy` right after a save copies the old version
|
||||||
|
|
||||||
|
**Status:** Fixed, checked on 2026-10-27 by the acceptance case "regression B256" on a live
|
||||||
|
account
|
||||||
**Found:** 2026-10-26, the simulation `a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes`
|
**Found:** 2026-10-26, the simulation `a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes`
|
||||||
failing on most runs. The feature was not released yet.
|
failing on most runs. The feature was not released yet.
|
||||||
**Where:** `release` in `crates/pdfs-fuse/src/filesystem.rs`; `copy_to` in
|
**Where:** `release` in `crates/pdfs-fuse/src/filesystem.rs`; `copy_to` in
|
||||||
@@ -800,7 +1079,8 @@ A move checks the same set.
|
|||||||
|
|
||||||
### B255 — A synced folder whose files are all gone here is stuck for good
|
### B255 — A synced folder whose files are all gone here is stuck for good
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, checked on 2026-10-27 by the acceptance case "regression B255" on a live
|
||||||
|
account
|
||||||
**Found:** 2026-10-26, issue #46: a folder restored from another computer never downloaded.
|
**Found:** 2026-10-26, issue #46: a folder restored from another computer never downloaded.
|
||||||
**Where:** `push_pass` in `crates/pdfs-fuse/src/sync.rs`; `reset_sync_folder` in
|
**Where:** `push_pass` in `crates/pdfs-fuse/src/sync.rs`; `reset_sync_folder` in
|
||||||
`crates/pdfs-fuse/src/devices.rs`
|
`crates/pdfs-fuse/src/devices.rs`
|
||||||
|
|||||||
@@ -11,6 +11,101 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.7.0] — 2026-11-02
|
||||||
|
|
||||||
|
Schema: **44**, unchanged.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **The app and the tray say why your Drive does not open** (B263, #40). When the service runs
|
||||||
|
but its mount keeps failing, the app shows **Can't open your Drive** with the last error and a
|
||||||
|
**Retry** button, instead of "Connecting…" for as long as it lasts. The tray says "can't open
|
||||||
|
your Drive", `pdfs diagnose` reports `mount: failing for …`, and
|
||||||
|
`systemctl --user status proton-drive` shows the error as the unit's status.
|
||||||
|
|
||||||
|
- **`pdfs diagnostics` shows what the sync engine is doing**, in a new `sync engine:` line:
|
||||||
|
idle, the folder it is on and for how long, or stopped. The journal warns when the engine has
|
||||||
|
stopped, or when a pass makes no progress for 15 minutes (B259, #53).
|
||||||
|
|
||||||
|
- **`pdfs diagnose` reports more problems** (B268, #53): writes that keep failing, data that has
|
||||||
|
waited a day or more to be sent, a paused sync, synced folders in error or conflict, and a
|
||||||
|
control socket file that nothing listens on.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **A mount that keeps failing retries less often**, doubling from 5 seconds to every 2 minutes,
|
||||||
|
instead of every 5 seconds forever (B263).
|
||||||
|
|
||||||
|
- **`proton-sdk` and `proton-drive-rs` 0.10.1.** Dependencies are updated, among them
|
||||||
|
`rusqlite` 0.41 with a newer bundled SQLite.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **My files no longer opens over a folder that already has files in it** (#67, B277). The files
|
||||||
|
were hidden under it, not uploaded, and came back only when the service stopped, so they looked
|
||||||
|
deleted. The service now waits until the folder is empty, and the app says why and offers to
|
||||||
|
open the folder or choose another one. The folder picker in Preferences refuses a folder with
|
||||||
|
files in it. **After upgrading:** if your `~/ProtonDrive` has files underneath, for example
|
||||||
|
saved there while the service was stopped, My files does not open until you move them out.
|
||||||
|
|
||||||
|
- **The sidebar no longer says "Proton Drive isn't running" while the service is running**
|
||||||
|
(B263, B277). When the service runs but cannot open My files, for example because its folder
|
||||||
|
has files in it, the status at the bottom of the sidebar and on the Sync page now reads
|
||||||
|
"Can't open your Drive" with the reason, like the page itself.
|
||||||
|
|
||||||
|
- **A synced folder stops instead of deleting when nearly all of it vanishes at once** (B270).
|
||||||
|
The guard against a lost folder, such as a disk that is not mounted, stopped a pass only when
|
||||||
|
every synced file was gone. One file left over was enough to clear it, and the next pass
|
||||||
|
trashed everything else on Proton Drive, and on every other computer syncing the folder. A
|
||||||
|
folder with 20 or more files and folders now also stops when nine in ten of them are missing,
|
||||||
|
on this computer or on Proton Drive. **Sync From Scratch…** brings them back.
|
||||||
|
|
||||||
|
- **My Files opens for accounts whose address keys depend on an inactive account key** (B265,
|
||||||
|
#40). The mount retried `missing key` forever. Address key tokens are now decrypted with every
|
||||||
|
account key the password opens, and one address whose keys do not open no longer breaks the
|
||||||
|
others.
|
||||||
|
|
||||||
|
- **A new folder starts its first sync at once** (B260, #53), instead of waiting until every
|
||||||
|
other folder was walked again. A folder watch that failed partway is retried every 10 minutes
|
||||||
|
instead of never (B262), and a folder removed and added again keeps its watch (B272).
|
||||||
|
|
||||||
|
- **A crash in one folder's sync no longer stops all syncing until a restart** (B259, #53). That
|
||||||
|
folder shows a sync error, and the next pass tries it again.
|
||||||
|
|
||||||
|
- **A folder reads "waiting for first sync" until its first sync finishes**, instead of "up to
|
||||||
|
date", on **Sync → Folders** and on the Computers page (B261, #53).
|
||||||
|
|
||||||
|
- **A large first sync goes on when one folder on Proton Drive fails to list** (B271, #46). It
|
||||||
|
used to start over from nothing. The folder that failed is tried again on the next pass, and
|
||||||
|
nothing under it is deleted.
|
||||||
|
|
||||||
|
- **A restore that skipped folders is reported as failed**, with the reason for each, instead of
|
||||||
|
"restored 0 folder(s)" as a success (B269).
|
||||||
|
|
||||||
|
- **`pdfs sync list` and `pdfs devices list` show when a folder last synced as a time**, such as
|
||||||
|
`synced: 11-01 22:07` in UTC like `pdfs activity`, instead of seconds since 1970 (B278, #46).
|
||||||
|
|
||||||
|
- **"You're offline" no longer appears for a cache or database error** when pinning, unpinning,
|
||||||
|
searching or loading a folder (B266, #38). The app shows the actual error.
|
||||||
|
|
||||||
|
- **A save no longer fails with "Input/output error"** when the file is opened for writing just
|
||||||
|
as its last change finishes uploading (B274).
|
||||||
|
|
||||||
|
- **`journalctl --user -u proton-drive.service -p warning` shows the service's warnings and
|
||||||
|
errors** (B258, #53). Every line was stored at info level, so the filter showed nothing.
|
||||||
|
|
||||||
|
- **Stopping the service no longer ends with a panic in the journal** (B276).
|
||||||
|
|
||||||
|
- **A failed mount attempt stops the workers it started** (B264). They kept running beside the
|
||||||
|
next attempt and held the database.
|
||||||
|
|
||||||
|
- **A new file reads without the network as soon as `pdfs status` shows nothing queued**
|
||||||
|
(B257). A new file's upload counted as done before its bytes were cached, so a script that
|
||||||
|
waited for an empty queue and then went offline could not read it. A new file deleted while
|
||||||
|
open in that moment could also lose the bytes its open handle reads, which made a write or
|
||||||
|
read through that handle fail with "Input/output error". The cache also removes leftover
|
||||||
|
tags of evicted files when it opens.
|
||||||
|
|
||||||
## [3.6.0] — 2026-10-27
|
## [3.6.0] — 2026-10-27
|
||||||
|
|
||||||
Schema: **44**, unchanged.
|
Schema: **44**, unchanged.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ fix it.
|
|||||||
|
|
||||||
| Key | Type | Default | In the app | Meaning |
|
| Key | Type | Default | In the app | Meaning |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `mountpoint` | path | `~/ProtonDrive` | General → Location | Where My files is mounted |
|
| `mountpoint` | path | `~/ProtonDrive` | General → Location | Where My files is mounted. Must be empty: the service does not mount over files |
|
||||||
| `cache_budget` | bytes | 5 GiB | Storage → Cache | Soft limit for cached content. `0` means unlimited. Available-offline files do not count against it |
|
| `cache_budget` | bytes | 5 GiB | Storage → Cache | Soft limit for cached content. `0` means unlimited. Available-offline files do not count against it |
|
||||||
| `upload_limit` | bytes/s | none | General → Network | Shared cap for all uploads. `0` or `null` means none |
|
| `upload_limit` | bytes/s | none | General → Network | Shared cap for all uploads. `0` or `null` means none |
|
||||||
| `download_limit` | bytes/s | none | General → Network | Shared cap for all downloads, reads from the mount included |
|
| `download_limit` | bytes/s | none | General → Network | Shared cap for all downloads, reads from the mount included |
|
||||||
|
|||||||
+3
-1
@@ -28,7 +28,9 @@ Yes. `~/ProtonDrive` is your Proton Drive, not a copy. A deleted file moves to t
|
|||||||
Trash, and you can restore it from **Trash** in the app or the web. See [Trash](FILES.md#trash).
|
Trash, and you can restore it from **Trash** in the app or the web. See [Trash](FILES.md#trash).
|
||||||
|
|
||||||
Stopping the service is not the same: once the service stops, `~/ProtonDrive` is just an empty
|
Stopping the service is not the same: once the service stops, `~/ProtonDrive` is just an empty
|
||||||
folder, and your files stay on Proton Drive.
|
folder, and your files stay on Proton Drive. Keep it empty: the service does not open My files
|
||||||
|
over a folder with files in it, because it would hide them. See
|
||||||
|
[My files does not open: the folder is not empty](TROUBLESHOOTING.md#my-files-does-not-open-the-folder-is-not-empty).
|
||||||
|
|
||||||
## Online only, available offline or synced folder: which do I want?
|
## Online only, available offline or synced folder: which do I want?
|
||||||
|
|
||||||
|
|||||||
+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:
|
the tag to update:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.6.0";
|
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.7.0";
|
||||||
```
|
```
|
||||||
|
|
||||||
Each release tag is built with Nix before the release is published.
|
Each release tag is built with Nix before the release is published.
|
||||||
|
|||||||
+1
-1
@@ -246,7 +246,7 @@ remaining cases are skipped. Cases that make many round trips by design get twic
|
|||||||
| `--report-json PATH` | Every result and recorded observation. Attach it to a bug report |
|
| `--report-json PATH` | Every result and recorded observation. Attach it to a bug report |
|
||||||
| `--report-junit PATH` | JUnit XML. `known` is reported as skipped |
|
| `--report-junit PATH` | JUnit XML. `known` is reported as skipped |
|
||||||
| `--budget SECONDS` | Flags cases that pass but take longer (`PDFS_ACCEPTANCE_BUDGET`) |
|
| `--budget SECONDS` | Flags cases that pass but take longer (`PDFS_ACCEPTANCE_BUDGET`) |
|
||||||
| `--journal-check` | Fails the run if `proton-drive.service` logged an error during it, or the warnings "queued write conflicts; keeping a conflict copy" and "a fuse worker has held the same job for a long time" |
|
| `--journal-check` | Fails the run if `proton-drive.service` logged an error or a panic during it, or the warnings "queued write conflicts; keeping a conflict copy" and "a fuse worker has held the same job for a long time". A `Failed to send FUSE reply: No such file or directory` during a stop does not count: it is a request answering after the stop aborted its connection |
|
||||||
| `--durability` | Restarts the service mid-suite and checks the bytes written before it. Proves the queue survives a restart. `PDFS_ACCEPTANCE_UNIT` names another unit |
|
| `--durability` | Restarts the service mid-suite and checks the bytes written before it. Proves the queue survives a restart. `PDFS_ACCEPTANCE_UNIT` names another unit |
|
||||||
| `--fail-fast` | Stops at the first failure |
|
| `--fail-fast` | Stops at the first failure |
|
||||||
| `--list` | Prints every case and its targets |
|
| `--list` | Prints every case and its targets |
|
||||||
|
|||||||
+18
-3
@@ -141,6 +141,20 @@ fusermount3 -uz ~/ProtonDrive
|
|||||||
systemctl --user restart proton-drive.service
|
systemctl --user restart proton-drive.service
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### My files does not open: the folder is not empty
|
||||||
|
|
||||||
|
The app says **Your Proton Drive Folder Isn't Empty**, and `pdfs diagnose` reports
|
||||||
|
`mount: failing … is not empty`. The folder chosen for My files has files in it, and the service
|
||||||
|
does not open My files over them: they would be hidden, and not uploaded, for as long as the
|
||||||
|
service runs.
|
||||||
|
|
||||||
|
Move the files out of the folder, or choose another folder in **Preferences → General →
|
||||||
|
Location**. The service tries again on its own and opens once the folder is empty. To put the
|
||||||
|
files on Proton Drive, copy them into My files once it is open.
|
||||||
|
|
||||||
|
Files saved into `~/ProtonDrive` while the service was stopped are in the same place: in the
|
||||||
|
local folder, not on Proton Drive.
|
||||||
|
|
||||||
### The folder is empty or missing
|
### The folder is empty or missing
|
||||||
|
|
||||||
Check that the service runs and is signed in, with `pdfs status`. The service waits for a stored
|
Check that the service runs and is signed in, with `pdfs status`. The service waits for a stored
|
||||||
@@ -234,9 +248,10 @@ Check that:
|
|||||||
|
|
||||||
### A synced folder stays in error and nothing downloads
|
### A synced folder stays in error and nothing downloads
|
||||||
|
|
||||||
The log says `every one of the N synced paths is missing locally; refusing to trash them on
|
The log says `M of the N synced paths are missing locally; refusing to trash them on Drive`.
|
||||||
Drive`. The folder synced before, and now none of its files are on this computer. The sync
|
The folder synced before, and now all or nearly all of its files are gone from this computer:
|
||||||
stops so that it does not delete them on Proton Drive too. Check that the folder is the right
|
everything, or nine in ten of a folder with 20 or more files and folders. The sync stops so that
|
||||||
|
it does not delete them on Proton Drive too. Check that the folder is the right
|
||||||
one and that its disk is mounted. To get the files back, choose **Sync From Scratch…** in the
|
one and that its disk is mounted. To get the files back, choose **Sync From Scratch…** in the
|
||||||
folder's menu on **Sync → Folders**, or run:
|
folder's menu on **Sync → Folders**, or run:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=3.6.0
|
pkgver=3.7.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-bin
|
pkgbase = proton-drive-for-linux-bin
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||||
pkgver = 3.6.0
|
pkgver = 3.7.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
install = proton-drive.install
|
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 = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=3.6.0
|
provides = proton-drive-for-linux=3.7.0
|
||||||
provides = proton-drive-linux=3.6.0
|
provides = proton-drive-linux=3.7.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !strip
|
options = !strip
|
||||||
options = !debug
|
options = !debug
|
||||||
source = proton-drive-linux-3.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 = 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.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-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.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-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.6.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.svg
|
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.6.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/proton-drive.service
|
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.6.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/LICENSE
|
source = LICENSE-3.7.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/LICENSE
|
||||||
sha256sums = 8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7
|
sha256sums = 3f0d16c19b10b30e3f181e3c7fb439f0a8c38980e8ab7a4a96ae6596b349787c
|
||||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-bin
|
pkgname=proton-drive-for-linux-bin
|
||||||
pkgver=3.6.0
|
pkgver=3.7.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -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"
|
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||||
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
||||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||||
sha256sums=('8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7'
|
sha256sums=('3f0d16c19b10b30e3f181e3c7fb439f0a8c38980e8ab7a4a96ae6596b349787c'
|
||||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-git
|
pkgbase = proton-drive-for-linux-git
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||||
pkgver = 3.6.0.r0.g2720191
|
pkgver = 3.7.0.r0.g7ab14bb
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
install = proton-drive.install
|
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 = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=3.6.0.r0.g2720191
|
provides = proton-drive-for-linux=3.7.0.r0.g7ab14bb
|
||||||
provides = proton-drive-linux=3.6.0.r0.g2720191
|
provides = proton-drive-linux=3.7.0.r0.g7ab14bb
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-git
|
pkgname=proton-drive-for-linux-git
|
||||||
pkgver=3.6.0.r0.g2720191
|
pkgver=3.7.0.r0.g7ab14bb
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux
|
pkgbase = proton-drive-for-linux
|
||||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||||
pkgver = 3.6.0
|
pkgver = 3.7.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
install = proton-drive.install
|
install = proton-drive.install
|
||||||
@@ -24,13 +24,13 @@ pkgbase = proton-drive-for-linux
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-linux=3.6.0
|
provides = proton-drive-linux=3.7.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux-bin
|
conflicts = proton-drive-for-linux-bin
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !lto
|
options = !lto
|
||||||
source = proton-drive-for-linux-3.6.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.6.0.tar.gz
|
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 = d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d
|
sha256sums = 542c534976e335cde6658de127e1eefffade9057d5332fd33457bc371c7a2737
|
||||||
|
|
||||||
pkgname = proton-drive-for-linux
|
pkgname = proton-drive-for-linux
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux
|
pkgname=proton-drive-for-linux
|
||||||
pkgver=3.6.0
|
pkgver=3.7.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -26,7 +26,7 @@ options=('!lto')
|
|||||||
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
||||||
install=proton-drive.install
|
install=proton-drive.install
|
||||||
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
sha256sums=('d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d')
|
sha256sums=('542c534976e335cde6658de127e1eefffade9057d5332fd33457bc371c7a2737')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
||||||
%global _lto_cflags %{nil}
|
%global _lto_cflags %{nil}
|
||||||
|
|
||||||
%{!?version: %global version 3.6.0}
|
%{!?version: %global version 3.7.0}
|
||||||
%global forge https://github.com/narrrl/proton-drive-linux
|
%global forge https://github.com/narrrl/proton-drive-linux
|
||||||
# Files come from the checkout, or from the unpacked tag archive.
|
# Files come from the checkout, or from the unpacked tag archive.
|
||||||
%global src %{?git_dir}%{!?git_dir:.}
|
%global src %{?git_dir}%{!?git_dir:.}
|
||||||
|
|||||||
+244
-154
@@ -5,7 +5,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: proton-drive-linux 2.1.0\n"
|
"Project-Id-Version: proton-drive-linux 2.1.0\n"
|
||||||
"Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n"
|
"Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n"
|
||||||
"POT-Creation-Date: 2026-10-31 10:26+0100\n"
|
"POT-Creation-Date: 2026-11-01 17:51+0100\n"
|
||||||
"PO-Revision-Date: 2026-09-23 12:00+0200\n"
|
"PO-Revision-Date: 2026-09-23 12:00+0200\n"
|
||||||
"Last-Translator: Proton Drive for Linux contributors\n"
|
"Last-Translator: Proton Drive for Linux contributors\n"
|
||||||
"Language-Team: English (United Kingdom)\n"
|
"Language-Team: English (United Kingdom)\n"
|
||||||
@@ -57,7 +57,7 @@ msgstr "%-d %b %Y"
|
|||||||
msgid "%A, %B %-d, %Y, %H:%M"
|
msgid "%A, %B %-d, %Y, %H:%M"
|
||||||
msgstr "%A %-d %B %Y, %H:%M"
|
msgstr "%A %-d %B %Y, %H:%M"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:856
|
#: crates/pdfs-gui/src/app/main.rs:860
|
||||||
#: crates/pdfs-gui/src/app/pages/activity_text.rs:543
|
#: crates/pdfs-gui/src/app/pages/activity_text.rs:543
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:454
|
#: crates/pdfs-gui/src/app/pages/browser.rs:454
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:833
|
#: crates/pdfs-gui/src/app/pages/browser.rs:833
|
||||||
@@ -70,74 +70,74 @@ msgstr "%A %-d %B %Y, %H:%M"
|
|||||||
msgid "My files"
|
msgid "My files"
|
||||||
msgstr "My files"
|
msgstr "My files"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:859 crates/pdfs-gui/src/app/main.rs:1528
|
#: crates/pdfs-gui/src/app/main.rs:863 crates/pdfs-gui/src/app/main.rs:1532
|
||||||
#: crates/pdfs-gui/src/app/pages/albums.rs:638
|
#: crates/pdfs-gui/src/app/pages/albums.rs:638
|
||||||
#: crates/pdfs-gui/src/app/pages/photos.rs:845
|
#: crates/pdfs-gui/src/app/pages/photos.rs:845
|
||||||
#: crates/pdfs-gui/src/app/pages/places.rs:441
|
#: crates/pdfs-gui/src/app/pages/places.rs:441
|
||||||
msgid "Photos"
|
msgid "Photos"
|
||||||
msgstr "Photos"
|
msgstr "Photos"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:864
|
#: crates/pdfs-gui/src/app/main.rs:868
|
||||||
#: crates/pdfs-gui/src/app/pages/shared.rs:168
|
#: crates/pdfs-gui/src/app/pages/shared.rs:168
|
||||||
#: crates/pdfs-gui/src/app/pages/shared.rs:484
|
#: crates/pdfs-gui/src/app/pages/shared.rs:484
|
||||||
msgid "Shared with me"
|
msgid "Shared with me"
|
||||||
msgstr "Shared with me"
|
msgstr "Shared with me"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:869
|
#: crates/pdfs-gui/src/app/main.rs:873
|
||||||
#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120
|
#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120
|
||||||
msgid "Shared by me"
|
msgid "Shared by me"
|
||||||
msgstr "Shared by me"
|
msgstr "Shared by me"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:872
|
#: crates/pdfs-gui/src/app/main.rs:876
|
||||||
#: crates/pdfs-gui/src/app/pages/devices.rs:140
|
#: crates/pdfs-gui/src/app/pages/devices.rs:140
|
||||||
#: crates/pdfs-gui/src/app/pages/devices.rs:493
|
#: crates/pdfs-gui/src/app/pages/devices.rs:493
|
||||||
msgid "Computers"
|
msgid "Computers"
|
||||||
msgstr "Computers"
|
msgstr "Computers"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:873
|
#: crates/pdfs-gui/src/app/main.rs:877
|
||||||
#: crates/pdfs-gui/src/app/pages/trash.rs:101
|
#: crates/pdfs-gui/src/app/pages/trash.rs:101
|
||||||
msgid "Trash"
|
msgid "Trash"
|
||||||
msgstr "Trash"
|
msgstr "Trash"
|
||||||
|
|
||||||
#. Translators: sidebar entry for the page listing this computer's synced folders.
|
#. Translators: sidebar entry for the page listing this computer's synced folders.
|
||||||
#: crates/pdfs-gui/src/app/main.rs:875
|
#: crates/pdfs-gui/src/app/main.rs:879
|
||||||
msgid "Sync"
|
msgid "Sync"
|
||||||
msgstr "Sync"
|
msgstr "Sync"
|
||||||
|
|
||||||
#. Translators: a failed action's toast. {what} says what failed, such as
|
#. Translators: a failed action's toast. {what} says what failed, such as
|
||||||
#. "Couldn't rename"; {detail} is the reason, usually untranslated text
|
#. "Couldn't rename"; {detail} is the reason, usually untranslated text
|
||||||
#. from the mount service.
|
#. from the mount service.
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1225
|
#: crates/pdfs-gui/src/app/main.rs:1229
|
||||||
#: crates/pdfs-gui/src/app/pages/takeout.rs:714
|
#: crates/pdfs-gui/src/app/pages/takeout.rs:714
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{what}: {detail}"
|
msgid "{what}: {detail}"
|
||||||
msgstr "{what}: {detail}"
|
msgstr "{what}: {detail}"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1248
|
#: crates/pdfs-gui/src/app/main.rs:1252
|
||||||
msgid "You're offline"
|
msgid "You're offline"
|
||||||
msgstr "You're offline"
|
msgstr "You're offline"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1249
|
#: crates/pdfs-gui/src/app/main.rs:1253
|
||||||
msgid "That's not there any more"
|
msgid "That's not there any more"
|
||||||
msgstr "That's not there any more"
|
msgstr "That's not there any more"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1250
|
#: crates/pdfs-gui/src/app/main.rs:1254
|
||||||
msgid "You don't have access to that"
|
msgid "You don't have access to that"
|
||||||
msgstr "You don't have access to that"
|
msgstr "You don't have access to that"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1251
|
#: crates/pdfs-gui/src/app/main.rs:1255
|
||||||
msgid "Something changed this first"
|
msgid "Something changed this first"
|
||||||
msgstr "Something changed this first"
|
msgstr "Something changed this first"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1252
|
#: crates/pdfs-gui/src/app/main.rs:1256
|
||||||
msgid "Your Proton Drive is full"
|
msgid "Your Proton Drive is full"
|
||||||
msgstr "Your Proton Drive is full"
|
msgstr "Your Proton Drive is full"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1339
|
#: crates/pdfs-gui/src/app/main.rs:1343
|
||||||
msgid "Proton Drive for Linux"
|
msgid "Proton Drive for Linux"
|
||||||
msgstr "Proton Drive for Linux"
|
msgstr "Proton Drive for Linux"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1347
|
#: crates/pdfs-gui/src/app/main.rs:1351
|
||||||
msgid ""
|
msgid ""
|
||||||
"Files-on-demand Proton Drive for the Linux desktop.\n"
|
"Files-on-demand Proton Drive for the Linux desktop.\n"
|
||||||
"\n"
|
"\n"
|
||||||
@@ -149,23 +149,23 @@ msgstr ""
|
|||||||
"Unofficial client — not affiliated with, endorsed by, or supported by Proton "
|
"Unofficial client — not affiliated with, endorsed by, or supported by Proton "
|
||||||
"AG."
|
"AG."
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1352
|
#: crates/pdfs-gui/src/app/main.rs:1356
|
||||||
msgid "Source Code"
|
msgid "Source Code"
|
||||||
msgstr "Source Code"
|
msgstr "Source Code"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1356
|
#: crates/pdfs-gui/src/app/main.rs:1360
|
||||||
msgid "Proton Terms of Service"
|
msgid "Proton Terms of Service"
|
||||||
msgstr "Proton Terms of Service"
|
msgstr "Proton Terms of Service"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1360
|
#: crates/pdfs-gui/src/app/main.rs:1364
|
||||||
msgid "Proton Privacy Policy"
|
msgid "Proton Privacy Policy"
|
||||||
msgstr "Proton Privacy Policy"
|
msgstr "Proton Privacy Policy"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1365
|
#: crates/pdfs-gui/src/app/main.rs:1369
|
||||||
msgid "Disclaimer"
|
msgid "Disclaimer"
|
||||||
msgstr "Disclaimer"
|
msgstr "Disclaimer"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1369
|
#: crates/pdfs-gui/src/app/main.rs:1373
|
||||||
msgid ""
|
msgid ""
|
||||||
"This is an unofficial, community-built client. It is not affiliated with, "
|
"This is an unofficial, community-built client. It is not affiliated with, "
|
||||||
"endorsed by, or supported by Proton AG.\n"
|
"endorsed by, or supported by Proton AG.\n"
|
||||||
@@ -187,7 +187,7 @@ msgstr ""
|
|||||||
"This software is provided “as is”, without warranty of any kind. Keep a copy "
|
"This software is provided “as is”, without warranty of any kind. Keep a copy "
|
||||||
"of any data you cannot afford to lose."
|
"of any data you cannot afford to lose."
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1377
|
#: crates/pdfs-gui/src/app/main.rs:1381
|
||||||
msgid ""
|
msgid ""
|
||||||
"Places are named with data from <a href=\"https://www.geonames.org/"
|
"Places are named with data from <a href=\"https://www.geonames.org/"
|
||||||
"\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/"
|
"\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/"
|
||||||
@@ -197,7 +197,7 @@ msgstr ""
|
|||||||
"\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/"
|
"\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/"
|
||||||
"licenses/by/4.0/\">CC BY 4.0</a>."
|
"licenses/by/4.0/\">CC BY 4.0</a>."
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1385
|
#: crates/pdfs-gui/src/app/main.rs:1389
|
||||||
msgid ""
|
msgid ""
|
||||||
"The places map is drawn from <a href=\"https://www.naturalearthdata.com/"
|
"The places map is drawn from <a href=\"https://www.naturalearthdata.com/"
|
||||||
"\">Natural Earth</a> land outlines and country borders, which are in the "
|
"\">Natural Earth</a> land outlines and country borders, which are in the "
|
||||||
@@ -207,7 +207,7 @@ msgstr ""
|
|||||||
"\">Natural Earth</a> land outlines and country borders, which are in the "
|
"\">Natural Earth</a> land outlines and country borders, which are in the "
|
||||||
"public domain."
|
"public domain."
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1393
|
#: crates/pdfs-gui/src/app/main.rs:1397
|
||||||
msgid ""
|
msgid ""
|
||||||
"The street map, when turned on, comes from <a href=\"https://openfreemap.org/"
|
"The street map, when turned on, comes from <a href=\"https://openfreemap.org/"
|
||||||
"\">OpenFreeMap</a> in the <a href=\"https://www.openmaptiles.org/"
|
"\">OpenFreeMap</a> in the <a href=\"https://www.openmaptiles.org/"
|
||||||
@@ -223,15 +223,15 @@ msgstr ""
|
|||||||
|
|
||||||
#. Translators: replace with your name(s) and email address(es), one
|
#. Translators: replace with your name(s) and email address(es), one
|
||||||
#. per line, to be credited in the About dialog.
|
#. per line, to be credited in the About dialog.
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1404
|
#: crates/pdfs-gui/src/app/main.rs:1408
|
||||||
msgid "translator-credits"
|
msgid "translator-credits"
|
||||||
msgstr "Proton Drive for Linux contributors"
|
msgstr "Proton Drive for Linux contributors"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1448
|
#: crates/pdfs-gui/src/app/main.rs:1452
|
||||||
msgid "Stop Proton Drive?"
|
msgid "Stop Proton Drive?"
|
||||||
msgstr "Stop Proton Drive?"
|
msgstr "Stop Proton Drive?"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1449
|
#: crates/pdfs-gui/src/app/main.rs:1453
|
||||||
msgid ""
|
msgid ""
|
||||||
"My files goes offline and nothing syncs until you connect again. Proton "
|
"My files goes offline and nothing syncs until you connect again. Proton "
|
||||||
"Drive starts again at your next login."
|
"Drive starts again at your next login."
|
||||||
@@ -239,7 +239,7 @@ msgstr ""
|
|||||||
"My files goes offline and nothing syncs until you connect again. Proton "
|
"My files goes offline and nothing syncs until you connect again. Proton "
|
||||||
"Drive starts again at your next login."
|
"Drive starts again at your next login."
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1451 crates/pdfs-gui/src/app/main.rs:1726
|
#: crates/pdfs-gui/src/app/main.rs:1455 crates/pdfs-gui/src/app/main.rs:1730
|
||||||
#: crates/pdfs-gui/src/app/pages/albums.rs:243
|
#: crates/pdfs-gui/src/app/pages/albums.rs:243
|
||||||
#: crates/pdfs-gui/src/app/pages/albums.rs:428
|
#: crates/pdfs-gui/src/app/pages/albums.rs:428
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:1917
|
#: crates/pdfs-gui/src/app/pages/browser.rs:1917
|
||||||
@@ -274,30 +274,30 @@ msgstr ""
|
|||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Cancel"
|
msgstr "Cancel"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1452
|
#: crates/pdfs-gui/src/app/main.rs:1456
|
||||||
msgctxt "verb"
|
msgctxt "verb"
|
||||||
msgid "Stop"
|
msgid "Stop"
|
||||||
msgstr "Stop"
|
msgstr "Stop"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1469
|
#: crates/pdfs-gui/src/app/main.rs:1473
|
||||||
msgid "Couldn't stop Proton Drive"
|
msgid "Couldn't stop Proton Drive"
|
||||||
msgstr "Couldn't stop Proton Drive"
|
msgstr "Couldn't stop Proton Drive"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1470
|
#: crates/pdfs-gui/src/app/main.rs:1474
|
||||||
msgid "The Proton Drive service did not stop."
|
msgid "The Proton Drive service did not stop."
|
||||||
msgstr "The Proton Drive service did not stop."
|
msgstr "The Proton Drive service did not stop."
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1506
|
#: crates/pdfs-gui/src/app/main.rs:1510
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:269
|
#: crates/pdfs-gui/src/app/pages/status.rs:269
|
||||||
msgid "General"
|
msgid "General"
|
||||||
msgstr "General"
|
msgstr "General"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1508
|
#: crates/pdfs-gui/src/app/main.rs:1512
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:247
|
#: crates/pdfs-gui/src/app/pages/browser.rs:247
|
||||||
msgid "Search Drive"
|
msgid "Search Drive"
|
||||||
msgstr "Search Drive"
|
msgstr "Search Drive"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1509
|
#: crates/pdfs-gui/src/app/main.rs:1513
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:984
|
#: crates/pdfs-gui/src/app/pages/browser.rs:984
|
||||||
#: crates/pdfs-gui/src/app/pages/devices.rs:559
|
#: crates/pdfs-gui/src/app/pages/devices.rs:559
|
||||||
#: crates/pdfs-gui/src/app/pages/shared.rs:581
|
#: crates/pdfs-gui/src/app/pages/shared.rs:581
|
||||||
@@ -306,43 +306,43 @@ msgstr "Search Drive"
|
|||||||
msgid "Refresh"
|
msgid "Refresh"
|
||||||
msgstr "Refresh"
|
msgstr "Refresh"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1510
|
#: crates/pdfs-gui/src/app/main.rs:1514
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:407
|
#: crates/pdfs-gui/src/app/pages/status.rs:407
|
||||||
msgid "Preferences"
|
msgid "Preferences"
|
||||||
msgstr "Preferences"
|
msgstr "Preferences"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1511
|
#: crates/pdfs-gui/src/app/main.rs:1515
|
||||||
msgid "Keyboard shortcuts"
|
msgid "Keyboard shortcuts"
|
||||||
msgstr "Keyboard shortcuts"
|
msgstr "Keyboard shortcuts"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1515
|
#: crates/pdfs-gui/src/app/main.rs:1519
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Files"
|
msgstr "Files"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1517
|
#: crates/pdfs-gui/src/app/main.rs:1521
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:294
|
#: crates/pdfs-gui/src/app/pages/browser.rs:294
|
||||||
msgid "New folder"
|
msgid "New folder"
|
||||||
msgstr "New folder"
|
msgstr "New folder"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1518
|
#: crates/pdfs-gui/src/app/main.rs:1522
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:290
|
#: crates/pdfs-gui/src/app/pages/browser.rs:290
|
||||||
msgid "Upload files"
|
msgid "Upload files"
|
||||||
msgstr "Upload files"
|
msgstr "Upload files"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1519
|
#: crates/pdfs-gui/src/app/main.rs:1523
|
||||||
msgid "Back / forward"
|
msgid "Back / forward"
|
||||||
msgstr "Back / forward"
|
msgstr "Back / forward"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1520
|
#: crates/pdfs-gui/src/app/main.rs:1524
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:2014
|
#: crates/pdfs-gui/src/app/pages/browser.rs:2014
|
||||||
msgid "Parent folder"
|
msgid "Parent folder"
|
||||||
msgstr "Parent folder"
|
msgstr "Parent folder"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1521
|
#: crates/pdfs-gui/src/app/main.rs:1525
|
||||||
msgid "Grid / list"
|
msgid "Grid / list"
|
||||||
msgstr "Grid / list"
|
msgstr "Grid / list"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1522
|
#: crates/pdfs-gui/src/app/main.rs:1526
|
||||||
#: crates/pdfs-gui/src/app/pages/albums.rs:298
|
#: crates/pdfs-gui/src/app/pages/albums.rs:298
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:1896
|
#: crates/pdfs-gui/src/app/pages/browser.rs:1896
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:1918
|
#: crates/pdfs-gui/src/app/pages/browser.rs:1918
|
||||||
@@ -351,7 +351,7 @@ msgstr "Grid / list"
|
|||||||
msgid "Rename"
|
msgid "Rename"
|
||||||
msgstr "Rename"
|
msgstr "Rename"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1523 crates/pdfs-gui/src/app/main.rs:1543
|
#: crates/pdfs-gui/src/app/main.rs:1527 crates/pdfs-gui/src/app/main.rs:1547
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:357
|
#: crates/pdfs-gui/src/app/pages/browser.rs:357
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:924
|
#: crates/pdfs-gui/src/app/pages/browser.rs:924
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:959
|
#: crates/pdfs-gui/src/app/pages/browser.rs:959
|
||||||
@@ -361,76 +361,137 @@ msgstr "Rename"
|
|||||||
msgid "Move to Trash"
|
msgid "Move to Trash"
|
||||||
msgstr "Move to Trash"
|
msgstr "Move to Trash"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1524
|
#: crates/pdfs-gui/src/app/main.rs:1528
|
||||||
msgid "Clear the selection"
|
msgid "Clear the selection"
|
||||||
msgstr "Clear the selection"
|
msgstr "Clear the selection"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1530
|
#: crates/pdfs-gui/src/app/main.rs:1534
|
||||||
msgid "Larger thumbnails"
|
msgid "Larger thumbnails"
|
||||||
msgstr "Larger thumbnails"
|
msgstr "Larger thumbnails"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1531
|
#: crates/pdfs-gui/src/app/main.rs:1535
|
||||||
msgid "Smaller thumbnails"
|
msgid "Smaller thumbnails"
|
||||||
msgstr "Smaller thumbnails"
|
msgstr "Smaller thumbnails"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1532
|
#: crates/pdfs-gui/src/app/main.rs:1536
|
||||||
msgid "Reset thumbnail size"
|
msgid "Reset thumbnail size"
|
||||||
msgstr "Reset thumbnail size"
|
msgstr "Reset thumbnail size"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1533
|
#: crates/pdfs-gui/src/app/main.rs:1537
|
||||||
msgid "Select all"
|
msgid "Select all"
|
||||||
msgstr "Select all"
|
msgstr "Select all"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1537
|
#: crates/pdfs-gui/src/app/main.rs:1541
|
||||||
msgid "Photo Viewer"
|
msgid "Photo Viewer"
|
||||||
msgstr "Photo Viewer"
|
msgstr "Photo Viewer"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1539
|
#: crates/pdfs-gui/src/app/main.rs:1543
|
||||||
msgid "Previous / next photo"
|
msgid "Previous / next photo"
|
||||||
msgstr "Previous / next photo"
|
msgstr "Previous / next photo"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1540
|
#: crates/pdfs-gui/src/app/main.rs:1544
|
||||||
msgid "First / last photo"
|
msgid "First / last photo"
|
||||||
msgstr "First / last photo"
|
msgstr "First / last photo"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1541
|
#: crates/pdfs-gui/src/app/main.rs:1545
|
||||||
msgid "Show details"
|
msgid "Show details"
|
||||||
msgstr "Show details"
|
msgstr "Show details"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1542
|
#: crates/pdfs-gui/src/app/main.rs:1546
|
||||||
msgid "Fullscreen"
|
msgid "Fullscreen"
|
||||||
msgstr "Fullscreen"
|
msgstr "Fullscreen"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1544
|
#: crates/pdfs-gui/src/app/main.rs:1548
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:855
|
||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr "Close"
|
msgstr "Close"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1634
|
#: crates/pdfs-gui/src/app/main.rs:1638
|
||||||
msgid "Select a single item to rename it"
|
msgid "Select a single item to rename it"
|
||||||
msgstr "Select a single item to rename it"
|
msgstr "Select a single item to rename it"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1794
|
#: crates/pdfs-gui/src/app/main.rs:1798
|
||||||
#: crates/pdfs-gui/src/app/pages/albums.rs:85
|
#: crates/pdfs-gui/src/app/pages/albums.rs:85
|
||||||
#: crates/pdfs-gui/src/app/pages/places.rs:104
|
#: crates/pdfs-gui/src/app/pages/places.rs:104
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1492
|
#: crates/pdfs-gui/src/app/pages/status.rs:1571
|
||||||
msgid "Not connected"
|
msgid "Not connected"
|
||||||
msgstr "Not connected"
|
msgstr "Not connected"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1795
|
#: crates/pdfs-gui/src/app/main.rs:1799
|
||||||
msgid "The Proton Drive service isn't running."
|
msgid "The Proton Drive service isn't running."
|
||||||
msgstr "The Proton Drive service isn't running."
|
msgstr "The Proton Drive service isn't running."
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1803
|
#: crates/pdfs-gui/src/app/main.rs:1819 crates/pdfs-gui/src/app/main.rs:1834
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:1577
|
||||||
|
msgid "Can't open your Drive"
|
||||||
|
msgstr "Can't open your Drive"
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1822
|
||||||
|
#, rust-format
|
||||||
|
msgid ""
|
||||||
|
"“{path}” already has files in it, and My files would hide them. Proton Drive "
|
||||||
|
"tries again on its own once the folder is empty. You can also choose another "
|
||||||
|
"folder for My files in Preferences."
|
||||||
|
msgstr ""
|
||||||
|
"“{path}” already has files in it, and My files would hide them. Proton Drive "
|
||||||
|
"tries again on its own once the folder is empty. You can also choose another "
|
||||||
|
"folder for My files in Preferences."
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1837
|
||||||
|
#, rust-format
|
||||||
|
msgid ""
|
||||||
|
"The Proton Drive service keeps failing to start and tries again on its own. "
|
||||||
|
"The last error was: {error}"
|
||||||
|
msgstr ""
|
||||||
|
"The Proton Drive service keeps failing to start and tries again on its own. "
|
||||||
|
"The last error was: {error}"
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1845
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:355
|
#: crates/pdfs-gui/src/app/pages/status.rs:355
|
||||||
#: crates/pdfs-gui/src/hud/mod.rs:269 crates/pdfs-gui/src/hud/mod.rs:579
|
#: crates/pdfs-gui/src/hud/mod.rs:269 crates/pdfs-gui/src/hud/mod.rs:579
|
||||||
#: crates/pdfs-gui/src/main.rs:475 crates/pdfs-gui/src/main.rs:517
|
#: crates/pdfs-gui/src/main.rs:493 crates/pdfs-gui/src/main.rs:535
|
||||||
msgid "Connecting…"
|
msgid "Connecting…"
|
||||||
msgstr "Connecting…"
|
msgstr "Connecting…"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1804
|
#: crates/pdfs-gui/src/app/main.rs:1846
|
||||||
msgid "Waiting for the Proton Drive service to start."
|
msgid "Waiting for the Proton Drive service to start."
|
||||||
msgstr "Waiting for the Proton Drive service to start."
|
msgstr "Waiting for the Proton Drive service to start."
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1864
|
||||||
|
msgid "Your Proton Drive Folder Isn't Empty"
|
||||||
|
msgstr "Your Proton Drive Folder Isn't Empty"
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1868
|
||||||
|
#, rust-format
|
||||||
|
msgid ""
|
||||||
|
"“{path}” already has {n} item in it. My files would hide it, so Proton Drive "
|
||||||
|
"does not open there. Move the item to another folder, or choose another "
|
||||||
|
"folder for My files."
|
||||||
|
msgid_plural ""
|
||||||
|
"“{path}” already has {n} items in it. My files would hide them, so Proton "
|
||||||
|
"Drive does not open there. Move the items to another folder, or choose "
|
||||||
|
"another folder for My files."
|
||||||
|
msgstr[0] ""
|
||||||
|
"“{path}” already has {n} item in it. My files would hide it, so Proton Drive "
|
||||||
|
"does not open there. Move the item to another folder, or choose another "
|
||||||
|
"folder for My files."
|
||||||
|
msgstr[1] ""
|
||||||
|
"“{path}” already has {n} items in it. My files would hide them, so Proton "
|
||||||
|
"Drive does not open there. Move the items to another folder, or choose "
|
||||||
|
"another folder for My files."
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1874
|
||||||
|
msgid "Not Now"
|
||||||
|
msgstr "Not Now"
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1875 crates/pdfs-gui/src/main.rs:500
|
||||||
|
msgid "Open Folder"
|
||||||
|
msgstr "Open Folder"
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1876
|
||||||
|
msgid "Choose Another Folder…"
|
||||||
|
msgstr "Choose Another Folder…"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/activity.rs:87
|
#: crates/pdfs-gui/src/app/pages/activity.rs:87
|
||||||
msgctxt "activity filter"
|
msgctxt "activity filter"
|
||||||
msgid "All"
|
msgid "All"
|
||||||
@@ -637,7 +698,7 @@ msgstr[0] "Created {n} folder"
|
|||||||
msgstr[1] "Created {n} folders"
|
msgstr[1] "Created {n} folders"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/activity.rs:983
|
#: crates/pdfs-gui/src/app/pages/activity.rs:983
|
||||||
#: crates/pdfs-gui/src/i18n.rs:191
|
#: crates/pdfs-gui/src/i18n.rs:195
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} change"
|
msgid "{n} change"
|
||||||
msgid_plural "{n} changes"
|
msgid_plural "{n} changes"
|
||||||
@@ -1287,7 +1348,7 @@ msgstr "Couldn't load albums"
|
|||||||
#: crates/pdfs-gui/src/app/pages/shared.rs:1003
|
#: crates/pdfs-gui/src/app/pages/shared.rs:1003
|
||||||
#: crates/pdfs-gui/src/app/pages/shared.rs:1119
|
#: crates/pdfs-gui/src/app/pages/shared.rs:1119
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:802
|
#: crates/pdfs-gui/src/app/pages/status.rs:802
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1642
|
#: crates/pdfs-gui/src/app/pages/status.rs:1731
|
||||||
#: crates/pdfs-gui/src/app/pages/takeout.rs:424
|
#: crates/pdfs-gui/src/app/pages/takeout.rs:424
|
||||||
#: crates/pdfs-gui/src/app/pages/takeout.rs:618
|
#: crates/pdfs-gui/src/app/pages/takeout.rs:618
|
||||||
#: crates/pdfs-gui/src/app/widgets/share_dialog.rs:516
|
#: crates/pdfs-gui/src/app/widgets/share_dialog.rs:516
|
||||||
@@ -1557,7 +1618,7 @@ msgid "Make available offline"
|
|||||||
msgstr "Make available offline"
|
msgstr "Make available offline"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:347
|
#: crates/pdfs-gui/src/app/pages/browser.rs:347
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1618
|
#: crates/pdfs-gui/src/app/pages/status.rs:1707
|
||||||
msgid "Make online only"
|
msgid "Make online only"
|
||||||
msgstr "Make online only"
|
msgstr "Make online only"
|
||||||
|
|
||||||
@@ -2149,7 +2210,7 @@ msgid "No files or folders match that search."
|
|||||||
msgstr "No files or folders match that search."
|
msgstr "No files or folders match that search."
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:3096
|
#: crates/pdfs-gui/src/app/pages/browser.rs:3096
|
||||||
#: crates/pdfs-gui/src/i18n.rs:248
|
#: crates/pdfs-gui/src/i18n.rs:252
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} file"
|
msgid "{n} file"
|
||||||
msgid_plural "{n} files"
|
msgid_plural "{n} files"
|
||||||
@@ -2880,7 +2941,7 @@ msgid "Sync paused until you resume"
|
|||||||
msgstr "Sync paused until you resume"
|
msgstr "Sync paused until you resume"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/locations.rs:570
|
#: crates/pdfs-gui/src/app/pages/locations.rs:570
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1445 crates/pdfs-gui/src/main.rs:290
|
#: crates/pdfs-gui/src/app/pages/status.rs:1524 crates/pdfs-gui/src/main.rs:308
|
||||||
msgid "Sync paused"
|
msgid "Sync paused"
|
||||||
msgstr "Sync paused"
|
msgstr "Sync paused"
|
||||||
|
|
||||||
@@ -4877,65 +4938,88 @@ msgstr "Couldn't save the language"
|
|||||||
msgid "Restart Proton Drive to use the new language"
|
msgid "Restart Proton Drive to use the new language"
|
||||||
msgstr "Restart Proton Drive to use the new language"
|
msgstr "Restart Proton Drive to use the new language"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:813
|
#: crates/pdfs-gui/src/app/pages/status.rs:814
|
||||||
msgid "Choose Proton Drive Folder"
|
msgid "Choose Proton Drive Folder"
|
||||||
msgstr "Choose Proton Drive Folder"
|
msgstr "Choose Proton Drive Folder"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:825
|
#: crates/pdfs-gui/src/app/pages/status.rs:835
|
||||||
|
msgid "Couldn't read the folder"
|
||||||
|
msgstr "Couldn't read the folder"
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:845
|
||||||
|
msgid "Choose an Empty Folder"
|
||||||
|
msgstr "Choose an Empty Folder"
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:849
|
||||||
|
#, rust-format
|
||||||
|
msgid ""
|
||||||
|
"“{path}” already has {n} item in it. My files would hide it while Proton "
|
||||||
|
"Drive runs, and it would not be uploaded."
|
||||||
|
msgid_plural ""
|
||||||
|
"“{path}” already has {n} items in it. My files would hide them while Proton "
|
||||||
|
"Drive runs, and they would not be uploaded."
|
||||||
|
msgstr[0] ""
|
||||||
|
"“{path}” already has {n} item in it. My files would hide it while Proton "
|
||||||
|
"Drive runs, and it would not be uploaded."
|
||||||
|
msgstr[1] ""
|
||||||
|
"“{path}” already has {n} items in it. My files would hide them while Proton "
|
||||||
|
"Drive runs, and they would not be uploaded."
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:870
|
||||||
msgid "Couldn't save the folder"
|
msgid "Couldn't save the folder"
|
||||||
msgstr "Couldn't save the folder"
|
msgstr "Couldn't save the folder"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:837
|
#: crates/pdfs-gui/src/app/pages/status.rs:882
|
||||||
msgid "Restart Proton Drive?"
|
msgid "Restart Proton Drive?"
|
||||||
msgstr "Restart Proton Drive?"
|
msgstr "Restart Proton Drive?"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:840
|
#: crates/pdfs-gui/src/app/pages/status.rs:885
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "My files now appears in “{path}” after Proton Drive restarts."
|
msgid "My files now appears in “{path}” after Proton Drive restarts."
|
||||||
msgstr "My files now appears in “{path}” after Proton Drive restarts."
|
msgstr "My files now appears in “{path}” after Proton Drive restarts."
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:844
|
#: crates/pdfs-gui/src/app/pages/status.rs:889
|
||||||
msgid "Later"
|
msgid "Later"
|
||||||
msgstr "Later"
|
msgstr "Later"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:845
|
#: crates/pdfs-gui/src/app/pages/status.rs:890
|
||||||
msgid "Restart Now"
|
msgid "Restart Now"
|
||||||
msgstr "Restart Now"
|
msgstr "Restart Now"
|
||||||
|
|
||||||
#. Translators: {size} is an amount of storage, such as "1.2 GiB".
|
#. Translators: {size} is an amount of storage, such as "1.2 GiB".
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:972
|
#: crates/pdfs-gui/src/app/pages/status.rs:1016
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{size} used"
|
msgid "{size} used"
|
||||||
msgstr "{size} used"
|
msgstr "{size} used"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:983
|
#: crates/pdfs-gui/src/app/pages/status.rs:1027
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{used} of {total} used ({percent}%)"
|
msgid "{used} of {total} used ({percent}%)"
|
||||||
msgstr "{used} of {total} used ({percent}%)"
|
msgstr "{used} of {total} used ({percent}%)"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1023
|
#: crates/pdfs-gui/src/app/pages/status.rs:1067
|
||||||
msgid "Proton Drive connected"
|
msgid "Proton Drive connected"
|
||||||
msgstr "Proton Drive connected"
|
msgstr "Proton Drive connected"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1024
|
#: crates/pdfs-gui/src/app/pages/status.rs:1068
|
||||||
msgid "Your files are available."
|
msgid "Your files are available."
|
||||||
msgstr "Your files are available."
|
msgstr "Your files are available."
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1029
|
#: crates/pdfs-gui/src/app/pages/status.rs:1073
|
||||||
msgid "Proton Drive disconnected"
|
msgid "Proton Drive disconnected"
|
||||||
msgstr "Proton Drive disconnected"
|
msgstr "Proton Drive disconnected"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1030
|
#: crates/pdfs-gui/src/app/pages/status.rs:1074
|
||||||
msgid ""
|
msgid ""
|
||||||
"The Proton Drive service stopped. Files aren't available until it restarts."
|
"The Proton Drive service stopped. Files aren't available until it restarts."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"The Proton Drive service stopped. Files aren't available until it restarts."
|
"The Proton Drive service stopped. Files aren't available until it restarts."
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1099
|
#: crates/pdfs-gui/src/app/pages/status.rs:1143
|
||||||
msgid "Sync complete"
|
msgid "Sync complete"
|
||||||
msgstr "Sync complete"
|
msgstr "Sync complete"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1101
|
#: crates/pdfs-gui/src/app/pages/status.rs:1145
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} file finished transferring."
|
msgid "{n} file finished transferring."
|
||||||
msgid_plural "{n} files finished transferring."
|
msgid_plural "{n} files finished transferring."
|
||||||
@@ -4944,28 +5028,28 @@ msgstr[1] "{n} files finished transferring."
|
|||||||
|
|
||||||
#. Translators: a background job's progress. {title} is the job, such as
|
#. Translators: a background job's progress. {title} is the job, such as
|
||||||
#. "Scanning Photos"; {done} and {total} are counts.
|
#. "Scanning Photos"; {done} and {total} are counts.
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1179
|
#: crates/pdfs-gui/src/app/pages/status.rs:1223
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{title} — {done} of {total}"
|
msgid "{title} — {done} of {total}"
|
||||||
msgstr "{title} — {done} of {total}"
|
msgstr "{title} — {done} of {total}"
|
||||||
|
|
||||||
#. Translators: a background job's progress. {title} is the job, {detail}
|
#. Translators: a background job's progress. {title} is the job, {detail}
|
||||||
#. what it is working on; {done} and {total} are counts.
|
#. what it is working on; {done} and {total} are counts.
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1182
|
#: crates/pdfs-gui/src/app/pages/status.rs:1226
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{title} — {detail} ({done} of {total})"
|
msgid "{title} — {detail} ({done} of {total})"
|
||||||
msgstr "{title} — {detail} ({done} of {total})"
|
msgstr "{title} — {detail} ({done} of {total})"
|
||||||
|
|
||||||
#. Translators: a background job still running. {title} is the job.
|
#. Translators: a background job still running. {title} is the job.
|
||||||
#. Translators: {title} names a running job whose size is not known yet.
|
#. Translators: {title} names a running job whose size is not known yet.
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1184 crates/pdfs-gui/src/main.rs:113
|
#: crates/pdfs-gui/src/app/pages/status.rs:1228 crates/pdfs-gui/src/main.rs:113
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{title}…"
|
msgid "{title}…"
|
||||||
msgstr "{title}…"
|
msgstr "{title}…"
|
||||||
|
|
||||||
#. Translators: a background job still running. {title} is the job,
|
#. Translators: a background job still running. {title} is the job,
|
||||||
#. {detail} what it is working on.
|
#. {detail} what it is working on.
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1187
|
#: crates/pdfs-gui/src/app/pages/status.rs:1231
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{title} — {detail}…"
|
msgid "{title} — {detail}…"
|
||||||
msgstr "{title} — {detail}…"
|
msgstr "{title} — {detail}…"
|
||||||
@@ -4973,7 +5057,7 @@ msgstr "{title} — {detail}…"
|
|||||||
#. Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
|
#. Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
|
||||||
#. file name, {done} the size moved so far and {speed} a size such as
|
#. file name, {done} the size moved so far and {speed} a size such as
|
||||||
#. "1.2 MiB"; "/s" means per second.
|
#. "1.2 MiB"; "/s" means per second.
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1216
|
#: crates/pdfs-gui/src/app/pages/status.rs:1260
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{arrow} {name} — {done} ({speed}/s)"
|
msgid "{arrow} {name} — {done} ({speed}/s)"
|
||||||
msgstr "{arrow} {name} — {done} ({speed}/s)"
|
msgstr "{arrow} {name} — {done} ({speed}/s)"
|
||||||
@@ -4981,151 +5065,159 @@ msgstr "{arrow} {name} — {done} ({speed}/s)"
|
|||||||
#. Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
|
#. Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
|
||||||
#. file name, {done} and {total} sizes and {speed} a size such as
|
#. file name, {done} and {total} sizes and {speed} a size such as
|
||||||
#. "1.2 MiB"; "/s" means per second.
|
#. "1.2 MiB"; "/s" means per second.
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1224
|
#: crates/pdfs-gui/src/app/pages/status.rs:1268
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{arrow} {name} — {done} of {total} ({speed}/s)"
|
msgid "{arrow} {name} — {done} of {total} ({speed}/s)"
|
||||||
msgstr "{arrow} {name} — {done} of {total} ({speed}/s)"
|
msgstr "{arrow} {name} — {done} of {total} ({speed}/s)"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1325
|
#: crates/pdfs-gui/src/app/pages/status.rs:1369
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{used} cached — no limit set"
|
msgid "{used} cached — no limit set"
|
||||||
msgstr "{used} cached — no limit set"
|
msgstr "{used} cached — no limit set"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1331
|
#: crates/pdfs-gui/src/app/pages/status.rs:1375
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{used} of {limit} used"
|
msgid "{used} of {limit} used"
|
||||||
msgstr "{used} of {limit} used"
|
msgstr "{used} of {limit} used"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1410
|
#: crates/pdfs-gui/src/app/pages/status.rs:1489
|
||||||
msgid "Session expired"
|
msgid "Session expired"
|
||||||
msgstr "Session expired"
|
msgstr "Session expired"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1415
|
#: crates/pdfs-gui/src/app/pages/status.rs:1494
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{queued} · sign in again to sync"
|
msgid "{queued} · sign in again to sync"
|
||||||
msgstr "{queued} · sign in again to sync"
|
msgstr "{queued} · sign in again to sync"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1417
|
#: crates/pdfs-gui/src/app/pages/status.rs:1496
|
||||||
msgid "Sign in again to sync"
|
msgid "Sign in again to sync"
|
||||||
msgstr "Sign in again to sync"
|
msgstr "Sign in again to sync"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1423
|
#: crates/pdfs-gui/src/app/pages/status.rs:1502
|
||||||
msgid "Sign-in not saved"
|
msgid "Sign-in not saved"
|
||||||
msgstr "Sign-in not saved"
|
msgstr "Sign-in not saved"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1425
|
#: crates/pdfs-gui/src/app/pages/status.rs:1504
|
||||||
msgid "Unlock your keyring, or the next reboot signs you out"
|
msgid "Unlock your keyring, or the next reboot signs you out"
|
||||||
msgstr "Unlock your keyring, or the next reboot signs you out"
|
msgstr "Unlock your keyring, or the next reboot signs you out"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1431
|
#: crates/pdfs-gui/src/app/pages/status.rs:1510
|
||||||
msgid "Restart to finish updating"
|
msgid "Restart to finish updating"
|
||||||
msgstr "Restart to finish updating"
|
msgstr "Restart to finish updating"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1433
|
#: crates/pdfs-gui/src/app/pages/status.rs:1512
|
||||||
msgid "An older version of Proton Drive is still running"
|
msgid "An older version of Proton Drive is still running"
|
||||||
msgstr "An older version of Proton Drive is still running"
|
msgstr "An older version of Proton Drive is still running"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1437
|
#: crates/pdfs-gui/src/app/pages/status.rs:1516
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Proton Drive {version} is still running"
|
msgid "Proton Drive {version} is still running"
|
||||||
msgstr "Proton Drive {version} is still running"
|
msgstr "Proton Drive {version} is still running"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1450
|
#: crates/pdfs-gui/src/app/pages/status.rs:1529
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{queued} · resumes {time}"
|
msgid "{queued} · resumes {time}"
|
||||||
msgstr "{queued} · resumes {time}"
|
msgstr "{queued} · resumes {time}"
|
||||||
|
|
||||||
#. Translators: {time} is when syncing resumes, such as "at 14:30"
|
#. Translators: {time} is when syncing resumes, such as "at 14:30"
|
||||||
#. or "Tue 09:00".
|
#. or "Tue 09:00".
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1455
|
#: crates/pdfs-gui/src/app/pages/status.rs:1534
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Resumes {time}"
|
msgid "Resumes {time}"
|
||||||
msgstr "Resumes {time}"
|
msgstr "Resumes {time}"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1457
|
#: crates/pdfs-gui/src/app/pages/status.rs:1536
|
||||||
msgid "Until you resume"
|
msgid "Until you resume"
|
||||||
msgstr "Until you resume"
|
msgstr "Until you resume"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1464 crates/pdfs-gui/src/main.rs:292
|
#: crates/pdfs-gui/src/app/pages/status.rs:1543 crates/pdfs-gui/src/main.rs:310
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} change needs attention"
|
msgid "{n} change needs attention"
|
||||||
msgid_plural "{n} changes need attention"
|
msgid_plural "{n} changes need attention"
|
||||||
msgstr[0] "{n} change needs attention"
|
msgstr[0] "{n} change needs attention"
|
||||||
msgstr[1] "{n} changes need attention"
|
msgstr[1] "{n} changes need attention"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1474
|
#: crates/pdfs-gui/src/app/pages/status.rs:1553
|
||||||
#: crates/pdfs-gui/src/app/widgets/details.rs:110
|
#: crates/pdfs-gui/src/app/widgets/details.rs:110
|
||||||
msgctxt "state"
|
msgctxt "state"
|
||||||
msgid "Offline"
|
msgid "Offline"
|
||||||
msgstr "Offline"
|
msgstr "Offline"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1475
|
#: crates/pdfs-gui/src/app/pages/status.rs:1554
|
||||||
msgid "Cached files only"
|
msgid "Cached files only"
|
||||||
msgstr "Cached files only"
|
msgstr "Cached files only"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1480
|
#: crates/pdfs-gui/src/app/pages/status.rs:1559
|
||||||
msgctxt "state"
|
msgctxt "state"
|
||||||
msgid "Syncing"
|
msgid "Syncing"
|
||||||
msgstr "Syncing"
|
msgstr "Syncing"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1486
|
#: crates/pdfs-gui/src/app/pages/status.rs:1565
|
||||||
msgid "Up to date"
|
msgid "Up to date"
|
||||||
msgstr "Up to date"
|
msgstr "Up to date"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1493
|
#: crates/pdfs-gui/src/app/pages/status.rs:1572
|
||||||
#: crates/pdfs-gui/src/hud/mod.rs:592 crates/pdfs-gui/src/hud/mod.rs:606
|
#: crates/pdfs-gui/src/hud/mod.rs:592 crates/pdfs-gui/src/hud/mod.rs:606
|
||||||
msgid "Proton Drive isn't running"
|
msgid "Proton Drive isn't running"
|
||||||
msgstr "Proton Drive isn't running"
|
msgstr "Proton Drive isn't running"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1498
|
#: crates/pdfs-gui/src/app/pages/status.rs:1579
|
||||||
|
msgid "Your Proton Drive folder isn't empty"
|
||||||
|
msgstr "Your Proton Drive folder isn't empty"
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:1581
|
||||||
|
msgid "Proton Drive keeps trying to start"
|
||||||
|
msgstr "Proton Drive keeps trying to start"
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:1587
|
||||||
msgid "Not responding"
|
msgid "Not responding"
|
||||||
msgstr "Not responding"
|
msgstr "Not responding"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1499
|
#: crates/pdfs-gui/src/app/pages/status.rs:1588
|
||||||
msgid "Proton Drive is taking too long to answer"
|
msgid "Proton Drive is taking too long to answer"
|
||||||
msgstr "Proton Drive is taking too long to answer"
|
msgstr "Proton Drive is taking too long to answer"
|
||||||
|
|
||||||
#. Translators: stands in for a time that could not be read, as in
|
#. Translators: stands in for a time that could not be read, as in
|
||||||
#. "Resumes later".
|
#. "Resumes later".
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1539
|
#: crates/pdfs-gui/src/app/pages/status.rs:1628
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1554
|
#: crates/pdfs-gui/src/app/pages/status.rs:1643
|
||||||
msgid "later"
|
msgid "later"
|
||||||
msgstr "later"
|
msgstr "later"
|
||||||
|
|
||||||
#. Translators: strftime format for a time later today, such as "at 14:30".
|
#. Translators: strftime format for a time later today, such as "at 14:30".
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1543
|
#: crates/pdfs-gui/src/app/pages/status.rs:1632
|
||||||
msgid "at %H:%M"
|
msgid "at %H:%M"
|
||||||
msgstr "at %H:%M"
|
msgstr "at %H:%M"
|
||||||
|
|
||||||
#. Translators: strftime format for a time within the next week, such as
|
#. Translators: strftime format for a time within the next week, such as
|
||||||
#. "Tue 09:00".
|
#. "Tue 09:00".
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1547
|
#: crates/pdfs-gui/src/app/pages/status.rs:1636
|
||||||
msgid "%a %H:%M"
|
msgid "%a %H:%M"
|
||||||
msgstr "%a %H:%M"
|
msgstr "%a %H:%M"
|
||||||
|
|
||||||
#. Translators: strftime format for a date further ahead, such as "23 Sep".
|
#. Translators: strftime format for a date further ahead, such as "23 Sep".
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1550
|
#: crates/pdfs-gui/src/app/pages/status.rs:1639
|
||||||
msgid "%e %b"
|
msgid "%e %b"
|
||||||
msgstr "%e %b"
|
msgstr "%e %b"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1580
|
#: crates/pdfs-gui/src/app/pages/status.rs:1669
|
||||||
msgid "No files kept offline"
|
msgid "No files kept offline"
|
||||||
msgstr "No files kept offline"
|
msgstr "No files kept offline"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1582
|
#: crates/pdfs-gui/src/app/pages/status.rs:1671
|
||||||
msgid "Right-click a file or folder and choose “Available offline”."
|
msgid "Right-click a file or folder and choose “Available offline”."
|
||||||
msgstr "Right-click a file or folder and choose “Available offline”."
|
msgstr "Right-click a file or folder and choose “Available offline”."
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1634
|
#: crates/pdfs-gui/src/app/pages/status.rs:1723
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1641
|
#: crates/pdfs-gui/src/app/pages/status.rs:1730
|
||||||
msgid "Couldn't make it online only"
|
msgid "Couldn't make it online only"
|
||||||
msgstr "Couldn't make it online only"
|
msgstr "Couldn't make it online only"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1658
|
#: crates/pdfs-gui/src/app/pages/status.rs:1747
|
||||||
msgid "Show fewer"
|
msgid "Show fewer"
|
||||||
msgstr "Show fewer"
|
msgstr "Show fewer"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1661
|
#: crates/pdfs-gui/src/app/pages/status.rs:1750
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Show all {n} file"
|
msgid "Show all {n} file"
|
||||||
msgid_plural "Show all {n} files"
|
msgid_plural "Show all {n} files"
|
||||||
@@ -6179,37 +6271,37 @@ msgctxt "file state"
|
|||||||
msgid "Online only"
|
msgid "Online only"
|
||||||
msgstr "Online only"
|
msgstr "Online only"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/i18n.rs:84
|
#: crates/pdfs-gui/src/i18n.rs:85
|
||||||
msgid "Kosovo"
|
msgid "Kosovo"
|
||||||
msgstr "Kosovo"
|
msgstr "Kosovo"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/i18n.rs:178
|
#: crates/pdfs-gui/src/i18n.rs:182
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} upload queued"
|
msgid "{n} upload queued"
|
||||||
msgid_plural "{n} uploads queued"
|
msgid_plural "{n} uploads queued"
|
||||||
msgstr[0] "{n} upload queued"
|
msgstr[0] "{n} upload queued"
|
||||||
msgstr[1] "{n} uploads queued"
|
msgstr[1] "{n} uploads queued"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/i18n.rs:184
|
#: crates/pdfs-gui/src/i18n.rs:188
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} change queued"
|
msgid "{n} change queued"
|
||||||
msgid_plural "{n} changes queued"
|
msgid_plural "{n} changes queued"
|
||||||
msgstr[0] "{n} change queued"
|
msgstr[0] "{n} change queued"
|
||||||
msgstr[1] "{n} changes queued"
|
msgstr[1] "{n} changes queued"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/i18n.rs:190
|
#: crates/pdfs-gui/src/i18n.rs:194
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} upload"
|
msgid "{n} upload"
|
||||||
msgid_plural "{n} uploads"
|
msgid_plural "{n} uploads"
|
||||||
msgstr[0] "{n} upload"
|
msgstr[0] "{n} upload"
|
||||||
msgstr[1] "{n} uploads"
|
msgstr[1] "{n} uploads"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/i18n.rs:194
|
#: crates/pdfs-gui/src/i18n.rs:198
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{uploads}, {changes} queued"
|
msgid "{uploads}, {changes} queued"
|
||||||
msgstr "{uploads}, {changes} queued"
|
msgstr "{uploads}, {changes} queued"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/i18n.rs:251
|
#: crates/pdfs-gui/src/i18n.rs:255
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} file in {dir}"
|
msgid "{n} file in {dir}"
|
||||||
msgid_plural "{n} files in {dir}"
|
msgid_plural "{n} files in {dir}"
|
||||||
@@ -6257,52 +6349,57 @@ msgstr "Proton Drive isn't responding"
|
|||||||
msgid "Signed in as {username} — locked"
|
msgid "Signed in as {username} — locked"
|
||||||
msgstr "Signed in as {username} — locked"
|
msgstr "Signed in as {username} — locked"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:200
|
#: crates/pdfs-gui/src/main.rs:209
|
||||||
|
#, rust-format
|
||||||
|
msgid "Signed in as {username} — can't open your Drive"
|
||||||
|
msgstr "Signed in as {username} — can't open your Drive"
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/main.rs:218
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Signed in as {username} — not connected"
|
msgid "Signed in as {username} — not connected"
|
||||||
msgstr "Signed in as {username} — not connected"
|
msgstr "Signed in as {username} — not connected"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:205
|
#: crates/pdfs-gui/src/main.rs:223
|
||||||
msgid "Not signed in"
|
msgid "Not signed in"
|
||||||
msgstr "Not signed in"
|
msgstr "Not signed in"
|
||||||
|
|
||||||
#. Translators: {error} is an error message, in English.
|
#. Translators: {error} is an error message, in English.
|
||||||
#: crates/pdfs-gui/src/main.rs:208
|
#: crates/pdfs-gui/src/main.rs:226
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Error: {error}"
|
msgid "Error: {error}"
|
||||||
msgstr "Error: {error}"
|
msgstr "Error: {error}"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:230
|
#: crates/pdfs-gui/src/main.rs:248
|
||||||
msgid "Proton Drive sent an unexpected reply"
|
msgid "Proton Drive sent an unexpected reply"
|
||||||
msgstr "Proton Drive sent an unexpected reply"
|
msgstr "Proton Drive sent an unexpected reply"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:284
|
#: crates/pdfs-gui/src/main.rs:302
|
||||||
msgid "Session expired — sign in again"
|
msgid "Session expired — sign in again"
|
||||||
msgstr "Session expired — sign in again"
|
msgstr "Session expired — sign in again"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:286
|
#: crates/pdfs-gui/src/main.rs:304
|
||||||
msgid "Sign-in not saved — unlock your keyring"
|
msgid "Sign-in not saved — unlock your keyring"
|
||||||
msgstr "Sign-in not saved — unlock your keyring"
|
msgstr "Sign-in not saved — unlock your keyring"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:289
|
#: crates/pdfs-gui/src/main.rs:307
|
||||||
msgid "Update not finished — restart Proton Drive"
|
msgid "Update not finished — restart Proton Drive"
|
||||||
msgstr "Update not finished — restart Proton Drive"
|
msgstr "Update not finished — restart Proton Drive"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:299
|
#: crates/pdfs-gui/src/main.rs:317
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Up to date in {mountpoint} ({n} pinned)"
|
msgid "Up to date in {mountpoint} ({n} pinned)"
|
||||||
msgid_plural "Up to date in {mountpoint} ({n} pinned)"
|
msgid_plural "Up to date in {mountpoint} ({n} pinned)"
|
||||||
msgstr[0] "Up to date in {mountpoint} ({n} pinned)"
|
msgstr[0] "Up to date in {mountpoint} ({n} pinned)"
|
||||||
msgstr[1] "Up to date in {mountpoint} ({n} pinned)"
|
msgstr[1] "Up to date in {mountpoint} ({n} pinned)"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:306
|
#: crates/pdfs-gui/src/main.rs:324
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Syncing — {queued} ({n} pinned)"
|
msgid "Syncing — {queued} ({n} pinned)"
|
||||||
msgid_plural "Syncing — {queued} ({n} pinned)"
|
msgid_plural "Syncing — {queued} ({n} pinned)"
|
||||||
msgstr[0] "Syncing — {queued} ({n} pinned)"
|
msgstr[0] "Syncing — {queued} ({n} pinned)"
|
||||||
msgstr[1] "Syncing — {queued} ({n} pinned)"
|
msgstr[1] "Syncing — {queued} ({n} pinned)"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:313
|
#: crates/pdfs-gui/src/main.rs:331
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Offline — cached files only ({n} pinned)"
|
msgid "Offline — cached files only ({n} pinned)"
|
||||||
msgid_plural "Offline — cached files only ({n} pinned)"
|
msgid_plural "Offline — cached files only ({n} pinned)"
|
||||||
@@ -6310,60 +6407,56 @@ msgstr[0] "Offline — cached files only ({n} pinned)"
|
|||||||
msgstr[1] "Offline — cached files only ({n} pinned)"
|
msgstr[1] "Offline — cached files only ({n} pinned)"
|
||||||
|
|
||||||
#. Translators: {queued} is a queue summary such as "3 uploads queued".
|
#. Translators: {queued} is a queue summary such as "3 uploads queued".
|
||||||
#: crates/pdfs-gui/src/main.rs:319
|
#: crates/pdfs-gui/src/main.rs:337
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Offline — {queued}"
|
msgid "Offline — {queued}"
|
||||||
msgstr "Offline — {queued}"
|
msgstr "Offline — {queued}"
|
||||||
|
|
||||||
#. Translators: a menu item that opens the list of failing changes.
|
#. Translators: a menu item that opens the list of failing changes.
|
||||||
#: crates/pdfs-gui/src/main.rs:358
|
#: crates/pdfs-gui/src/main.rs:376
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} issue — View"
|
msgid "{n} issue — View"
|
||||||
msgid_plural "{n} issues — View"
|
msgid_plural "{n} issues — View"
|
||||||
msgstr[0] "{n} issue — View"
|
msgstr[0] "{n} issue — View"
|
||||||
msgstr[1] "{n} issues — View"
|
msgstr[1] "{n} issues — View"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:465
|
#: crates/pdfs-gui/src/main.rs:483
|
||||||
msgid "Sign In…"
|
msgid "Sign In…"
|
||||||
msgstr "Sign In…"
|
msgstr "Sign In…"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:466
|
#: crates/pdfs-gui/src/main.rs:484
|
||||||
msgid "Sign In Again…"
|
msgid "Sign In Again…"
|
||||||
msgstr "Sign In Again…"
|
msgstr "Sign In Again…"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:469
|
#: crates/pdfs-gui/src/main.rs:487
|
||||||
msgid "Open Proton Drive"
|
msgid "Open Proton Drive"
|
||||||
msgstr "Open Proton Drive"
|
msgstr "Open Proton Drive"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:473
|
#: crates/pdfs-gui/src/main.rs:491
|
||||||
msgid "Restart Proton Drive"
|
msgid "Restart Proton Drive"
|
||||||
msgstr "Restart Proton Drive"
|
msgstr "Restart Proton Drive"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:482
|
#: crates/pdfs-gui/src/main.rs:512
|
||||||
msgid "Open Folder"
|
|
||||||
msgstr "Open Folder"
|
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:494
|
|
||||||
msgid "Resume Sync"
|
msgid "Resume Sync"
|
||||||
msgstr "Resume Sync"
|
msgstr "Resume Sync"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:496
|
#: crates/pdfs-gui/src/main.rs:514
|
||||||
msgid "Pause Sync"
|
msgid "Pause Sync"
|
||||||
msgstr "Pause Sync"
|
msgstr "Pause Sync"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:515
|
#: crates/pdfs-gui/src/main.rs:533
|
||||||
msgid "Connect"
|
msgid "Connect"
|
||||||
msgstr "Connect"
|
msgstr "Connect"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:522
|
#: crates/pdfs-gui/src/main.rs:540
|
||||||
msgid "Hide Tray Icon"
|
msgid "Hide Tray Icon"
|
||||||
msgstr "Hide Tray Icon"
|
msgstr "Hide Tray Icon"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:526
|
#: crates/pdfs-gui/src/main.rs:544
|
||||||
msgid "Stop Proton Drive…"
|
msgid "Stop Proton Drive…"
|
||||||
msgstr "Stop Proton Drive…"
|
msgstr "Stop Proton Drive…"
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:530
|
#: crates/pdfs-gui/src/main.rs:548
|
||||||
msgid "Quit"
|
msgid "Quit"
|
||||||
msgstr "Quit"
|
msgstr "Quit"
|
||||||
|
|
||||||
@@ -6670,9 +6763,6 @@ msgstr "cannot reach the Proton Drive daemon: {error}"
|
|||||||
#~ msgid "Stop sharing?"
|
#~ msgid "Stop sharing?"
|
||||||
#~ msgstr "Stop sharing?"
|
#~ msgstr "Stop sharing?"
|
||||||
|
|
||||||
#~ msgid "Choose mountpoint folder"
|
|
||||||
#~ msgstr "Choose mountpoint folder"
|
|
||||||
|
|
||||||
#~ msgid "Couldn't save mountpoint"
|
#~ msgid "Couldn't save mountpoint"
|
||||||
#~ msgstr "Couldn't save mountpoint"
|
#~ msgstr "Couldn't save mountpoint"
|
||||||
|
|
||||||
|
|||||||
+230
-152
@@ -6,9 +6,9 @@
|
|||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: proton-drive-linux 3.6.0\n"
|
"Project-Id-Version: proton-drive-linux 3.7.0\n"
|
||||||
"Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n"
|
"Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n"
|
||||||
"POT-Creation-Date: 2026-10-31 10:26+0100\n"
|
"POT-Creation-Date: 2026-11-01 17:51+0100\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -60,7 +60,7 @@ msgstr ""
|
|||||||
msgid "%A, %B %-d, %Y, %H:%M"
|
msgid "%A, %B %-d, %Y, %H:%M"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:856
|
#: crates/pdfs-gui/src/app/main.rs:860
|
||||||
#: crates/pdfs-gui/src/app/pages/activity_text.rs:543
|
#: crates/pdfs-gui/src/app/pages/activity_text.rs:543
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:454
|
#: crates/pdfs-gui/src/app/pages/browser.rs:454
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:833
|
#: crates/pdfs-gui/src/app/pages/browser.rs:833
|
||||||
@@ -73,74 +73,74 @@ msgstr ""
|
|||||||
msgid "My files"
|
msgid "My files"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:859 crates/pdfs-gui/src/app/main.rs:1528
|
#: crates/pdfs-gui/src/app/main.rs:863 crates/pdfs-gui/src/app/main.rs:1532
|
||||||
#: crates/pdfs-gui/src/app/pages/albums.rs:638
|
#: crates/pdfs-gui/src/app/pages/albums.rs:638
|
||||||
#: crates/pdfs-gui/src/app/pages/photos.rs:845
|
#: crates/pdfs-gui/src/app/pages/photos.rs:845
|
||||||
#: crates/pdfs-gui/src/app/pages/places.rs:441
|
#: crates/pdfs-gui/src/app/pages/places.rs:441
|
||||||
msgid "Photos"
|
msgid "Photos"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:864
|
#: crates/pdfs-gui/src/app/main.rs:868
|
||||||
#: crates/pdfs-gui/src/app/pages/shared.rs:168
|
#: crates/pdfs-gui/src/app/pages/shared.rs:168
|
||||||
#: crates/pdfs-gui/src/app/pages/shared.rs:484
|
#: crates/pdfs-gui/src/app/pages/shared.rs:484
|
||||||
msgid "Shared with me"
|
msgid "Shared with me"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:869
|
#: crates/pdfs-gui/src/app/main.rs:873
|
||||||
#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120
|
#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120
|
||||||
msgid "Shared by me"
|
msgid "Shared by me"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:872
|
#: crates/pdfs-gui/src/app/main.rs:876
|
||||||
#: crates/pdfs-gui/src/app/pages/devices.rs:140
|
#: crates/pdfs-gui/src/app/pages/devices.rs:140
|
||||||
#: crates/pdfs-gui/src/app/pages/devices.rs:493
|
#: crates/pdfs-gui/src/app/pages/devices.rs:493
|
||||||
msgid "Computers"
|
msgid "Computers"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:873
|
#: crates/pdfs-gui/src/app/main.rs:877
|
||||||
#: crates/pdfs-gui/src/app/pages/trash.rs:101
|
#: crates/pdfs-gui/src/app/pages/trash.rs:101
|
||||||
msgid "Trash"
|
msgid "Trash"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: sidebar entry for the page listing this computer's synced folders.
|
#. Translators: sidebar entry for the page listing this computer's synced folders.
|
||||||
#: crates/pdfs-gui/src/app/main.rs:875
|
#: crates/pdfs-gui/src/app/main.rs:879
|
||||||
msgid "Sync"
|
msgid "Sync"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: a failed action's toast. {what} says what failed, such as
|
#. Translators: a failed action's toast. {what} says what failed, such as
|
||||||
#. "Couldn't rename"; {detail} is the reason, usually untranslated text
|
#. "Couldn't rename"; {detail} is the reason, usually untranslated text
|
||||||
#. from the mount service.
|
#. from the mount service.
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1225
|
#: crates/pdfs-gui/src/app/main.rs:1229
|
||||||
#: crates/pdfs-gui/src/app/pages/takeout.rs:714
|
#: crates/pdfs-gui/src/app/pages/takeout.rs:714
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{what}: {detail}"
|
msgid "{what}: {detail}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1248
|
#: crates/pdfs-gui/src/app/main.rs:1252
|
||||||
msgid "You're offline"
|
msgid "You're offline"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1249
|
#: crates/pdfs-gui/src/app/main.rs:1253
|
||||||
msgid "That's not there any more"
|
msgid "That's not there any more"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1250
|
#: crates/pdfs-gui/src/app/main.rs:1254
|
||||||
msgid "You don't have access to that"
|
msgid "You don't have access to that"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1251
|
#: crates/pdfs-gui/src/app/main.rs:1255
|
||||||
msgid "Something changed this first"
|
msgid "Something changed this first"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1252
|
#: crates/pdfs-gui/src/app/main.rs:1256
|
||||||
msgid "Your Proton Drive is full"
|
msgid "Your Proton Drive is full"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1339
|
#: crates/pdfs-gui/src/app/main.rs:1343
|
||||||
msgid "Proton Drive for Linux"
|
msgid "Proton Drive for Linux"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1347
|
#: crates/pdfs-gui/src/app/main.rs:1351
|
||||||
msgid ""
|
msgid ""
|
||||||
"Files-on-demand Proton Drive for the Linux desktop.\n"
|
"Files-on-demand Proton Drive for the Linux desktop.\n"
|
||||||
"\n"
|
"\n"
|
||||||
@@ -148,23 +148,23 @@ msgid ""
|
|||||||
"AG."
|
"AG."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1352
|
#: crates/pdfs-gui/src/app/main.rs:1356
|
||||||
msgid "Source Code"
|
msgid "Source Code"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1356
|
#: crates/pdfs-gui/src/app/main.rs:1360
|
||||||
msgid "Proton Terms of Service"
|
msgid "Proton Terms of Service"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1360
|
#: crates/pdfs-gui/src/app/main.rs:1364
|
||||||
msgid "Proton Privacy Policy"
|
msgid "Proton Privacy Policy"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1365
|
#: crates/pdfs-gui/src/app/main.rs:1369
|
||||||
msgid "Disclaimer"
|
msgid "Disclaimer"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1369
|
#: crates/pdfs-gui/src/app/main.rs:1373
|
||||||
msgid ""
|
msgid ""
|
||||||
"This is an unofficial, community-built client. It is not affiliated with, "
|
"This is an unofficial, community-built client. It is not affiliated with, "
|
||||||
"endorsed by, or supported by Proton AG.\n"
|
"endorsed by, or supported by Proton AG.\n"
|
||||||
@@ -177,21 +177,21 @@ msgid ""
|
|||||||
"of any data you cannot afford to lose."
|
"of any data you cannot afford to lose."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1377
|
#: crates/pdfs-gui/src/app/main.rs:1381
|
||||||
msgid ""
|
msgid ""
|
||||||
"Places are named with data from <a href=\"https://www.geonames.org/"
|
"Places are named with data from <a href=\"https://www.geonames.org/"
|
||||||
"\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/"
|
"\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/"
|
||||||
"licenses/by/4.0/\">CC BY 4.0</a>."
|
"licenses/by/4.0/\">CC BY 4.0</a>."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1385
|
#: crates/pdfs-gui/src/app/main.rs:1389
|
||||||
msgid ""
|
msgid ""
|
||||||
"The places map is drawn from <a href=\"https://www.naturalearthdata.com/"
|
"The places map is drawn from <a href=\"https://www.naturalearthdata.com/"
|
||||||
"\">Natural Earth</a> land outlines and country borders, which are in the "
|
"\">Natural Earth</a> land outlines and country borders, which are in the "
|
||||||
"public domain."
|
"public domain."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1393
|
#: crates/pdfs-gui/src/app/main.rs:1397
|
||||||
msgid ""
|
msgid ""
|
||||||
"The street map, when turned on, comes from <a href=\"https://openfreemap.org/"
|
"The street map, when turned on, comes from <a href=\"https://openfreemap.org/"
|
||||||
"\">OpenFreeMap</a> in the <a href=\"https://www.openmaptiles.org/"
|
"\">OpenFreeMap</a> in the <a href=\"https://www.openmaptiles.org/"
|
||||||
@@ -202,21 +202,21 @@ msgstr ""
|
|||||||
|
|
||||||
#. Translators: replace with your name(s) and email address(es), one
|
#. Translators: replace with your name(s) and email address(es), one
|
||||||
#. per line, to be credited in the About dialog.
|
#. per line, to be credited in the About dialog.
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1404
|
#: crates/pdfs-gui/src/app/main.rs:1408
|
||||||
msgid "translator-credits"
|
msgid "translator-credits"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1448
|
#: crates/pdfs-gui/src/app/main.rs:1452
|
||||||
msgid "Stop Proton Drive?"
|
msgid "Stop Proton Drive?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1449
|
#: crates/pdfs-gui/src/app/main.rs:1453
|
||||||
msgid ""
|
msgid ""
|
||||||
"My files goes offline and nothing syncs until you connect again. Proton "
|
"My files goes offline and nothing syncs until you connect again. Proton "
|
||||||
"Drive starts again at your next login."
|
"Drive starts again at your next login."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1451 crates/pdfs-gui/src/app/main.rs:1726
|
#: crates/pdfs-gui/src/app/main.rs:1455 crates/pdfs-gui/src/app/main.rs:1730
|
||||||
#: crates/pdfs-gui/src/app/pages/albums.rs:243
|
#: crates/pdfs-gui/src/app/pages/albums.rs:243
|
||||||
#: crates/pdfs-gui/src/app/pages/albums.rs:428
|
#: crates/pdfs-gui/src/app/pages/albums.rs:428
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:1917
|
#: crates/pdfs-gui/src/app/pages/browser.rs:1917
|
||||||
@@ -251,30 +251,30 @@ msgstr ""
|
|||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1452
|
#: crates/pdfs-gui/src/app/main.rs:1456
|
||||||
msgctxt "verb"
|
msgctxt "verb"
|
||||||
msgid "Stop"
|
msgid "Stop"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1469
|
#: crates/pdfs-gui/src/app/main.rs:1473
|
||||||
msgid "Couldn't stop Proton Drive"
|
msgid "Couldn't stop Proton Drive"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1470
|
#: crates/pdfs-gui/src/app/main.rs:1474
|
||||||
msgid "The Proton Drive service did not stop."
|
msgid "The Proton Drive service did not stop."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1506
|
#: crates/pdfs-gui/src/app/main.rs:1510
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:269
|
#: crates/pdfs-gui/src/app/pages/status.rs:269
|
||||||
msgid "General"
|
msgid "General"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1508
|
#: crates/pdfs-gui/src/app/main.rs:1512
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:247
|
#: crates/pdfs-gui/src/app/pages/browser.rs:247
|
||||||
msgid "Search Drive"
|
msgid "Search Drive"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1509
|
#: crates/pdfs-gui/src/app/main.rs:1513
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:984
|
#: crates/pdfs-gui/src/app/pages/browser.rs:984
|
||||||
#: crates/pdfs-gui/src/app/pages/devices.rs:559
|
#: crates/pdfs-gui/src/app/pages/devices.rs:559
|
||||||
#: crates/pdfs-gui/src/app/pages/shared.rs:581
|
#: crates/pdfs-gui/src/app/pages/shared.rs:581
|
||||||
@@ -283,43 +283,43 @@ msgstr ""
|
|||||||
msgid "Refresh"
|
msgid "Refresh"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1510
|
#: crates/pdfs-gui/src/app/main.rs:1514
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:407
|
#: crates/pdfs-gui/src/app/pages/status.rs:407
|
||||||
msgid "Preferences"
|
msgid "Preferences"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1511
|
#: crates/pdfs-gui/src/app/main.rs:1515
|
||||||
msgid "Keyboard shortcuts"
|
msgid "Keyboard shortcuts"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1515
|
#: crates/pdfs-gui/src/app/main.rs:1519
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1517
|
#: crates/pdfs-gui/src/app/main.rs:1521
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:294
|
#: crates/pdfs-gui/src/app/pages/browser.rs:294
|
||||||
msgid "New folder"
|
msgid "New folder"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1518
|
#: crates/pdfs-gui/src/app/main.rs:1522
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:290
|
#: crates/pdfs-gui/src/app/pages/browser.rs:290
|
||||||
msgid "Upload files"
|
msgid "Upload files"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1519
|
#: crates/pdfs-gui/src/app/main.rs:1523
|
||||||
msgid "Back / forward"
|
msgid "Back / forward"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1520
|
#: crates/pdfs-gui/src/app/main.rs:1524
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:2014
|
#: crates/pdfs-gui/src/app/pages/browser.rs:2014
|
||||||
msgid "Parent folder"
|
msgid "Parent folder"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1521
|
#: crates/pdfs-gui/src/app/main.rs:1525
|
||||||
msgid "Grid / list"
|
msgid "Grid / list"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1522
|
#: crates/pdfs-gui/src/app/main.rs:1526
|
||||||
#: crates/pdfs-gui/src/app/pages/albums.rs:298
|
#: crates/pdfs-gui/src/app/pages/albums.rs:298
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:1896
|
#: crates/pdfs-gui/src/app/pages/browser.rs:1896
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:1918
|
#: crates/pdfs-gui/src/app/pages/browser.rs:1918
|
||||||
@@ -328,7 +328,7 @@ msgstr ""
|
|||||||
msgid "Rename"
|
msgid "Rename"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1523 crates/pdfs-gui/src/app/main.rs:1543
|
#: crates/pdfs-gui/src/app/main.rs:1527 crates/pdfs-gui/src/app/main.rs:1547
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:357
|
#: crates/pdfs-gui/src/app/pages/browser.rs:357
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:924
|
#: crates/pdfs-gui/src/app/pages/browser.rs:924
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:959
|
#: crates/pdfs-gui/src/app/pages/browser.rs:959
|
||||||
@@ -338,76 +338,126 @@ msgstr ""
|
|||||||
msgid "Move to Trash"
|
msgid "Move to Trash"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1524
|
#: crates/pdfs-gui/src/app/main.rs:1528
|
||||||
msgid "Clear the selection"
|
msgid "Clear the selection"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1530
|
#: crates/pdfs-gui/src/app/main.rs:1534
|
||||||
msgid "Larger thumbnails"
|
msgid "Larger thumbnails"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1531
|
#: crates/pdfs-gui/src/app/main.rs:1535
|
||||||
msgid "Smaller thumbnails"
|
msgid "Smaller thumbnails"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1532
|
#: crates/pdfs-gui/src/app/main.rs:1536
|
||||||
msgid "Reset thumbnail size"
|
msgid "Reset thumbnail size"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1533
|
#: crates/pdfs-gui/src/app/main.rs:1537
|
||||||
msgid "Select all"
|
msgid "Select all"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1537
|
#: crates/pdfs-gui/src/app/main.rs:1541
|
||||||
msgid "Photo Viewer"
|
msgid "Photo Viewer"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1539
|
#: crates/pdfs-gui/src/app/main.rs:1543
|
||||||
msgid "Previous / next photo"
|
msgid "Previous / next photo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1540
|
#: crates/pdfs-gui/src/app/main.rs:1544
|
||||||
msgid "First / last photo"
|
msgid "First / last photo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1541
|
#: crates/pdfs-gui/src/app/main.rs:1545
|
||||||
msgid "Show details"
|
msgid "Show details"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1542
|
#: crates/pdfs-gui/src/app/main.rs:1546
|
||||||
msgid "Fullscreen"
|
msgid "Fullscreen"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1544
|
#: crates/pdfs-gui/src/app/main.rs:1548
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:855
|
||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1634
|
#: crates/pdfs-gui/src/app/main.rs:1638
|
||||||
msgid "Select a single item to rename it"
|
msgid "Select a single item to rename it"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1794
|
#: crates/pdfs-gui/src/app/main.rs:1798
|
||||||
#: crates/pdfs-gui/src/app/pages/albums.rs:85
|
#: crates/pdfs-gui/src/app/pages/albums.rs:85
|
||||||
#: crates/pdfs-gui/src/app/pages/places.rs:104
|
#: crates/pdfs-gui/src/app/pages/places.rs:104
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1492
|
#: crates/pdfs-gui/src/app/pages/status.rs:1571
|
||||||
msgid "Not connected"
|
msgid "Not connected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1795
|
#: crates/pdfs-gui/src/app/main.rs:1799
|
||||||
msgid "The Proton Drive service isn't running."
|
msgid "The Proton Drive service isn't running."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1803
|
#: crates/pdfs-gui/src/app/main.rs:1819 crates/pdfs-gui/src/app/main.rs:1834
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:1577
|
||||||
|
msgid "Can't open your Drive"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1822
|
||||||
|
#, rust-format
|
||||||
|
msgid ""
|
||||||
|
"“{path}” already has files in it, and My files would hide them. Proton Drive "
|
||||||
|
"tries again on its own once the folder is empty. You can also choose another "
|
||||||
|
"folder for My files in Preferences."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1837
|
||||||
|
#, rust-format
|
||||||
|
msgid ""
|
||||||
|
"The Proton Drive service keeps failing to start and tries again on its own. "
|
||||||
|
"The last error was: {error}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1845
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:355
|
#: crates/pdfs-gui/src/app/pages/status.rs:355
|
||||||
#: crates/pdfs-gui/src/hud/mod.rs:269 crates/pdfs-gui/src/hud/mod.rs:579
|
#: crates/pdfs-gui/src/hud/mod.rs:269 crates/pdfs-gui/src/hud/mod.rs:579
|
||||||
#: crates/pdfs-gui/src/main.rs:475 crates/pdfs-gui/src/main.rs:517
|
#: crates/pdfs-gui/src/main.rs:493 crates/pdfs-gui/src/main.rs:535
|
||||||
msgid "Connecting…"
|
msgid "Connecting…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/main.rs:1804
|
#: crates/pdfs-gui/src/app/main.rs:1846
|
||||||
msgid "Waiting for the Proton Drive service to start."
|
msgid "Waiting for the Proton Drive service to start."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1864
|
||||||
|
msgid "Your Proton Drive Folder Isn't Empty"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1868
|
||||||
|
#, rust-format
|
||||||
|
msgid ""
|
||||||
|
"“{path}” already has {n} item in it. My files would hide it, so Proton Drive "
|
||||||
|
"does not open there. Move the item to another folder, or choose another "
|
||||||
|
"folder for My files."
|
||||||
|
msgid_plural ""
|
||||||
|
"“{path}” already has {n} items in it. My files would hide them, so Proton "
|
||||||
|
"Drive does not open there. Move the items to another folder, or choose "
|
||||||
|
"another folder for My files."
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1874
|
||||||
|
msgid "Not Now"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1875 crates/pdfs-gui/src/main.rs:500
|
||||||
|
msgid "Open Folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/main.rs:1876
|
||||||
|
msgid "Choose Another Folder…"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/activity.rs:87
|
#: crates/pdfs-gui/src/app/pages/activity.rs:87
|
||||||
msgctxt "activity filter"
|
msgctxt "activity filter"
|
||||||
msgid "All"
|
msgid "All"
|
||||||
@@ -612,7 +662,7 @@ msgstr[0] ""
|
|||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/activity.rs:983
|
#: crates/pdfs-gui/src/app/pages/activity.rs:983
|
||||||
#: crates/pdfs-gui/src/i18n.rs:191
|
#: crates/pdfs-gui/src/i18n.rs:195
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} change"
|
msgid "{n} change"
|
||||||
msgid_plural "{n} changes"
|
msgid_plural "{n} changes"
|
||||||
@@ -1252,7 +1302,7 @@ msgstr ""
|
|||||||
#: crates/pdfs-gui/src/app/pages/shared.rs:1003
|
#: crates/pdfs-gui/src/app/pages/shared.rs:1003
|
||||||
#: crates/pdfs-gui/src/app/pages/shared.rs:1119
|
#: crates/pdfs-gui/src/app/pages/shared.rs:1119
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:802
|
#: crates/pdfs-gui/src/app/pages/status.rs:802
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1642
|
#: crates/pdfs-gui/src/app/pages/status.rs:1731
|
||||||
#: crates/pdfs-gui/src/app/pages/takeout.rs:424
|
#: crates/pdfs-gui/src/app/pages/takeout.rs:424
|
||||||
#: crates/pdfs-gui/src/app/pages/takeout.rs:618
|
#: crates/pdfs-gui/src/app/pages/takeout.rs:618
|
||||||
#: crates/pdfs-gui/src/app/widgets/share_dialog.rs:516
|
#: crates/pdfs-gui/src/app/widgets/share_dialog.rs:516
|
||||||
@@ -1522,7 +1572,7 @@ msgid "Make available offline"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:347
|
#: crates/pdfs-gui/src/app/pages/browser.rs:347
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1618
|
#: crates/pdfs-gui/src/app/pages/status.rs:1707
|
||||||
msgid "Make online only"
|
msgid "Make online only"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -2114,7 +2164,7 @@ msgid "No files or folders match that search."
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/browser.rs:3096
|
#: crates/pdfs-gui/src/app/pages/browser.rs:3096
|
||||||
#: crates/pdfs-gui/src/i18n.rs:248
|
#: crates/pdfs-gui/src/i18n.rs:252
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} file"
|
msgid "{n} file"
|
||||||
msgid_plural "{n} files"
|
msgid_plural "{n} files"
|
||||||
@@ -2799,7 +2849,7 @@ msgid "Sync paused until you resume"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/locations.rs:570
|
#: crates/pdfs-gui/src/app/pages/locations.rs:570
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1445 crates/pdfs-gui/src/main.rs:290
|
#: crates/pdfs-gui/src/app/pages/status.rs:1524 crates/pdfs-gui/src/main.rs:308
|
||||||
msgid "Sync paused"
|
msgid "Sync paused"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -4726,64 +4776,83 @@ msgstr ""
|
|||||||
msgid "Restart Proton Drive to use the new language"
|
msgid "Restart Proton Drive to use the new language"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:813
|
#: crates/pdfs-gui/src/app/pages/status.rs:814
|
||||||
msgid "Choose Proton Drive Folder"
|
msgid "Choose Proton Drive Folder"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:825
|
#: crates/pdfs-gui/src/app/pages/status.rs:835
|
||||||
|
msgid "Couldn't read the folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:845
|
||||||
|
msgid "Choose an Empty Folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:849
|
||||||
|
#, rust-format
|
||||||
|
msgid ""
|
||||||
|
"“{path}” already has {n} item in it. My files would hide it while Proton "
|
||||||
|
"Drive runs, and it would not be uploaded."
|
||||||
|
msgid_plural ""
|
||||||
|
"“{path}” already has {n} items in it. My files would hide them while Proton "
|
||||||
|
"Drive runs, and they would not be uploaded."
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:870
|
||||||
msgid "Couldn't save the folder"
|
msgid "Couldn't save the folder"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:837
|
#: crates/pdfs-gui/src/app/pages/status.rs:882
|
||||||
msgid "Restart Proton Drive?"
|
msgid "Restart Proton Drive?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:840
|
#: crates/pdfs-gui/src/app/pages/status.rs:885
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "My files now appears in “{path}” after Proton Drive restarts."
|
msgid "My files now appears in “{path}” after Proton Drive restarts."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:844
|
#: crates/pdfs-gui/src/app/pages/status.rs:889
|
||||||
msgid "Later"
|
msgid "Later"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:845
|
#: crates/pdfs-gui/src/app/pages/status.rs:890
|
||||||
msgid "Restart Now"
|
msgid "Restart Now"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: {size} is an amount of storage, such as "1.2 GiB".
|
#. Translators: {size} is an amount of storage, such as "1.2 GiB".
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:972
|
#: crates/pdfs-gui/src/app/pages/status.rs:1016
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{size} used"
|
msgid "{size} used"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:983
|
#: crates/pdfs-gui/src/app/pages/status.rs:1027
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{used} of {total} used ({percent}%)"
|
msgid "{used} of {total} used ({percent}%)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1023
|
#: crates/pdfs-gui/src/app/pages/status.rs:1067
|
||||||
msgid "Proton Drive connected"
|
msgid "Proton Drive connected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1024
|
#: crates/pdfs-gui/src/app/pages/status.rs:1068
|
||||||
msgid "Your files are available."
|
msgid "Your files are available."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1029
|
#: crates/pdfs-gui/src/app/pages/status.rs:1073
|
||||||
msgid "Proton Drive disconnected"
|
msgid "Proton Drive disconnected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1030
|
#: crates/pdfs-gui/src/app/pages/status.rs:1074
|
||||||
msgid ""
|
msgid ""
|
||||||
"The Proton Drive service stopped. Files aren't available until it restarts."
|
"The Proton Drive service stopped. Files aren't available until it restarts."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1099
|
#: crates/pdfs-gui/src/app/pages/status.rs:1143
|
||||||
msgid "Sync complete"
|
msgid "Sync complete"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1101
|
#: crates/pdfs-gui/src/app/pages/status.rs:1145
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} file finished transferring."
|
msgid "{n} file finished transferring."
|
||||||
msgid_plural "{n} files finished transferring."
|
msgid_plural "{n} files finished transferring."
|
||||||
@@ -4792,28 +4861,28 @@ msgstr[1] ""
|
|||||||
|
|
||||||
#. Translators: a background job's progress. {title} is the job, such as
|
#. Translators: a background job's progress. {title} is the job, such as
|
||||||
#. "Scanning Photos"; {done} and {total} are counts.
|
#. "Scanning Photos"; {done} and {total} are counts.
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1179
|
#: crates/pdfs-gui/src/app/pages/status.rs:1223
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{title} — {done} of {total}"
|
msgid "{title} — {done} of {total}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: a background job's progress. {title} is the job, {detail}
|
#. Translators: a background job's progress. {title} is the job, {detail}
|
||||||
#. what it is working on; {done} and {total} are counts.
|
#. what it is working on; {done} and {total} are counts.
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1182
|
#: crates/pdfs-gui/src/app/pages/status.rs:1226
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{title} — {detail} ({done} of {total})"
|
msgid "{title} — {detail} ({done} of {total})"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: a background job still running. {title} is the job.
|
#. Translators: a background job still running. {title} is the job.
|
||||||
#. Translators: {title} names a running job whose size is not known yet.
|
#. Translators: {title} names a running job whose size is not known yet.
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1184 crates/pdfs-gui/src/main.rs:113
|
#: crates/pdfs-gui/src/app/pages/status.rs:1228 crates/pdfs-gui/src/main.rs:113
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{title}…"
|
msgid "{title}…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: a background job still running. {title} is the job,
|
#. Translators: a background job still running. {title} is the job,
|
||||||
#. {detail} what it is working on.
|
#. {detail} what it is working on.
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1187
|
#: crates/pdfs-gui/src/app/pages/status.rs:1231
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{title} — {detail}…"
|
msgid "{title} — {detail}…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -4821,7 +4890,7 @@ msgstr ""
|
|||||||
#. Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
|
#. Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
|
||||||
#. file name, {done} the size moved so far and {speed} a size such as
|
#. file name, {done} the size moved so far and {speed} a size such as
|
||||||
#. "1.2 MiB"; "/s" means per second.
|
#. "1.2 MiB"; "/s" means per second.
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1216
|
#: crates/pdfs-gui/src/app/pages/status.rs:1260
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{arrow} {name} — {done} ({speed}/s)"
|
msgid "{arrow} {name} — {done} ({speed}/s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -4829,151 +4898,159 @@ msgstr ""
|
|||||||
#. Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
|
#. Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
|
||||||
#. file name, {done} and {total} sizes and {speed} a size such as
|
#. file name, {done} and {total} sizes and {speed} a size such as
|
||||||
#. "1.2 MiB"; "/s" means per second.
|
#. "1.2 MiB"; "/s" means per second.
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1224
|
#: crates/pdfs-gui/src/app/pages/status.rs:1268
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{arrow} {name} — {done} of {total} ({speed}/s)"
|
msgid "{arrow} {name} — {done} of {total} ({speed}/s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1325
|
#: crates/pdfs-gui/src/app/pages/status.rs:1369
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{used} cached — no limit set"
|
msgid "{used} cached — no limit set"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1331
|
#: crates/pdfs-gui/src/app/pages/status.rs:1375
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{used} of {limit} used"
|
msgid "{used} of {limit} used"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1410
|
#: crates/pdfs-gui/src/app/pages/status.rs:1489
|
||||||
msgid "Session expired"
|
msgid "Session expired"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1415
|
#: crates/pdfs-gui/src/app/pages/status.rs:1494
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{queued} · sign in again to sync"
|
msgid "{queued} · sign in again to sync"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1417
|
#: crates/pdfs-gui/src/app/pages/status.rs:1496
|
||||||
msgid "Sign in again to sync"
|
msgid "Sign in again to sync"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1423
|
#: crates/pdfs-gui/src/app/pages/status.rs:1502
|
||||||
msgid "Sign-in not saved"
|
msgid "Sign-in not saved"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1425
|
#: crates/pdfs-gui/src/app/pages/status.rs:1504
|
||||||
msgid "Unlock your keyring, or the next reboot signs you out"
|
msgid "Unlock your keyring, or the next reboot signs you out"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1431
|
#: crates/pdfs-gui/src/app/pages/status.rs:1510
|
||||||
msgid "Restart to finish updating"
|
msgid "Restart to finish updating"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1433
|
#: crates/pdfs-gui/src/app/pages/status.rs:1512
|
||||||
msgid "An older version of Proton Drive is still running"
|
msgid "An older version of Proton Drive is still running"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1437
|
#: crates/pdfs-gui/src/app/pages/status.rs:1516
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Proton Drive {version} is still running"
|
msgid "Proton Drive {version} is still running"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1450
|
#: crates/pdfs-gui/src/app/pages/status.rs:1529
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{queued} · resumes {time}"
|
msgid "{queued} · resumes {time}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: {time} is when syncing resumes, such as "at 14:30"
|
#. Translators: {time} is when syncing resumes, such as "at 14:30"
|
||||||
#. or "Tue 09:00".
|
#. or "Tue 09:00".
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1455
|
#: crates/pdfs-gui/src/app/pages/status.rs:1534
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Resumes {time}"
|
msgid "Resumes {time}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1457
|
#: crates/pdfs-gui/src/app/pages/status.rs:1536
|
||||||
msgid "Until you resume"
|
msgid "Until you resume"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1464 crates/pdfs-gui/src/main.rs:292
|
#: crates/pdfs-gui/src/app/pages/status.rs:1543 crates/pdfs-gui/src/main.rs:310
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} change needs attention"
|
msgid "{n} change needs attention"
|
||||||
msgid_plural "{n} changes need attention"
|
msgid_plural "{n} changes need attention"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1474
|
#: crates/pdfs-gui/src/app/pages/status.rs:1553
|
||||||
#: crates/pdfs-gui/src/app/widgets/details.rs:110
|
#: crates/pdfs-gui/src/app/widgets/details.rs:110
|
||||||
msgctxt "state"
|
msgctxt "state"
|
||||||
msgid "Offline"
|
msgid "Offline"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1475
|
#: crates/pdfs-gui/src/app/pages/status.rs:1554
|
||||||
msgid "Cached files only"
|
msgid "Cached files only"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1480
|
#: crates/pdfs-gui/src/app/pages/status.rs:1559
|
||||||
msgctxt "state"
|
msgctxt "state"
|
||||||
msgid "Syncing"
|
msgid "Syncing"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1486
|
#: crates/pdfs-gui/src/app/pages/status.rs:1565
|
||||||
msgid "Up to date"
|
msgid "Up to date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1493
|
#: crates/pdfs-gui/src/app/pages/status.rs:1572
|
||||||
#: crates/pdfs-gui/src/hud/mod.rs:592 crates/pdfs-gui/src/hud/mod.rs:606
|
#: crates/pdfs-gui/src/hud/mod.rs:592 crates/pdfs-gui/src/hud/mod.rs:606
|
||||||
msgid "Proton Drive isn't running"
|
msgid "Proton Drive isn't running"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1498
|
#: crates/pdfs-gui/src/app/pages/status.rs:1579
|
||||||
|
msgid "Your Proton Drive folder isn't empty"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:1581
|
||||||
|
msgid "Proton Drive keeps trying to start"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/app/pages/status.rs:1587
|
||||||
msgid "Not responding"
|
msgid "Not responding"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1499
|
#: crates/pdfs-gui/src/app/pages/status.rs:1588
|
||||||
msgid "Proton Drive is taking too long to answer"
|
msgid "Proton Drive is taking too long to answer"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: stands in for a time that could not be read, as in
|
#. Translators: stands in for a time that could not be read, as in
|
||||||
#. "Resumes later".
|
#. "Resumes later".
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1539
|
#: crates/pdfs-gui/src/app/pages/status.rs:1628
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1554
|
#: crates/pdfs-gui/src/app/pages/status.rs:1643
|
||||||
msgid "later"
|
msgid "later"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: strftime format for a time later today, such as "at 14:30".
|
#. Translators: strftime format for a time later today, such as "at 14:30".
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1543
|
#: crates/pdfs-gui/src/app/pages/status.rs:1632
|
||||||
msgid "at %H:%M"
|
msgid "at %H:%M"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: strftime format for a time within the next week, such as
|
#. Translators: strftime format for a time within the next week, such as
|
||||||
#. "Tue 09:00".
|
#. "Tue 09:00".
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1547
|
#: crates/pdfs-gui/src/app/pages/status.rs:1636
|
||||||
msgid "%a %H:%M"
|
msgid "%a %H:%M"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: strftime format for a date further ahead, such as "23 Sep".
|
#. Translators: strftime format for a date further ahead, such as "23 Sep".
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1550
|
#: crates/pdfs-gui/src/app/pages/status.rs:1639
|
||||||
msgid "%e %b"
|
msgid "%e %b"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1580
|
#: crates/pdfs-gui/src/app/pages/status.rs:1669
|
||||||
msgid "No files kept offline"
|
msgid "No files kept offline"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1582
|
#: crates/pdfs-gui/src/app/pages/status.rs:1671
|
||||||
msgid "Right-click a file or folder and choose “Available offline”."
|
msgid "Right-click a file or folder and choose “Available offline”."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1634
|
#: crates/pdfs-gui/src/app/pages/status.rs:1723
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1641
|
#: crates/pdfs-gui/src/app/pages/status.rs:1730
|
||||||
msgid "Couldn't make it online only"
|
msgid "Couldn't make it online only"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1658
|
#: crates/pdfs-gui/src/app/pages/status.rs:1747
|
||||||
msgid "Show fewer"
|
msgid "Show fewer"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/app/pages/status.rs:1661
|
#: crates/pdfs-gui/src/app/pages/status.rs:1750
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Show all {n} file"
|
msgid "Show all {n} file"
|
||||||
msgid_plural "Show all {n} files"
|
msgid_plural "Show all {n} files"
|
||||||
@@ -6004,37 +6081,37 @@ msgctxt "file state"
|
|||||||
msgid "Online only"
|
msgid "Online only"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/i18n.rs:84
|
#: crates/pdfs-gui/src/i18n.rs:85
|
||||||
msgid "Kosovo"
|
msgid "Kosovo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/i18n.rs:178
|
#: crates/pdfs-gui/src/i18n.rs:182
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} upload queued"
|
msgid "{n} upload queued"
|
||||||
msgid_plural "{n} uploads queued"
|
msgid_plural "{n} uploads queued"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/i18n.rs:184
|
#: crates/pdfs-gui/src/i18n.rs:188
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} change queued"
|
msgid "{n} change queued"
|
||||||
msgid_plural "{n} changes queued"
|
msgid_plural "{n} changes queued"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/i18n.rs:190
|
#: crates/pdfs-gui/src/i18n.rs:194
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} upload"
|
msgid "{n} upload"
|
||||||
msgid_plural "{n} uploads"
|
msgid_plural "{n} uploads"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/i18n.rs:194
|
#: crates/pdfs-gui/src/i18n.rs:198
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{uploads}, {changes} queued"
|
msgid "{uploads}, {changes} queued"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/i18n.rs:251
|
#: crates/pdfs-gui/src/i18n.rs:255
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} file in {dir}"
|
msgid "{n} file in {dir}"
|
||||||
msgid_plural "{n} files in {dir}"
|
msgid_plural "{n} files in {dir}"
|
||||||
@@ -6082,52 +6159,57 @@ msgstr ""
|
|||||||
msgid "Signed in as {username} — locked"
|
msgid "Signed in as {username} — locked"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:200
|
#: crates/pdfs-gui/src/main.rs:209
|
||||||
|
#, rust-format
|
||||||
|
msgid "Signed in as {username} — can't open your Drive"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crates/pdfs-gui/src/main.rs:218
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Signed in as {username} — not connected"
|
msgid "Signed in as {username} — not connected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:205
|
#: crates/pdfs-gui/src/main.rs:223
|
||||||
msgid "Not signed in"
|
msgid "Not signed in"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: {error} is an error message, in English.
|
#. Translators: {error} is an error message, in English.
|
||||||
#: crates/pdfs-gui/src/main.rs:208
|
#: crates/pdfs-gui/src/main.rs:226
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Error: {error}"
|
msgid "Error: {error}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:230
|
#: crates/pdfs-gui/src/main.rs:248
|
||||||
msgid "Proton Drive sent an unexpected reply"
|
msgid "Proton Drive sent an unexpected reply"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:284
|
#: crates/pdfs-gui/src/main.rs:302
|
||||||
msgid "Session expired — sign in again"
|
msgid "Session expired — sign in again"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:286
|
#: crates/pdfs-gui/src/main.rs:304
|
||||||
msgid "Sign-in not saved — unlock your keyring"
|
msgid "Sign-in not saved — unlock your keyring"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:289
|
#: crates/pdfs-gui/src/main.rs:307
|
||||||
msgid "Update not finished — restart Proton Drive"
|
msgid "Update not finished — restart Proton Drive"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:299
|
#: crates/pdfs-gui/src/main.rs:317
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Up to date in {mountpoint} ({n} pinned)"
|
msgid "Up to date in {mountpoint} ({n} pinned)"
|
||||||
msgid_plural "Up to date in {mountpoint} ({n} pinned)"
|
msgid_plural "Up to date in {mountpoint} ({n} pinned)"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:306
|
#: crates/pdfs-gui/src/main.rs:324
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Syncing — {queued} ({n} pinned)"
|
msgid "Syncing — {queued} ({n} pinned)"
|
||||||
msgid_plural "Syncing — {queued} ({n} pinned)"
|
msgid_plural "Syncing — {queued} ({n} pinned)"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:313
|
#: crates/pdfs-gui/src/main.rs:331
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Offline — cached files only ({n} pinned)"
|
msgid "Offline — cached files only ({n} pinned)"
|
||||||
msgid_plural "Offline — cached files only ({n} pinned)"
|
msgid_plural "Offline — cached files only ({n} pinned)"
|
||||||
@@ -6135,60 +6217,56 @@ msgstr[0] ""
|
|||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
#. Translators: {queued} is a queue summary such as "3 uploads queued".
|
#. Translators: {queued} is a queue summary such as "3 uploads queued".
|
||||||
#: crates/pdfs-gui/src/main.rs:319
|
#: crates/pdfs-gui/src/main.rs:337
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "Offline — {queued}"
|
msgid "Offline — {queued}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translators: a menu item that opens the list of failing changes.
|
#. Translators: a menu item that opens the list of failing changes.
|
||||||
#: crates/pdfs-gui/src/main.rs:358
|
#: crates/pdfs-gui/src/main.rs:376
|
||||||
#, rust-format
|
#, rust-format
|
||||||
msgid "{n} issue — View"
|
msgid "{n} issue — View"
|
||||||
msgid_plural "{n} issues — View"
|
msgid_plural "{n} issues — View"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:465
|
#: crates/pdfs-gui/src/main.rs:483
|
||||||
msgid "Sign In…"
|
msgid "Sign In…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:466
|
#: crates/pdfs-gui/src/main.rs:484
|
||||||
msgid "Sign In Again…"
|
msgid "Sign In Again…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:469
|
#: crates/pdfs-gui/src/main.rs:487
|
||||||
msgid "Open Proton Drive"
|
msgid "Open Proton Drive"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:473
|
#: crates/pdfs-gui/src/main.rs:491
|
||||||
msgid "Restart Proton Drive"
|
msgid "Restart Proton Drive"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:482
|
#: crates/pdfs-gui/src/main.rs:512
|
||||||
msgid "Open Folder"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:494
|
|
||||||
msgid "Resume Sync"
|
msgid "Resume Sync"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:496
|
#: crates/pdfs-gui/src/main.rs:514
|
||||||
msgid "Pause Sync"
|
msgid "Pause Sync"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:515
|
#: crates/pdfs-gui/src/main.rs:533
|
||||||
msgid "Connect"
|
msgid "Connect"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:522
|
#: crates/pdfs-gui/src/main.rs:540
|
||||||
msgid "Hide Tray Icon"
|
msgid "Hide Tray Icon"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:526
|
#: crates/pdfs-gui/src/main.rs:544
|
||||||
msgid "Stop Proton Drive…"
|
msgid "Stop Proton Drive…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crates/pdfs-gui/src/main.rs:530
|
#: crates/pdfs-gui/src/main.rs:548
|
||||||
msgid "Quit"
|
msgid "Quit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
+244
-154
File diff suppressed because it is too large.
Load diff
@@ -3561,8 +3561,36 @@ JOURNAL_WARNINGS = (
|
|||||||
"queued write conflicts; keeping a conflict copy",
|
"queued write conflicts; keeping a conflict copy",
|
||||||
"a fuse worker has held the same job for a long time",
|
"a fuse worker has held the same job for a long time",
|
||||||
"re-interned an authority missing from the local tree",
|
"re-interned an authority missing from the local tree",
|
||||||
|
# A Rust panic goes to stderr without a tracing level, so the ERROR match
|
||||||
|
# does not see it (`docs/BUGS.md` B276).
|
||||||
|
"panicked at",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A stop aborts each mount's FUSE connection so the unmount cannot hang. A
|
||||||
|
# request still running then replies into a connection the kernel has
|
||||||
|
# closed, and the kernel refuses that reply with ENOENT. While a stop is in
|
||||||
|
# progress that error is expected (`docs/BUGS.md` B276); at any other time it
|
||||||
|
# still counts.
|
||||||
|
STOP_STARTED = "aborted FUSE connection to unblock unmount"
|
||||||
|
STOP_ENDED = ("daemon stopping", "control socket listening")
|
||||||
|
LATE_REPLY = "Failed to send FUSE reply: No such file or directory"
|
||||||
|
|
||||||
|
|
||||||
|
def journal_problems(lines: list[str]) -> list[str]:
|
||||||
|
"""The lines of `lines` that count as problems."""
|
||||||
|
problems = []
|
||||||
|
stopping = False
|
||||||
|
for line in lines:
|
||||||
|
if STOP_STARTED in line:
|
||||||
|
stopping = True
|
||||||
|
elif any(marker in line for marker in STOP_ENDED):
|
||||||
|
stopping = False
|
||||||
|
if stopping and LATE_REPLY in line:
|
||||||
|
continue
|
||||||
|
if ERROR_LEVEL.search(line) or any(warning in line for warning in JOURNAL_WARNINGS):
|
||||||
|
problems.append(line)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
def read_journal(unit: str, since: float) -> list[str] | None:
|
def read_journal(unit: str, since: float) -> list[str] | None:
|
||||||
"""The unit's journal lines since `since`, or None without journalctl.
|
"""The unit's journal lines since `since`, or None without journalctl.
|
||||||
@@ -3621,11 +3649,7 @@ class JournalWatch:
|
|||||||
# Filtering on syslog priority (`-p err`) finds nothing: the daemon writes
|
# Filtering on syslog priority (`-p err`) finds nothing: the daemon writes
|
||||||
# tracing levels as text on stdout, which journald files at the unit's
|
# tracing levels as text on stdout, which journald files at the unit's
|
||||||
# default priority. Match the level token instead.
|
# default priority. Match the level token instead.
|
||||||
return [
|
return journal_problems(lines)
|
||||||
line
|
|
||||||
for line in lines
|
|
||||||
if ERROR_LEVEL.search(line) or any(warning in line for warning in JOURNAL_WARNINGS)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user