mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
fix(core,fuse,cli,gui): refuse to mount My files over a folder with files in it
FUSE mounts over a non-empty folder without complaint, so files already in the My files folder were hidden under the mount: not uploaded, and back only once the service stopped, so they looked deleted (#67, B277). - pdfs-core: new `mountpoint` module. `not_empty` lists what is in a folder (hidden entries count); `mounted_fs_type` moves here from the CLI so the daemon can use it. `MountFailure` gains an optional `not_empty` field, which older readers ignore. - pdfs-fuse: `mount_with` checks the folder before the root fetch and before any worker starts. A dead mount is cleared first; one of our own live mounts is left to `spawn_session`, as before. - pdfs-cli: `cmd_daemon` records the folder in the failure record. The refusal backs off and retries like any mount failure, so emptying the folder is enough. - pdfs-gui: the status pages explain the refusal, and a dialog offers to open the folder or choose another one, once per run of failures. The folder picker refuses a folder with files in it. Translated in all catalogs. Docs: BUGS (B277), CHANGELOG, FAQ, TROUBLESHOOTING, CONFIGURATION.
This commit is contained in:
1 parent
321547fb77
commit
5048492a83
22 files changed
+2105
-1251
No files matched your search
+36
-21
@@ -2432,6 +2432,7 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> 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<PathBuf>) -> 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<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.
|
||||
///
|
||||
@@ -3738,26 +3756,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.
|
||||
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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<crate::mountpoint::NotEmpty>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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::<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.
|
||||
fn write_at(path: &Path, offset: u64, bytes: &[u8]) -> io::Result<()> {
|
||||
use std::os::unix::fs::FileExt;
|
||||
|
||||
@@ -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<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
|
||||
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
||||
mounted: RefCell<bool>,
|
||||
@@ -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<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.
|
||||
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
|
||||
/// 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>) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
@@ -816,46 +817,89 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
||||
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<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();
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in new issue
Block a user