diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 7ae5fe6..8109bf2 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -2432,6 +2432,7 @@ fn cmd_daemon(mountpoint: Option) -> Result<()> { 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"); @@ -2444,6 +2445,23 @@ fn cmd_daemon(mountpoint: Option) -> Result<()> { } } +/// 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 { + error.chain().find_map(|cause| { + cause + .downcast_ref::() + .or_else(|| { + cause + .downcast_ref::()? + .get_ref()? + .downcast_ref::() + }) + .cloned() + }) +} + /// How long to wait before mount attempt `attempts + 1`, after `attempts` /// failures in a row. /// @@ -3738,26 +3756,6 @@ fn executable_on_path(name: &str) -> bool { }) } -fn mounted_fs_type(path: &Path) -> Option { - 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. fn writable_probe(dir: &Path) -> std::io::Result<()> { let name = format!(".pdfs-diagnose-probe-{}", std::process::id()); @@ -3920,7 +3918,7 @@ fn cmd_diagnose() -> Result<()> { 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 .as_deref() .is_some_and(|kind| kind.starts_with("fuse")); @@ -4329,6 +4327,23 @@ mod login_wait_tests { 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; #[test] diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 0f79ec4..f24d291 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod keycheck; pub mod localindex; pub mod menu; pub mod mountfail; +pub mod mountpoint; pub mod mounts; pub mod opener; pub mod places; diff --git a/crates/pdfs-core/src/mountfail.rs b/crates/pdfs-core/src/mountfail.rs index 130a155..e99f245 100644 --- a/crates/pdfs-core/src/mountfail.rs +++ b/crates/pdfs-core/src/mountfail.rs @@ -24,6 +24,11 @@ pub struct MountFailure { 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, } /// Where the failure is kept for the state directory `state_dir`. @@ -65,6 +70,7 @@ mod tests { 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)); @@ -72,4 +78,12 @@ mod tests { 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); + } } diff --git a/crates/pdfs-core/src/mountpoint.rs b/crates/pdfs-core/src/mountpoint.rs new file mode 100644 index 0000000..82311a8 --- /dev/null +++ b/crates/pdfs-core/src/mountpoint.rs @@ -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, + /// 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> { + 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::>>()?; + 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 { + 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); + } +} diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index ff94d36..4d133f4 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -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 /// recover it from `nodes` without the API. 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`, // so the age it reports is the daemon's own. super::diagnostics::start_clock(); + check_mountpoint(mountpoint)?; let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?; let online = start == Start::Online; let scope = root.tree_event_scope_id(); diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index 81b41f6..561aa83 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -409,6 +409,43 @@ mod tests { 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::()) + .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. fn write_at(path: &Path, offset: u64, bytes: &[u8]) -> io::Result<()> { use std::os::unix::fs::FileExt; diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index a30fa50..cfb98be 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -125,6 +125,9 @@ struct Ui { /// Whether this run already asked for the mailbox password of a locked /// account, so a refresh never re-opens a dialog the user cancelled. unlock_offered: Cell, + /// 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, /// 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. mounted: RefCell, @@ -403,6 +406,7 @@ fn build_window(app: &adw::Application) { opening: RefCell::new(HashSet::new()), session: RefCell::new(startup_session), unlock_offered: Cell::new(false), + not_empty_offered: Cell::new(0), mounted: RefCell::new(false), sidebar: sidebar_list.clone(), nav: split.clone(), @@ -1807,6 +1811,23 @@ pub(crate) fn service_unreachable( .ok() .flatten(); match failure { + Some(failure) if failure.not_empty.is_some() => { + let found = failure.not_empty.as_ref().expect("checked above"); + 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", @@ -1834,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, 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. const COPIED_FEEDBACK: Duration = Duration::from_millis(1500); diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index a3a3879..e6fe81e 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -806,7 +806,8 @@ pub(crate) fn settings_request(ui: &Rc, req: Request, done: String, failed: } /// 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) { let win = ui_window(ui); let dialog = gtk4::FileDialog::builder() @@ -816,46 +817,89 @@ pub(crate) fn prompt_mountpoint(ui: &Rc) { dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| { let Ok(folder) = res else { return }; let Some(path) = folder.path() else { return }; - let path_str = path.display().to_string(); - - // Persist the choice to config so the next mount uses it. - let mut config = ui.dirs.load_config(); - config.mountpoint = Some(path_str.clone()); - if let Err(e) = ui.dirs.save_config(&config) { - toast_error(&ui, &gettext("Couldn't save the folder"), &e.to_string()); + // 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; } - ui.status.mountpoint_row.set_subtitle(&path_str); - // The Locations page shows the same path as a row title. Any other page - // reads it again on the next visit. - if ui.stack.visible_child_name().as_deref() == Some("locations") { - load_locations(&ui); - } - - // The daemon only reads the mountpoint at mount time, so offer a restart. - let confirm = adw::AlertDialog::builder() - .heading(gettext("Restart Proton Drive?")) - // Translators: {path} is the folder the drive is mounted at. - .body(gettext_f( - "My files now appears in “{path}” after Proton Drive restarts.", - &[("path", &path_str)], - )) - .build(); - confirm.add_response("later", &gettext("Later")); - confirm.add_response("restart", &gettext("Restart Now")); - confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested); - confirm.set_default_response(Some("restart")); - confirm.set_close_response("later"); - let ui_restart = ui.clone(); - confirm.connect_response(None, move |_, resp| { - if resp == "restart" { - restart_service_then(&ui_restart, |_| {}); + 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()), } }); - confirm.present(ui_window(&ui).as_ref()); }); } +/// Explain why the folder `found` cannot hold My files. +fn refuse_mountpoint(ui: &Rc, 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, path: &std::path::Path) { + let path_str = path.display().to_string(); + + // Persist the choice to config so the next mount uses it. + let mut config = ui.dirs.load_config(); + config.mountpoint = Some(path_str.clone()); + if let Err(e) = ui.dirs.save_config(&config) { + toast_error(ui, &gettext("Couldn't save the folder"), &e.to_string()); + return; + } + ui.status.mountpoint_row.set_subtitle(&path_str); + // The Locations page shows the same path as a row title. Any other page + // reads it again on the next visit. + if ui.stack.visible_child_name().as_deref() == Some("locations") { + load_locations(ui); + } + + // The daemon only reads the mountpoint at mount time, so offer a restart. + let confirm = adw::AlertDialog::builder() + .heading(gettext("Restart Proton Drive?")) + // Translators: {path} is the folder the drive is mounted at. + .body(gettext_f( + "My files now appears in “{path}” after Proton Drive restarts.", + &[("path", &path_str)], + )) + .build(); + confirm.add_response("later", &gettext("Later")); + confirm.add_response("restart", &gettext("Restart Now")); + confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested); + confirm.set_default_response(Some("restart")); + confirm.set_close_response("later"); + let ui_restart = ui.clone(); + confirm.connect_response(None, move |_, resp| { + if resp == "restart" { + restart_service_then(&ui_restart, |_| {}); + } + }); + confirm.present(ui_window(ui).as_ref()); +} + /// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] / /// [`gallery_unreachable`] when the mount is down): restart the systemd unit and /// reload the page. diff --git a/docs/BUGS.md b/docs/BUGS.md index 021afdb..ab01f94 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -109,6 +109,7 @@ a live account since. | Entry | Title | |---|---| +| [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 | | [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 | | [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 | @@ -379,6 +380,47 @@ a live account since. Newest first. +### 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, not verified live diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1d70bc8..b63975e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,13 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ### 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. + - **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 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f3acf22..1f3fd27 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -76,7 +76,7 @@ fix it. | 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 | | `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 | diff --git a/docs/FAQ.md b/docs/FAQ.md index 5824354..89faef9 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -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). 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? diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 93f9270..d54a6d3 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -141,6 +141,20 @@ fusermount3 -uz ~/ProtonDrive 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 Check that the service runs and is signed in, with `pdfs status`. The service waits for a stored diff --git a/po/de.po b/po/de.po index aa6d5d3..8c94a20 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-31 19:26+0100\n" +"POT-Creation-Date: 2026-11-01 15:25+0100\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -57,7 +57,7 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %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/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:833 @@ -70,74 +70,74 @@ msgstr "%A, %-d. %B %Y, %H:%M" msgid "My files" msgstr "Meine Dateien" -#: 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/photos.rs:845 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "Fotos" -#: 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:484 msgid "Shared with me" msgstr "Mit mir geteilt" -#: 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 msgid "Shared by me" msgstr "Von mir geteilt" -#: 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:493 msgid "Computers" msgstr "Computer" -#: 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 msgid "Trash" msgstr "Papierkorb" #. 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" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. 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 #, rust-format msgid "{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" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1249 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1250 +#: crates/pdfs-gui/src/app/main.rs:1254 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1251 +#: crates/pdfs-gui/src/app/main.rs:1255 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1256 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1339 +#: crates/pdfs-gui/src/app/main.rs:1343 msgid "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 "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -149,23 +149,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1352 +#: crates/pdfs-gui/src/app/main.rs:1356 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1356 +#: crates/pdfs-gui/src/app/main.rs:1360 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1360 +#: crates/pdfs-gui/src/app/main.rs:1364 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1365 +#: crates/pdfs-gui/src/app/main.rs:1369 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1369 +#: crates/pdfs-gui/src/app/main.rs:1373 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -190,7 +190,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1377 +#: crates/pdfs-gui/src/app/main.rs:1381 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1389 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -210,7 +210,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1397 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1389 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1397 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1389 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1397 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1389 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1397 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1389 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1397 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1389 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1397 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -60,7 +60,7 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" 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/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:833 @@ -73,74 +73,74 @@ msgstr "" msgid "My files" 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/photos.rs:845 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" 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:484 msgid "Shared with me" 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 msgid "Shared by me" 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:493 msgid "Computers" 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 msgid "Trash" msgstr "" #. 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" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. 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 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1248 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1249 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "That's not there any more" 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" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1251 +#: crates/pdfs-gui/src/app/main.rs:1255 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1256 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1339 +#: crates/pdfs-gui/src/app/main.rs:1343 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1347 +#: crates/pdfs-gui/src/app/main.rs:1351 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -148,23 +148,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1352 +#: crates/pdfs-gui/src/app/main.rs:1356 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1356 +#: crates/pdfs-gui/src/app/main.rs:1360 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1360 +#: crates/pdfs-gui/src/app/main.rs:1364 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1365 +#: crates/pdfs-gui/src/app/main.rs:1369 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1369 +#: crates/pdfs-gui/src/app/main.rs:1373 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -177,21 +177,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1377 +#: crates/pdfs-gui/src/app/main.rs:1381 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1389 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1397 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1389 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -211,7 +211,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1397 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1389 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1397 msgid "" "The street map, when turned on, comes from OpenFreeMap in the