mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
85 files changed
+14967
-8583
No files matched your search
@@ -45,7 +45,7 @@ jobs:
|
|||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
- name: Lints
|
- name: Lints
|
||||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: cargo test --workspace --locked
|
run: cargo test --workspace --locked
|
||||||
@@ -66,8 +66,9 @@ jobs:
|
|||||||
|
|
||||||
# The seeded simulation runs (docs/TESTING.md): real FUSE
|
# The seeded simulation runs (docs/TESTING.md): real FUSE
|
||||||
# daemons against an in-memory Drive, so they need /dev/fuse but no account.
|
# daemons against an in-memory Drive, so they need /dev/fuse but no account.
|
||||||
|
# They run on pull requests too: most data-loss fixes are only exercised
|
||||||
|
# here, and a change that breaks one should not reach main first.
|
||||||
simulation:
|
simulation:
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
|||||||
Generated
+5
-4
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "3.4.1"
|
version = "3.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3490,13 +3490,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "3.4.1"
|
version = "3.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"dbus-secret-service-keyring-store",
|
"dbus-secret-service-keyring-store",
|
||||||
"directories",
|
"directories",
|
||||||
"ignore",
|
"ignore",
|
||||||
|
"kamadak-exif",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
"libc",
|
"libc",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -3516,7 +3517,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "3.4.1"
|
version = "3.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3538,7 +3539,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "3.4.1"
|
version = "3.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|||||||
+13
-1
@@ -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.4.1"
|
version = "3.5.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -61,3 +61,15 @@ async-trait = "0.1"
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
fuser = "0.18"
|
fuser = "0.18"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
# No package ships a separate debug package (the PKGBUILD sets `!debug`, the
|
||||||
|
# RPM spec sets `debug_package` to nil), and the unstripped binaries weighed
|
||||||
|
# 43 MB (`pdfs`), 24 MB (`pdfs-app`) and 12 MB (`pdfs-tray`).
|
||||||
|
strip = "symbols"
|
||||||
|
# Off on purpose: LTO has broken GTK links for this workspace, which is why
|
||||||
|
# the packages turn it off as well.
|
||||||
|
lto = false
|
||||||
|
# `Workers` and the thumbnail build catch panics to keep the daemon alive, so
|
||||||
|
# unwinding must stay on.
|
||||||
|
panic = "unwind"
|
||||||
+106
-7
@@ -630,6 +630,16 @@ enum DeviceCmd {
|
|||||||
#[arg(long, conflicts_with = "uid")]
|
#[arg(long, conflicts_with = "uid")]
|
||||||
clear: bool,
|
clear: bool,
|
||||||
},
|
},
|
||||||
|
/// Sync the same folders as another computer: this machine continues that
|
||||||
|
/// computer's device alongside it, and its folders are offered for syncing
|
||||||
|
/// here. Both computers keep them in step.
|
||||||
|
Join {
|
||||||
|
/// Device uid (from `devices list`).
|
||||||
|
uid: String,
|
||||||
|
/// Skip the confirmation and accept every proposed local path.
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
/// Move another computer's folders into this computer's backup and sync
|
/// Move another computer's folders into this computer's backup and sync
|
||||||
/// them here. The other computer stays registered, without those folders.
|
/// them here. The other computer stays registered, without those folders.
|
||||||
Migrate {
|
Migrate {
|
||||||
@@ -831,6 +841,20 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
|
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
// The commands that hold the password, the key passphrases or the session
|
||||||
|
// tokens in memory: keep them out of core dumps and away from same-user
|
||||||
|
// ptrace. Only the daemon did, though `mount` holds the same keys for as
|
||||||
|
// long as it runs.
|
||||||
|
if matches!(
|
||||||
|
cli.command,
|
||||||
|
Command::Login { .. }
|
||||||
|
| Command::Unlock { .. }
|
||||||
|
| Command::Logout
|
||||||
|
| Command::Mount { .. }
|
||||||
|
| Command::Daemon { .. }
|
||||||
|
) {
|
||||||
|
pdfs_core::kernelkey::forbid_dumps();
|
||||||
|
}
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Login { username, remember } => cmd_login(username, remember),
|
Command::Login { username, remember } => cmd_login(username, remember),
|
||||||
Command::Unlock { remember } => cmd_unlock(remember),
|
Command::Unlock { remember } => cmd_unlock(remember),
|
||||||
@@ -978,6 +1002,26 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
|
|||||||
let uid = if clear { None } else { uid };
|
let uid = if clear { None } else { uid };
|
||||||
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
|
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
|
||||||
}
|
}
|
||||||
|
DeviceCmd::Join { uid, yes } => {
|
||||||
|
if !yes {
|
||||||
|
println!(
|
||||||
|
"This computer syncs into that device alongside it. Its folders are kept\n\
|
||||||
|
in step on both computers: an edit or a deletion on one reaches the other.\n\
|
||||||
|
New synced folders here go under that device too. Folders already synced\n\
|
||||||
|
here stay where they are."
|
||||||
|
);
|
||||||
|
confirm("Sync with it?")?;
|
||||||
|
}
|
||||||
|
let response = control_request(CtlRequest::AdoptDevice { uid: Some(uid) })?;
|
||||||
|
// A script gets the one reply and runs `sync restore` itself.
|
||||||
|
if emit_json(&response)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ok_or_bail(response)?;
|
||||||
|
// The device is this machine's now, so the plain restore offers its
|
||||||
|
// folders.
|
||||||
|
cmd_sync_restore(yes, None)?
|
||||||
|
}
|
||||||
DeviceCmd::Migrate { uid, yes } => {
|
DeviceCmd::Migrate { uid, yes } => {
|
||||||
if !yes {
|
if !yes {
|
||||||
println!(
|
println!(
|
||||||
@@ -1977,8 +2021,14 @@ fn key_state_name() -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_logout() -> Result<()> {
|
fn cmd_logout() -> Result<()> {
|
||||||
auth::logout()?;
|
if auth::logout()? {
|
||||||
println!("Stored session cleared.");
|
println!("Signed out: the session is ended on Proton and cleared here.");
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"Stored session cleared. Proton could not be reached to end the session; \
|
||||||
|
it stays valid until it expires or is signed out in your account's security settings."
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2221,9 +2271,6 @@ 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<()> {
|
||||||
// The daemon holds the account's key passphrases in memory; keep them out
|
|
||||||
// of core dumps and away from same-user ptrace.
|
|
||||||
pdfs_core::kernelkey::forbid_dumps();
|
|
||||||
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.
|
||||||
@@ -2592,7 +2639,10 @@ fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -
|
|||||||
} => continue,
|
} => continue,
|
||||||
CtlResponse::ImportStatus { summary, .. } => {
|
CtlResponse::ImportStatus { summary, .. } => {
|
||||||
print_import_summary(summary.as_ref());
|
print_import_summary(summary.as_ref());
|
||||||
return Ok(());
|
return finished_run(
|
||||||
|
"the import",
|
||||||
|
summary.map(|s| (s.failed, s.cancelled, s.error)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
other => bail!("unexpected response: {other:?}"),
|
other => bail!("unexpected response: {other:?}"),
|
||||||
@@ -2635,7 +2685,10 @@ fn cmd_redate_photos(
|
|||||||
CtlResponse::RedateStatus { running: true, .. } => continue,
|
CtlResponse::RedateStatus { running: true, .. } => continue,
|
||||||
CtlResponse::RedateStatus { summary, .. } => {
|
CtlResponse::RedateStatus { summary, .. } => {
|
||||||
print_redate_summary(summary.as_ref());
|
print_redate_summary(summary.as_ref());
|
||||||
return Ok(());
|
return finished_run(
|
||||||
|
"the re-date",
|
||||||
|
summary.map(|s| (s.failed, s.cancelled, s.error)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
other => bail!("unexpected response: {other:?}"),
|
other => bail!("unexpected response: {other:?}"),
|
||||||
@@ -2643,6 +2696,22 @@ fn cmd_redate_photos(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a run `--wait` followed ended, as the exit status: an error when it
|
||||||
|
/// failed, was cancelled, or left photos failed, so a script can tell.
|
||||||
|
/// `outcome` is `(failed, cancelled, error)`; `None` when the daemon has no
|
||||||
|
/// result, which happens when it restarted during the run.
|
||||||
|
fn finished_run(what: &str, outcome: Option<(usize, bool, Option<String>)>) -> Result<()> {
|
||||||
|
match outcome {
|
||||||
|
None => bail!("{what} ended without a result; the service may have restarted"),
|
||||||
|
Some((_, _, Some(error))) => bail!("{what} failed: {error}"),
|
||||||
|
Some((_, true, None)) => bail!("{what} was cancelled"),
|
||||||
|
Some((failed, false, None)) if failed > 0 => {
|
||||||
|
bail!("{what} finished, but {failed} photo(s) failed")
|
||||||
|
}
|
||||||
|
Some(_) => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A `YYYY-MM-DD` day as epoch seconds at UTC midnight. Reuses the Takeout
|
/// A `YYYY-MM-DD` day as epoch seconds at UTC midnight. Reuses the Takeout
|
||||||
/// scanner's date reader, which already accepts exactly this spelling.
|
/// scanner's date reader, which already accepts exactly this spelling.
|
||||||
fn parse_day(day: &str) -> Result<i64> {
|
fn parse_day(day: &str) -> Result<i64> {
|
||||||
@@ -2678,6 +2747,10 @@ fn print_redate_summary(summary: Option<&pdfs_core::control::RedateSummary>) {
|
|||||||
println!("No re-date has run yet.");
|
println!("No re-date has run yet.");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if let Some(error) = &summary.error {
|
||||||
|
println!("Failed : {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
println!("Photos examined : {}", summary.examined);
|
println!("Photos examined : {}", summary.examined);
|
||||||
println!("Mis-dated : {}", summary.candidates);
|
println!("Mis-dated : {}", summary.candidates);
|
||||||
if summary.dry_run {
|
if summary.dry_run {
|
||||||
@@ -2737,6 +2810,10 @@ fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>) {
|
|||||||
println!("No import has run yet.");
|
println!("No import has run yet.");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if let Some(error) = &summary.error {
|
||||||
|
println!("Failed : {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
println!("Photos in export : {}", summary.found);
|
println!("Photos in export : {}", summary.found);
|
||||||
println!("Uploaded : {}", summary.uploaded);
|
println!("Uploaded : {}", summary.uploaded);
|
||||||
println!("Already on Proton: {}", summary.duplicates);
|
println!("Already on Proton: {}", summary.duplicates);
|
||||||
@@ -3751,6 +3828,28 @@ mod login_wait_tests {
|
|||||||
assert_eq!(login_poll_delay(u32::MAX), Duration::from_secs(15));
|
assert_eq!(login_poll_delay(u32::MAX), Duration::from_secs(15));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The file-manager script passes each selected path after `--`, so a
|
||||||
|
/// file named like an option is still the path.
|
||||||
|
#[test]
|
||||||
|
fn a_path_after_a_double_dash_is_a_path_even_when_it_looks_like_a_flag() {
|
||||||
|
let cli = Cli::try_parse_from(["pdfs", "pin", "--", "--json"]).unwrap();
|
||||||
|
assert!(!cli.json);
|
||||||
|
match cli.command {
|
||||||
|
Command::Pin { path } => assert_eq!(path, PathBuf::from("--json")),
|
||||||
|
_ => panic!("not a pin"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_waited_for_run_that_did_not_fully_succeed_exits_with_an_error() {
|
||||||
|
assert!(finished_run("the import", Some((0, false, None))).is_ok());
|
||||||
|
assert!(finished_run("the import", Some((3, false, None))).is_err());
|
||||||
|
assert!(finished_run("the import", Some((0, true, None))).is_err());
|
||||||
|
let failed = finished_run("the import", Some((0, false, Some("not a zip".into()))));
|
||||||
|
assert!(failed.unwrap_err().to_string().contains("not a zip"));
|
||||||
|
assert!(finished_run("the import", None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn waiting_for_a_session_stops_hammering_the_keyring() {
|
fn waiting_for_a_session_stops_hammering_the_keyring() {
|
||||||
let mut checks_left = 20;
|
let mut checks_left = 20;
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ async-trait.workspace = true
|
|||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
zeroize.workspace = true
|
zeroize.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
|
exif = { version = "0.6", package = "kamadak-exif" }
|
||||||
@@ -516,8 +516,59 @@ pub fn lock(forget: bool) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget the persisted session (best-effort; absent entry is not an error).
|
/// How long [`logout`] waits for Proton to end the session before it signs
|
||||||
pub fn logout() -> Result<()> {
|
/// out locally without.
|
||||||
|
const END_SESSION_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// End the stored session on Proton's side (`DELETE auth/v4`). Returns false
|
||||||
|
/// when there is none or Proton could not be reached in time.
|
||||||
|
///
|
||||||
|
/// Runs on a thread of its own, so it works the same whether the caller is
|
||||||
|
/// inside a tokio runtime or not.
|
||||||
|
fn end_server_session() -> bool {
|
||||||
|
let Ok(stored) = load() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let ended = std::thread::scope(|scope| {
|
||||||
|
scope
|
||||||
|
.spawn(|| -> std::result::Result<(), String> {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
rt.block_on(async {
|
||||||
|
let session = resume_stored(&stored).map_err(|e| e.to_string())?;
|
||||||
|
match tokio::time::timeout(END_SESSION_TIMEOUT, session.end()).await {
|
||||||
|
Ok(result) => result.map_err(|e| e.to_string()),
|
||||||
|
Err(_) => Err(format!(
|
||||||
|
"no answer within {}s",
|
||||||
|
END_SESSION_TIMEOUT.as_secs()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.unwrap_or_else(|_| Err("the sign-out thread panicked".into()))
|
||||||
|
});
|
||||||
|
match ended {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "ending the session on Proton failed; signing out locally");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End the session on Proton, then forget it here. Returns whether Proton
|
||||||
|
/// ended it: offline, the session stays valid on Proton's side until it
|
||||||
|
/// expires or is signed out from the account's security settings.
|
||||||
|
///
|
||||||
|
/// Before this, signing out only deleted the keyring entry. The tokens stayed
|
||||||
|
/// valid on Proton, so a copy of them taken from this machine still worked.
|
||||||
|
///
|
||||||
|
/// The local half is best effort; an absent entry is not an error.
|
||||||
|
pub fn logout() -> Result<bool> {
|
||||||
|
let ended = end_server_session();
|
||||||
// The persisted entity cache describes the account being logged out of. It
|
// The persisted entity cache describes the account being logged out of. It
|
||||||
// is encrypted under a key derived from that account's passphrases, but
|
// is encrypted under a key derived from that account's passphrases, but
|
||||||
// leaving it behind would also mean the next account inherits a store it can
|
// leaving it behind would also mean the next account inherits a store it can
|
||||||
@@ -539,7 +590,7 @@ pub fn logout() -> Result<()> {
|
|||||||
}
|
}
|
||||||
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
||||||
match keyring_entry()?.delete_credential() {
|
match keyring_entry()?.delete_credential() {
|
||||||
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(ended),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ pub struct AppConfig {
|
|||||||
/// Defaulted for configs predating the field.
|
/// Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub files_view: FilesView,
|
pub files_view: FilesView,
|
||||||
|
/// The desktop app's main window size, remembered between runs. Defaulted
|
||||||
|
/// for configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub window: WindowState,
|
||||||
/// The user hid the tray icon from its own menu. The tray exits at start
|
/// The user hid the tray icon from its own menu. The tray exits at start
|
||||||
/// while this is set, and the app does not spawn it; Preferences turns it
|
/// while this is set, and the app does not spawn it; Preferences turns it
|
||||||
/// back on. Defaulted for configs predating the field.
|
/// back on. Defaulted for configs predating the field.
|
||||||
@@ -211,6 +215,44 @@ impl Default for FilesView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The desktop app's main window, as it was when it last closed.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct WindowState {
|
||||||
|
/// The unmaximised size, in logical pixels.
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
pub maximized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowState {
|
||||||
|
/// Below this the saved size is taken for damage and the default is used.
|
||||||
|
pub const MIN_WIDTH: i32 = 480;
|
||||||
|
pub const MIN_HEIGHT: i32 = 400;
|
||||||
|
|
||||||
|
/// This state, with a size too small to use replaced by the default.
|
||||||
|
pub fn usable(self) -> Self {
|
||||||
|
if self.width < Self::MIN_WIDTH || self.height < Self::MIN_HEIGHT {
|
||||||
|
Self {
|
||||||
|
maximized: self.maximized,
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WindowState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
width: 980,
|
||||||
|
height: 680,
|
||||||
|
maximized: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The key a My Files listing is ordered by.
|
/// The key a My Files listing is ordered by.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -310,6 +352,22 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fail when the config file at `path` exists but does not parse, so that
|
||||||
|
/// [`AppDirs::save_config`] never writes over settings it could not read.
|
||||||
|
fn refuse_to_replace_unreadable(path: &Path) -> Result<()> {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(content) => match serde_json::from_str::<AppConfig>(&content) {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(e) => Err(Error::Other(format!(
|
||||||
|
"{} cannot be read ({e}); fix it or move it aside before changing settings",
|
||||||
|
path.display()
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolved XDG locations for state, cache, and the default mountpoint.
|
/// Resolved XDG locations for state, cache, and the default mountpoint.
|
||||||
pub struct AppDirs {
|
pub struct AppDirs {
|
||||||
dirs: ProjectDirs,
|
dirs: ProjectDirs,
|
||||||
@@ -341,6 +399,7 @@ impl AppDirs {
|
|||||||
// Preserve the original bytes for diagnosis/recovery. In
|
// Preserve the original bytes for diagnosis/recovery. In
|
||||||
// particular, never replace a truncated config with defaults
|
// particular, never replace a truncated config with defaults
|
||||||
// and silently lose mount, device, or ignore settings.
|
// and silently lose mount, device, or ignore settings.
|
||||||
|
// `save_config` refuses to write over it for the same reason.
|
||||||
tracing::error!(path = %path.display(), error = %e, "config is unreadable; using in-memory defaults without overwriting it");
|
tracing::error!(path = %path.display(), error = %e, "config is unreadable; using in-memory defaults without overwriting it");
|
||||||
return AppConfig::default();
|
return AppConfig::default();
|
||||||
}
|
}
|
||||||
@@ -348,9 +407,8 @@ impl AppDirs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let default_config = AppConfig::default();
|
let default_config = AppConfig::default();
|
||||||
if let Ok(content) = serde_json::to_string_pretty(&default_config) {
|
if let Err(e) = self.save_config(&default_config) {
|
||||||
let _ = std::fs::create_dir_all(self.dirs.config_dir());
|
tracing::warn!(path = %path.display(), error = %e, "could not write the default config");
|
||||||
let _ = std::fs::write(&path, content);
|
|
||||||
}
|
}
|
||||||
default_config
|
default_config
|
||||||
}
|
}
|
||||||
@@ -358,8 +416,15 @@ impl AppDirs {
|
|||||||
/// Persist `config` to [`config_path`](Self::config_path), creating the
|
/// Persist `config` to [`config_path`](Self::config_path), creating the
|
||||||
/// config dir if missing. The Settings page calls this after editing the
|
/// config dir if missing. The Settings page calls this after editing the
|
||||||
/// cache budget or mountpoint; the daemon re-reads the file on its next mount.
|
/// cache budget or mountpoint; the daemon re-reads the file on its next mount.
|
||||||
|
///
|
||||||
|
/// Refuses while the file on disk does not parse. Every caller loads,
|
||||||
|
/// changes one field and saves, and [`load_config`](Self::load_config)
|
||||||
|
/// gives defaults for a file it cannot read, so the first change after
|
||||||
|
/// the file was damaged wrote defaults over the mountpoint, the device and
|
||||||
|
/// the ignore rules the user had (`docs/BUGS.md` B223).
|
||||||
pub fn save_config(&self, config: &AppConfig) -> Result<()> {
|
pub fn save_config(&self, config: &AppConfig) -> Result<()> {
|
||||||
let path = self.config_path();
|
let path = self.config_path();
|
||||||
|
refuse_to_replace_unreadable(&path)?;
|
||||||
let parent = path
|
let parent = path
|
||||||
.parent()
|
.parent()
|
||||||
.ok_or_else(|| Error::Other("configuration path has no parent".into()))?;
|
.ok_or_else(|| Error::Other("configuration path has no parent".into()))?;
|
||||||
@@ -383,6 +448,46 @@ impl AppDirs {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This machine's id (e.g. ~/.config/proton-drive-linux/machine-id). See
|
||||||
|
/// [`machine_id`](Self::machine_id).
|
||||||
|
pub fn machine_id_path(&self) -> PathBuf {
|
||||||
|
self.dirs.config_dir().join("machine-id")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A random id for this machine, made on first use and kept in
|
||||||
|
/// [`machine_id_path`](Self::machine_id_path).
|
||||||
|
///
|
||||||
|
/// It names this machine's profile in a device it may share with other
|
||||||
|
/// computers. Random rather than `/etc/machine-id`, so nothing that
|
||||||
|
/// identifies the host goes to Drive. In a file of its own rather than
|
||||||
|
/// `config.json`, because the app saves the config from a copy it loaded
|
||||||
|
/// earlier, and that would drop an id the daemon had just added.
|
||||||
|
pub fn machine_id(&self) -> Result<String> {
|
||||||
|
let path = self.machine_id_path();
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(id) if is_machine_id(id.trim()) => return Ok(id.trim().to_string()),
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::warn!(path = %path.display(), "machine id is unusable; making a new one");
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
use std::io::Read as _;
|
||||||
|
std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
|
||||||
|
let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| Error::Other("machine id path has no parent".into()))?;
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
|
||||||
|
std::fs::write(&tmp, format!("{id}\n"))?;
|
||||||
|
std::fs::File::open(&tmp)?.sync_all()?;
|
||||||
|
std::fs::rename(&tmp, &path)?;
|
||||||
|
std::fs::File::open(parent)?.sync_all()?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Persistent state: inode map / cache index DB lives here.
|
/// Persistent state: inode map / cache index DB lives here.
|
||||||
pub fn state_dir(&self) -> PathBuf {
|
pub fn state_dir(&self) -> PathBuf {
|
||||||
// `state_dir` is Linux-only in `directories`; fall back to data dir.
|
// `state_dir` is Linux-only in `directories`; fall back to data dir.
|
||||||
@@ -471,6 +576,12 @@ impl AppDirs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `id` is what [`AppDirs::machine_id`] writes: 32 lowercase hex
|
||||||
|
/// digits.
|
||||||
|
fn is_machine_id(id: &str) -> bool {
|
||||||
|
id.len() == 32 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||||
|
}
|
||||||
|
|
||||||
/// Make `dir` owner-only (`0700`). These directories contain decrypted bytes,
|
/// Make `dir` owner-only (`0700`). These directories contain decrypted bytes,
|
||||||
/// plaintext names, and an authenticated control socket, so failure is fatal.
|
/// plaintext names, and an authenticated control socket, so failure is fatal.
|
||||||
fn restrict_dir(dir: &Path) -> Result<()> {
|
fn restrict_dir(dir: &Path) -> Result<()> {
|
||||||
@@ -526,6 +637,28 @@ mod tests {
|
|||||||
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A damaged config must not be replaced by defaults the next time a
|
||||||
|
/// setting changes (`docs/BUGS.md` B223).
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_config_is_never_written_over() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-cfg-guard-{}-{:?}",
|
||||||
|
std::process::id(),
|
||||||
|
std::thread::current().id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("config.json");
|
||||||
|
|
||||||
|
assert!(refuse_to_replace_unreadable(&path).is_ok(), "no file yet");
|
||||||
|
std::fs::write(&path, serde_json::to_vec(&AppConfig::default()).unwrap()).unwrap();
|
||||||
|
assert!(refuse_to_replace_unreadable(&path).is_ok());
|
||||||
|
std::fs::write(&path, br#"{"mountpoint": "/home/me/Pro"#).unwrap();
|
||||||
|
assert!(refuse_to_replace_unreadable(&path).is_err(), "truncated");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
/// `docs/BUGS.md` B6. A permissive mode here is not a cosmetic problem: these
|
/// `docs/BUGS.md` B6. A permissive mode here is not a cosmetic problem: these
|
||||||
/// directories hold decrypted content, a plaintext index of every node name,
|
/// directories hold decrypted content, a plaintext index of every node name,
|
||||||
/// and the control socket that commands the daemon's session.
|
/// and the control socket that commands the daemon's session.
|
||||||
@@ -611,6 +744,11 @@ mod tests {
|
|||||||
descending: true,
|
descending: true,
|
||||||
folders_first: false,
|
folders_first: false,
|
||||||
},
|
},
|
||||||
|
window: WindowState {
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
maximized: true,
|
||||||
|
},
|
||||||
tray_hidden: true,
|
tray_hidden: true,
|
||||||
language: Some("de".to_string()),
|
language: Some("de".to_string()),
|
||||||
online_map: true,
|
online_map: true,
|
||||||
@@ -630,6 +768,39 @@ mod tests {
|
|||||||
assert_eq!(decoded.upload_limit, Some(500_000));
|
assert_eq!(decoded.upload_limit, Some(500_000));
|
||||||
assert_eq!(decoded.download_limit, None);
|
assert_eq!(decoded.download_limit, None);
|
||||||
assert_eq!(decoded.files_view, config.files_view);
|
assert_eq!(decoded.files_view, config.files_view);
|
||||||
|
assert_eq!(decoded.window, config.window);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_config_without_a_window_opens_at_the_default_size() {
|
||||||
|
let decoded: AppConfig =
|
||||||
|
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
|
||||||
|
assert_eq!(decoded.window, WindowState::default());
|
||||||
|
let partial: WindowState = serde_json::from_str(r#"{"maximized":true}"#).unwrap();
|
||||||
|
assert_eq!((partial.width, partial.height), (980, 680));
|
||||||
|
assert!(partial.maximized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_saved_too_small_opens_at_the_default_size() {
|
||||||
|
let tiny = WindowState {
|
||||||
|
width: 0,
|
||||||
|
height: 500,
|
||||||
|
maximized: true,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
tiny.usable(),
|
||||||
|
WindowState {
|
||||||
|
maximized: true,
|
||||||
|
..WindowState::default()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let fine = WindowState {
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
maximized: false,
|
||||||
|
};
|
||||||
|
assert_eq!(fine.usable(), fine);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -760,6 +931,15 @@ mod tests {
|
|||||||
assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce));
|
assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_machine_id_is_32_lowercase_hex_digits() {
|
||||||
|
assert!(is_machine_id("0123456789abcdef0123456789abcdef"));
|
||||||
|
assert!(!is_machine_id("0123456789ABCDEF0123456789ABCDEF"));
|
||||||
|
assert!(!is_machine_id("0123456789abcdef"));
|
||||||
|
assert!(!is_machine_id("../../../../etc/passwd/0123456789"));
|
||||||
|
assert!(!is_machine_id(""));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_config_predating_ignore_patterns_still_loads() {
|
fn a_config_predating_ignore_patterns_still_loads() {
|
||||||
// Every installed config on disk looks like this; the field must not be
|
// Every installed config on disk looks like this; the field must not be
|
||||||
|
|||||||
@@ -1591,6 +1591,10 @@ pub struct RedateSummary {
|
|||||||
/// name implies)` — what a dry run is read for.
|
/// name implies)` — what a dry run is read for.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub samples: Vec<(String, i64, i64)>,
|
pub samples: Vec<(String, i64, i64)>,
|
||||||
|
/// Why the run as a whole failed, when it did. The counts are then
|
||||||
|
/// empty: the run never got to the photos.
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a Google Photos Takeout import is going, or how the last one ended
|
/// How a Google Photos Takeout import is going, or how the last one ended
|
||||||
@@ -1620,6 +1624,10 @@ pub struct ImportSummary {
|
|||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
/// True when the run stopped early because it was cancelled.
|
/// True when the run stopped early because it was cancelled.
|
||||||
pub cancelled: bool,
|
pub cancelled: bool,
|
||||||
|
/// Why the run as a whole failed, when it did, such as an archive that
|
||||||
|
/// is not a zip. The counts are then empty.
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One album in a [`Response::Albums`] listing.
|
/// One album in a [`Response::Albums`] listing.
|
||||||
|
|||||||
@@ -111,9 +111,80 @@ impl Db {
|
|||||||
)?;
|
)?;
|
||||||
let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
|
let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
drop(conn);
|
||||||
|
self.compact_search_indexes_if_due(finished_at)?;
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Close a scan that did not see everything: it stopped at the entry cap,
|
||||||
|
/// or a batch failed to write. Nothing is pruned, since a path it missed is
|
||||||
|
/// not a path that is gone; the next complete scan prunes. Returns the
|
||||||
|
/// number of indexed entries.
|
||||||
|
///
|
||||||
|
/// Pruning after such a scan dropped every entry it had not reached, and
|
||||||
|
/// the next scan put them back. That churn is most of what grew
|
||||||
|
/// `local_fts` to 16 times its size (`docs/BUGS.md` B217).
|
||||||
|
pub fn local_finish_partial_scan(&self, finished_at: i64) -> Result<i64> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
[finished_at.to_string()],
|
||||||
|
)?;
|
||||||
|
let count: i64 = conn.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact both full-text indexes, at most once a day.
|
||||||
|
///
|
||||||
|
/// FTS5 keeps a deleted entry's data until the segments holding it are
|
||||||
|
/// merged, and with the few large segments these indexes settle into that
|
||||||
|
/// is close to never. Every file the local scan sees disappear or appear
|
||||||
|
/// leaves some behind, and every node row the tree replaces does too: on
|
||||||
|
/// one machine `local_fts` held 317 MiB for 40,000 paths, against 20 MiB
|
||||||
|
/// rebuilt, and a search took four times as long (`docs/BUGS.md` B217).
|
||||||
|
///
|
||||||
|
/// `local_fts` is rebuilt from `local_files`, which also drops anything
|
||||||
|
/// left over, and `nodes_fts`, which has no content table to rebuild from,
|
||||||
|
/// is merged into one segment. Each runs in its own transaction on the
|
||||||
|
/// write connection, about half a second on an index that size; once a
|
||||||
|
/// day keeps the leftovers small without paying that at every scan.
|
||||||
|
pub fn compact_search_indexes_if_due(&self, now: i64) -> Result<()> {
|
||||||
|
const EVERY_SECS: i64 = 24 * 60 * 60;
|
||||||
|
let last: i64 = self
|
||||||
|
.read()
|
||||||
|
.query_row(
|
||||||
|
"SELECT value FROM sync_state WHERE key = 'fts_compacted_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
if now - last < EVERY_SECS {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
{
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute("INSERT INTO local_fts(local_fts) VALUES('rebuild')", [])?;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute("INSERT INTO nodes_fts(nodes_fts) VALUES('optimize')", [])?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sync_state (key, value) VALUES ('fts_compacted_at', ?1)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
[now.to_string()],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||||
|
"compacted the search indexes"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// When the last local scan completed (epoch seconds), or `None` if the index
|
/// When the last local scan completed (epoch seconds), or `None` if the index
|
||||||
/// has never been built. The daemon uses this to decide whether a fresh mount
|
/// has never been built. The daemon uses this to decide whether a fresh mount
|
||||||
/// needs an immediate rescan or can serve the existing index.
|
/// needs an immediate rescan or can serve the existing index.
|
||||||
|
|||||||
@@ -91,8 +91,13 @@ impl Db {
|
|||||||
/// Returns an empty vector when the database is sound; otherwise each entry
|
/// Returns an empty vector when the database is sound; otherwise each entry
|
||||||
/// is one problem SQLite found. Read-only and safe against a live daemon,
|
/// is one problem SQLite found. Read-only and safe against a live daemon,
|
||||||
/// though it reads every page, so it is not free on a large file.
|
/// though it reads every page, so it is not free on a large file.
|
||||||
|
///
|
||||||
|
/// Runs on a read connection: it reads every page, which took 34 s on a
|
||||||
|
/// 567 MiB file, and on the write connection every write in the daemon
|
||||||
|
/// waited that long behind it (`docs/BUGS.md` B218). WAL lets it read a
|
||||||
|
/// consistent snapshot while the writer goes on.
|
||||||
pub fn integrity_check(&self) -> Result<Vec<String>> {
|
pub fn integrity_check(&self) -> Result<Vec<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare("PRAGMA integrity_check")?;
|
let mut stmt = conn.prepare("PRAGMA integrity_check")?;
|
||||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||||
let mut problems = Vec::new();
|
let mut problems = Vec::new();
|
||||||
@@ -107,9 +112,11 @@ impl Db {
|
|||||||
Ok(problems)
|
Ok(problems)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Size and row-count snapshot for `pdfs cache inspect`.
|
/// Size and row-count snapshot for `pdfs cache inspect`. On a read
|
||||||
|
/// connection, for the reason [`integrity_check`](Self::integrity_check)
|
||||||
|
/// gives: counting the rows of every table reads most of the file.
|
||||||
pub fn stats(&self) -> Result<DbStats> {
|
pub fn stats(&self) -> Result<DbStats> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let page_count: i64 = conn.query_row("PRAGMA page_count", [], |r| r.get(0))?;
|
let page_count: i64 = conn.query_row("PRAGMA page_count", [], |r| r.get(0))?;
|
||||||
let free_pages: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?;
|
let free_pages: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?;
|
||||||
let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?;
|
let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?;
|
||||||
@@ -218,6 +225,40 @@ mod tests {
|
|||||||
assert!(db.integrity_check().unwrap().is_empty());
|
assert!(db.integrity_check().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `pdfs diagnose` and `pdfs cache inspect` read the whole file. Behind the
|
||||||
|
/// write lock they froze every write in the daemon for as long as that
|
||||||
|
/// took (`docs/BUGS.md` B218).
|
||||||
|
#[test]
|
||||||
|
fn the_integrity_check_and_stats_do_not_wait_for_the_writer() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-check-{}-{}.db",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
let db = std::sync::Arc::new(Db::open(&path).unwrap());
|
||||||
|
let writer = db.conn.lock();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let reader = std::sync::Arc::clone(&db);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let checked = reader.integrity_check().map(|p| p.is_empty());
|
||||||
|
let counted = reader.stats().map(|s| s.page_count > 0);
|
||||||
|
let _ = tx.send((checked.ok(), counted.ok()));
|
||||||
|
});
|
||||||
|
let answer = rx.recv_timeout(std::time::Duration::from_secs(10));
|
||||||
|
drop(writer);
|
||||||
|
assert_eq!(answer.ok(), Some((Some(true), Some(true))));
|
||||||
|
drop(db);
|
||||||
|
for suffix in ["", "-wal", "-shm"] {
|
||||||
|
let mut file = path.as_os_str().to_owned();
|
||||||
|
file.push(suffix);
|
||||||
|
let _ = std::fs::remove_file(std::path::PathBuf::from(file));
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stats_report_the_schema_and_the_known_tables() {
|
fn stats_report_the_schema_and_the_known_tables() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 42;
|
pub(super) const SCHEMA_VERSION: i64 = 44;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -440,6 +440,33 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V42)?;
|
tx.execute_batch(MIGRATION_V42)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 43 {
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops {
|
||||||
|
tx.execute_batch(MIGRATION_V43)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 44 {
|
||||||
|
// Same guards as V26-V42.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('sync_entry') \
|
||||||
|
WHERE name = 'remote_revision_id'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_entries: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_entry'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_entries && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V44)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -1330,3 +1357,28 @@ const MIGRATION_V42: &str = "
|
|||||||
ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT;
|
ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT;
|
||||||
ALTER TABLE pending_op ADD COLUMN sent_to TEXT;
|
ALTER TABLE pending_op ADD COLUMN sent_to TEXT;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// Schema v43: queued ops by kind and name.
|
||||||
|
///
|
||||||
|
/// Before each create, rename and trash the drain asks whether a queued trash
|
||||||
|
/// or rename holds the name it wants, and a released name wakes the ops
|
||||||
|
/// waiting for it. Each of those scanned the whole queue, so a large offline
|
||||||
|
/// batch made draining it quadratic. The kind leads, so a lookup of every
|
||||||
|
/// queued rename uses the index too.
|
||||||
|
const MIGRATION_V43: &str = "
|
||||||
|
CREATE INDEX IF NOT EXISTS pending_op_kind_name ON pending_op(kind, name);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v44: a synced file's baseline remembers the revision it was synced
|
||||||
|
/// at.
|
||||||
|
///
|
||||||
|
/// A mirrored file edited here is uploaded as a new revision of whatever is
|
||||||
|
/// current on Drive, and another computer syncing the same folder may have
|
||||||
|
/// committed one since this pass looked. The revision id is what the upload
|
||||||
|
/// checks first, so that edit becomes a conflict copy instead of being
|
||||||
|
/// replaced (B249). `NULL` means "this row predates the column", and the check
|
||||||
|
/// falls back to the modification time and size; each row gains a real value
|
||||||
|
/// the next time its path is synced.
|
||||||
|
const MIGRATION_V44: &str = "
|
||||||
|
ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT;
|
||||||
|
";
|
||||||
+163
-15
@@ -179,6 +179,45 @@ impl Drop for Reader<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The write connection with `synchronous=FULL`, back to NORMAL on drop.
|
||||||
|
///
|
||||||
|
/// NORMAL in WAL mode keeps a commit across a crash of the process but not
|
||||||
|
/// across a power loss: the WAL is synced only at checkpoints. That is fine for
|
||||||
|
/// metadata the next listing re-fetches, and not for the queue. A staged blob
|
||||||
|
/// is synced before its op is queued, and the op row is what makes it
|
||||||
|
/// reachable; losing that row loses a write `close()` already reported as
|
||||||
|
/// done. A FULL commit syncs the WAL, which covers every NORMAL commit before
|
||||||
|
/// it as well.
|
||||||
|
pub(super) struct Durable<'a>(parking_lot::MutexGuard<'a, Connection>);
|
||||||
|
|
||||||
|
impl<'a> Durable<'a> {
|
||||||
|
pub(super) fn new(conn: parking_lot::MutexGuard<'a, Connection>) -> Result<Self> {
|
||||||
|
conn.pragma_update(None, "synchronous", "FULL")?;
|
||||||
|
Ok(Self(conn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for Durable<'_> {
|
||||||
|
type Target = Connection;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Connection {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::DerefMut for Durable<'_> {
|
||||||
|
fn deref_mut(&mut self) -> &mut Connection {
|
||||||
|
&mut self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Durable<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// A failure leaves the writer at FULL: slower, never less safe.
|
||||||
|
let _ = self.0.pragma_update(None, "synchronous", "NORMAL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Open a second connection to the same file for reads only.
|
/// Open a second connection to the same file for reads only.
|
||||||
///
|
///
|
||||||
/// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops
|
/// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops
|
||||||
@@ -216,18 +255,34 @@ impl Db {
|
|||||||
let conn = match Self::open_configured(path) {
|
let conn = match Self::open_configured(path) {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
Err(e) if is_corrupt(&e) => {
|
Err(e) if is_corrupt(&e) => {
|
||||||
// Every byte in here is derived from the account or from the
|
// Most of what is in here is derived from the account and is
|
||||||
// content cache directory, so the recovery is to start over —
|
// fetched again. The queue is not: it holds writes accepted
|
||||||
// loudly, and keeping the damaged file for a post-mortem rather
|
// here that Drive does not have yet, and the mirror baselines
|
||||||
// than deleting evidence.
|
// and pins are this machine's alone. So the damaged file is
|
||||||
|
// moved aside, kept as evidence, and those tables are copied
|
||||||
|
// out of it into the new one as far as SQLite can still read
|
||||||
|
// them (`docs/BUGS.md` B216).
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
db = %path.display(),
|
db = %path.display(),
|
||||||
error = %e,
|
error = %e,
|
||||||
"cache database is corrupt — moving it aside and rebuilding \
|
"cache database is corrupt; moving it aside, rebuilding, and \
|
||||||
(metadata will be re-fetched; no user data is stored here)"
|
copying the upload queue, sync baselines and pins out of it"
|
||||||
);
|
);
|
||||||
quarantine_corrupt_db(path)?;
|
let damaged = quarantine_corrupt_db(path)?;
|
||||||
Self::open_configured(path)?
|
let conn = Self::open_configured(path)?;
|
||||||
|
for (table, result) in salvage(&conn, &damaged) {
|
||||||
|
match result {
|
||||||
|
Ok(rows) => {
|
||||||
|
tracing::warn!(table, rows, "salvaged from the damaged database")
|
||||||
|
}
|
||||||
|
Err(error) => tracing::error!(
|
||||||
|
table,
|
||||||
|
%error,
|
||||||
|
"could not salvage from the damaged database"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
};
|
};
|
||||||
@@ -254,6 +309,12 @@ impl Db {
|
|||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The write connection for a commit that accepts a user's change into
|
||||||
|
/// the queue. See [`Durable`].
|
||||||
|
pub(super) fn durable(&self) -> Result<Durable<'_>> {
|
||||||
|
Durable::new(self.conn.lock())
|
||||||
|
}
|
||||||
|
|
||||||
/// A connection for a `SELECT`-only method.
|
/// A connection for a `SELECT`-only method.
|
||||||
///
|
///
|
||||||
/// Reads see the last committed state, which is the same thing they saw
|
/// Reads see the last committed state, which is the same thing they saw
|
||||||
@@ -281,7 +342,8 @@ impl Db {
|
|||||||
fn open_configured(path: &Path) -> Result<Connection> {
|
fn open_configured(path: &Path) -> Result<Connection> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
// WAL: readers never block the single writer. NORMAL sync is the
|
// WAL: readers never block the single writer. NORMAL sync is the
|
||||||
// standard durability/throughput tradeoff for WAL.
|
// standard durability/throughput tradeoff for WAL; the queue commits
|
||||||
|
// that need more raise it to FULL for themselves (`Durable`).
|
||||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
@@ -428,12 +490,96 @@ fn is_corrupt(e: &Error) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move a damaged database (and its WAL sidecars) out of the way so the next
|
/// The tables [`salvage`] copies out of a damaged database, in order, each
|
||||||
/// open starts from empty.
|
/// with the rows it keeps.
|
||||||
///
|
///
|
||||||
/// Renamed rather than deleted: this is a cache, but it is also the only
|
/// The queue goes with the node rows it names by local id: a create made here
|
||||||
/// evidence of what went wrong, and it costs one file the user can remove.
|
/// has no other record of its node, and a real node keeps its local id when its
|
||||||
fn quarantine_corrupt_db(path: &Path) -> Result<()> {
|
/// folder is listed again. Those rows come back unlisted, so their folders are
|
||||||
|
/// fetched again rather than trusted. Claims are cleared by [`Db::open`] as
|
||||||
|
/// after any other crash.
|
||||||
|
const SALVAGE: &[(&str, &str)] = &[
|
||||||
|
(
|
||||||
|
"nodes",
|
||||||
|
"lid IN (SELECT lid FROM old.pending_op WHERE lid IS NOT NULL
|
||||||
|
UNION SELECT parent_lid FROM old.pending_op WHERE parent_lid IS NOT NULL)
|
||||||
|
OR uid LIKE 'local~%'",
|
||||||
|
),
|
||||||
|
("pending_op", "1"),
|
||||||
|
("sync_folder", "1"),
|
||||||
|
("sync_entry", "1"),
|
||||||
|
("pins", "1"),
|
||||||
|
("own_sealed_rev", "1"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Copy what only this machine has out of the damaged database at `damaged`
|
||||||
|
/// into the fresh one `conn` just created. Returns how each table went.
|
||||||
|
///
|
||||||
|
/// Each table is copied in its own transaction, so a table SQLite cannot read
|
||||||
|
/// to the end is left out whole rather than half copied, and does not stop
|
||||||
|
/// the others. Only the columns both files have are copied, which covers a
|
||||||
|
/// file that broke in the middle of an upgrade. The damaged file is opened read
|
||||||
|
/// only and left as it was.
|
||||||
|
fn salvage(conn: &Connection, damaged: &Path) -> Vec<(&'static str, Result<usize>)> {
|
||||||
|
let uri = format!(
|
||||||
|
"file:{}?mode=ro",
|
||||||
|
damaged
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('%', "%25")
|
||||||
|
.replace('?', "%3f")
|
||||||
|
.replace('#', "%23")
|
||||||
|
);
|
||||||
|
if let Err(e) = conn.execute("ATTACH DATABASE ?1 AS old", [&uri]) {
|
||||||
|
let e = Error::Db(e);
|
||||||
|
tracing::error!(error = %e, "could not open the damaged database to salvage it");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let results = SALVAGE
|
||||||
|
.iter()
|
||||||
|
.map(|&(table, rows)| (table, salvage_table(conn, table, rows)))
|
||||||
|
.collect();
|
||||||
|
let _ = conn.execute("UPDATE nodes SET listed = 0", []);
|
||||||
|
if let Err(e) = conn.execute("DETACH DATABASE old", []) {
|
||||||
|
tracing::warn!(error = %e, "could not detach the damaged database");
|
||||||
|
}
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
fn salvage_table(conn: &Connection, table: &str, rows: &str) -> Result<usize> {
|
||||||
|
let columns = |schema: &str| -> Result<Vec<String>> {
|
||||||
|
let mut stmt = conn.prepare(&format!("PRAGMA {schema}.table_info({table})"))?;
|
||||||
|
let names = stmt
|
||||||
|
.query_map([], |r| r.get::<_, String>(1))?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(names)
|
||||||
|
};
|
||||||
|
let new = columns("main")?;
|
||||||
|
let shared: Vec<String> = columns("old")?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| new.contains(c))
|
||||||
|
.map(|c| format!("\"{c}\""))
|
||||||
|
.collect();
|
||||||
|
if shared.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let shared = shared.join(", ");
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
let copied = tx.execute(
|
||||||
|
&format!(
|
||||||
|
"INSERT OR IGNORE INTO main.{table} ({shared}) SELECT {shared} FROM old.{table} WHERE {rows}"
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(copied)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a damaged database (and its WAL sidecars) out of the way so the next
|
||||||
|
/// open starts from empty. Returns where the database itself went.
|
||||||
|
///
|
||||||
|
/// Renamed rather than deleted: it is the only evidence of what went wrong,
|
||||||
|
/// and [`salvage`] reads what only this machine has back out of it.
|
||||||
|
fn quarantine_corrupt_db(path: &Path) -> Result<PathBuf> {
|
||||||
let stamp = std::time::SystemTime::now()
|
let stamp = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
@@ -457,7 +603,9 @@ fn quarantine_corrupt_db(path: &Path) -> Result<()> {
|
|||||||
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
|
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
let mut kept = path.as_os_str().to_owned();
|
||||||
|
kept.push(format!(".corrupt-{stamp}"));
|
||||||
|
Ok(PathBuf::from(kept))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+129
-21
@@ -279,6 +279,45 @@ fn claimable() -> String {
|
|||||||
/// drain replaces it with the uid the server assigns.
|
/// drain replaces it with the uid the server assigns.
|
||||||
pub const LOCAL_VOLUME: &str = "local";
|
pub const LOCAL_VOLUME: &str = "local";
|
||||||
|
|
||||||
|
/// One place a queued create was sent, with when it first was.
|
||||||
|
struct SentTarget {
|
||||||
|
parent: String,
|
||||||
|
name: String,
|
||||||
|
at_ms: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `sent_to` column: `[[parent, name, at_ms], …]`, or `[[parent, name], …]`
|
||||||
|
/// as written before the time was kept. `None` when it does not parse.
|
||||||
|
fn parse_sent_to(json: Option<&str>) -> Option<Vec<SentTarget>> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum Entry {
|
||||||
|
At(String, String, i64),
|
||||||
|
Bare(String, String),
|
||||||
|
}
|
||||||
|
let Some(json) = json else {
|
||||||
|
return Some(Vec::new());
|
||||||
|
};
|
||||||
|
let entries: Vec<Entry> = serde_json::from_str(json).ok()?;
|
||||||
|
Some(
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| match entry {
|
||||||
|
Entry::At(parent, name, at) => SentTarget {
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
at_ms: Some(at),
|
||||||
|
},
|
||||||
|
Entry::Bare(parent, name) => SentTarget {
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
at_ms: None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The uid a node made on this machine goes by until Drive gives it one: its
|
/// The uid a node made on this machine goes by until Drive gives it one: its
|
||||||
/// row's local id under [`LOCAL_VOLUME`].
|
/// row's local id under [`LOCAL_VOLUME`].
|
||||||
///
|
///
|
||||||
@@ -541,7 +580,7 @@ impl Db {
|
|||||||
/// land in the order they were made (`docs/BUGS.md` B141). Should it fail,
|
/// land in the order they were made (`docs/BUGS.md` B141). Should it fail,
|
||||||
/// [`Db::record_op_failure`] or [`Db::release_op_claim`] drops it.
|
/// [`Db::record_op_failure`] or [`Db::release_op_claim`] drops it.
|
||||||
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
|
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.durable()?;
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
let superseded: Option<String> = if op_supersedes(&op.kind) {
|
let superseded: Option<String> = if op_supersedes(&op.kind) {
|
||||||
let lid = node_lid(&tx, &op.uid)?;
|
let lid = node_lid(&tx, &op.uid)?;
|
||||||
@@ -598,7 +637,7 @@ impl Db {
|
|||||||
name: &str,
|
name: &str,
|
||||||
created_at: i64,
|
created_at: i64,
|
||||||
) -> Result<(i64, Vec<String>)> {
|
) -> Result<(i64, Vec<String>)> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.durable()?;
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
let blobs = drop_doomed_ops(&tx, uid)?;
|
let blobs = drop_doomed_ops(&tx, uid)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
@@ -628,7 +667,7 @@ impl Db {
|
|||||||
blob_path: &str,
|
blob_path: &str,
|
||||||
meta_json: &str,
|
meta_json: &str,
|
||||||
) -> Result<Option<AttachedBlob>> {
|
) -> Result<Option<AttachedBlob>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.durable()?;
|
||||||
let lid = node_lid(&conn, uid)?;
|
let lid = node_lid(&conn, uid)?;
|
||||||
let existing: Option<(i64, Option<String>)> = conn
|
let existing: Option<(i64, Option<String>)> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -691,7 +730,7 @@ impl Db {
|
|||||||
rewrite: impl FnOnce(&str) -> Option<String>,
|
rewrite: impl FnOnce(&str) -> Option<String>,
|
||||||
) -> Result<CreateRetired> {
|
) -> Result<CreateRetired> {
|
||||||
let (local, real) = (landing.local, landing.real);
|
let (local, real) = (landing.local, landing.real);
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.durable()?;
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
let row: Option<CreateRow> = tx
|
let row: Option<CreateRow> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -808,12 +847,19 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record that queued create `id` is being sent to `parent` under `name`.
|
/// Record that queued create `id` is being sent to `parent` under `name`
|
||||||
|
/// at `at_ms`.
|
||||||
///
|
///
|
||||||
/// A rename while the op waits rewrites its target, but the file a lost
|
/// A rename while the op waits rewrites its target, but the file a lost
|
||||||
/// answer left stays where it was sent. The retry and the trash look
|
/// answer left stays where it was sent. The retry and the trash look
|
||||||
/// there too (`docs/BUGS.md` B184).
|
/// there too (`docs/BUGS.md` B184). A target sent before keeps its first
|
||||||
pub fn note_create_target(&self, id: i64, parent: &str, name: &str) -> Result<()> {
|
/// time.
|
||||||
|
///
|
||||||
|
/// A record that does not parse is left as it is rather than replaced
|
||||||
|
/// with this one target: the earlier ones are what the duplicate checks
|
||||||
|
/// look for. Without them a lost answer forks a conflict copy, which
|
||||||
|
/// loses nothing.
|
||||||
|
pub fn note_create_target(&self, id: i64, parent: &str, name: &str, at_ms: i64) -> Result<()> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
let sent: Option<String> = tx
|
let sent: Option<String> = tx
|
||||||
@@ -822,15 +868,26 @@ impl Db {
|
|||||||
})
|
})
|
||||||
.optional()?
|
.optional()?
|
||||||
.flatten();
|
.flatten();
|
||||||
let mut targets: Vec<(String, String)> = sent
|
let Some(mut targets) = parse_sent_to(sent.as_deref()) else {
|
||||||
.and_then(|json| serde_json::from_str(&json).ok())
|
tracing::warn!(
|
||||||
.unwrap_or_default();
|
id,
|
||||||
let target = (parent.to_string(), name.to_string());
|
"a queued create's send record does not parse; keeping it"
|
||||||
if !targets.contains(&target) {
|
);
|
||||||
targets.push(target);
|
return Ok(());
|
||||||
|
};
|
||||||
|
if !targets.iter().any(|t| t.parent == parent && t.name == name) {
|
||||||
|
targets.push(SentTarget {
|
||||||
|
parent: parent.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
at_ms: Some(at_ms),
|
||||||
|
});
|
||||||
|
let json: Vec<(&str, &str, i64)> = targets
|
||||||
|
.iter()
|
||||||
|
.map(|t| (t.parent.as_str(), t.name.as_str(), t.at_ms.unwrap_or(0)))
|
||||||
|
.collect();
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE pending_op SET sent_to = ?2 WHERE id = ?1",
|
"UPDATE pending_op SET sent_to = ?2 WHERE id = ?1",
|
||||||
params![id, serde_json::to_string(&targets)?],
|
params![id, serde_json::to_string(&json)?],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
@@ -840,6 +897,24 @@ impl Db {
|
|||||||
/// The parent and name queued op `id` was sent under as a create, in the
|
/// The parent and name queued op `id` was sent under as a create, in the
|
||||||
/// order it was ([`Db::note_create_target`]).
|
/// order it was ([`Db::note_create_target`]).
|
||||||
pub fn create_targets(&self, id: i64) -> Result<Vec<(String, String)>> {
|
pub fn create_targets(&self, id: i64) -> Result<Vec<(String, String)>> {
|
||||||
|
Ok(self
|
||||||
|
.sent_targets(id)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| (t.parent, t.name))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When queued create `id` was first sent, if that was recorded. Rows
|
||||||
|
/// written before the time was kept have none.
|
||||||
|
pub fn create_first_sent(&self, id: i64) -> Result<Option<i64>> {
|
||||||
|
Ok(self
|
||||||
|
.sent_targets(id)?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|t| t.at_ms.filter(|&at| at > 0))
|
||||||
|
.min())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sent_targets(&self, id: i64) -> Result<Vec<SentTarget>> {
|
||||||
let sent: Option<String> = self
|
let sent: Option<String> = self
|
||||||
.conn
|
.conn
|
||||||
.lock()
|
.lock()
|
||||||
@@ -848,9 +923,7 @@ impl Db {
|
|||||||
})
|
})
|
||||||
.optional()?
|
.optional()?
|
||||||
.flatten();
|
.flatten();
|
||||||
Ok(sent
|
Ok(parse_sent_to(sent.as_deref()).unwrap_or_default())
|
||||||
.and_then(|json| serde_json::from_str(&json).ok())
|
|
||||||
.unwrap_or_default())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The SHA-1 of each blob queued op `id` sent as a create
|
/// The SHA-1 of each blob queued op `id` sent as a create
|
||||||
@@ -898,6 +971,27 @@ impl Db {
|
|||||||
Ok(n > 0)
|
Ok(n > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the sidecar of op `id`, but only while it still holds the blob
|
||||||
|
/// at `blob_path`.
|
||||||
|
///
|
||||||
|
/// The drain fills an incomplete blob in place and then records it as
|
||||||
|
/// whole, so a retry after a lost answer can recognise its own bytes on the
|
||||||
|
/// server. A write that closed meanwhile has pointed the row at a newer
|
||||||
|
/// blob with its own sidecar; that one must not be overwritten. Returns
|
||||||
|
/// false when the row is gone or holds another blob.
|
||||||
|
pub fn update_op_meta_for_blob(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
blob_path: &str,
|
||||||
|
meta_json: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let n = self.conn.lock().execute(
|
||||||
|
"UPDATE pending_op SET meta_json = ?3 WHERE id = ?1 AND blob_path = ?2",
|
||||||
|
params![id, blob_path, meta_json],
|
||||||
|
)?;
|
||||||
|
Ok(n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Point a queued create at a new parent and name, for a node renamed or
|
/// Point a queued create at a new parent and name, for a node renamed or
|
||||||
/// moved before it ever reached the server.
|
/// moved before it ever reached the server.
|
||||||
///
|
///
|
||||||
@@ -911,7 +1005,7 @@ impl Db {
|
|||||||
/// name it no longer wants, such as one held by a queued change. A parked
|
/// name it no longer wants, such as one held by a queued change. A parked
|
||||||
/// create stays parked.
|
/// create stays parked.
|
||||||
pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result<bool> {
|
pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.durable()?;
|
||||||
let lid = node_lid(&conn, uid)?;
|
let lid = node_lid(&conn, uid)?;
|
||||||
let parent_lid = node_lid(&conn, parent_uid)?;
|
let parent_lid = node_lid(&conn, parent_uid)?;
|
||||||
let n = conn.execute(
|
let n = conn.execute(
|
||||||
@@ -1389,12 +1483,26 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// [`retry_op_now`](Self::retry_op_now) for every op that has failed at
|
/// [`retry_op_now`](Self::retry_op_now) for every op that has failed at
|
||||||
/// least once. Returns how many rows changed.
|
/// least once, for the user's "Retry all". Returns how many rows changed.
|
||||||
pub fn retry_failed_ops_now(&self, now: i64) -> Result<usize> {
|
pub fn retry_failed_ops_now(&self, now: i64) -> Result<usize> {
|
||||||
|
self.retry_ops_now(now, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`retry_failed_ops_now`](Self::retry_failed_ops_now) for the ops Drive
|
||||||
|
/// did not refuse, for a reconnect. A link coming back changes nothing
|
||||||
|
/// about a full quota or a missing permission, so those keep their backoff
|
||||||
|
/// instead of all being sent again at once.
|
||||||
|
pub fn retry_unrefused_ops_now(&self, now: i64) -> Result<usize> {
|
||||||
|
self.retry_ops_now(now, "AND issue IS NULL")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_ops_now(&self, now: i64, filter: &str) -> Result<usize> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
let changed = conn.execute(
|
let changed = conn.execute(
|
||||||
"UPDATE pending_op SET next_attempt_at = ?1
|
&format!(
|
||||||
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2",
|
"UPDATE pending_op SET next_attempt_at = ?1
|
||||||
|
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2 {filter}"
|
||||||
|
),
|
||||||
params![now, PARK_UNTIL],
|
params![now, PARK_UNTIL],
|
||||||
)?;
|
)?;
|
||||||
Ok(changed)
|
Ok(changed)
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ pub struct StoredSyncEntry {
|
|||||||
pub local_mtime_ns: Option<i64>,
|
pub local_mtime_ns: Option<i64>,
|
||||||
pub remote_rev: Option<String>,
|
pub remote_rev: Option<String>,
|
||||||
pub remote_hash: Option<String>,
|
pub remote_hash: Option<String>,
|
||||||
|
/// The id of the remote revision this row was synced at. A mirror upload
|
||||||
|
/// checks it before it supersedes what Drive holds, because another client
|
||||||
|
/// may have committed a revision since (`docs/BUGS.md` B249). `None` on a
|
||||||
|
/// row written before schema 44, or by a pass that did not learn it; the
|
||||||
|
/// check then falls back to `remote_rev` and `remote_hash`.
|
||||||
|
pub remote_revision_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
@@ -188,7 +194,7 @@ impl Db {
|
|||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||||
local_mtime_ns
|
local_mtime_ns, remote_revision_id
|
||||||
FROM sync_entry WHERE folder_id = ?1",
|
FROM sync_entry WHERE folder_id = ?1",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![folder_id], |r| {
|
let rows = stmt.query_map(params![folder_id], |r| {
|
||||||
@@ -200,6 +206,7 @@ impl Db {
|
|||||||
remote_rev: r.get(4)?,
|
remote_rev: r.get(4)?,
|
||||||
remote_hash: r.get(5)?,
|
remote_hash: r.get(5)?,
|
||||||
local_mtime_ns: r.get(6)?,
|
local_mtime_ns: r.get(6)?,
|
||||||
|
remote_revision_id: r.get(7)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
@@ -238,15 +245,16 @@ impl Db {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sync_entry
|
"INSERT INTO sync_entry
|
||||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||||
remote_hash, local_mtime_ns)
|
remote_hash, local_mtime_ns, remote_revision_id)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||||
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
||||||
remote_uid = excluded.remote_uid,
|
remote_uid = excluded.remote_uid,
|
||||||
local_mtime = excluded.local_mtime,
|
local_mtime = excluded.local_mtime,
|
||||||
local_size = excluded.local_size,
|
local_size = excluded.local_size,
|
||||||
remote_rev = excluded.remote_rev,
|
remote_rev = excluded.remote_rev,
|
||||||
remote_hash = excluded.remote_hash,
|
remote_hash = excluded.remote_hash,
|
||||||
local_mtime_ns = excluded.local_mtime_ns",
|
local_mtime_ns = excluded.local_mtime_ns,
|
||||||
|
remote_revision_id = excluded.remote_revision_id",
|
||||||
params![
|
params![
|
||||||
folder_id,
|
folder_id,
|
||||||
e.rel_path,
|
e.rel_path,
|
||||||
@@ -256,6 +264,7 @@ impl Db {
|
|||||||
e.remote_rev,
|
e.remote_rev,
|
||||||
e.remote_hash,
|
e.remote_hash,
|
||||||
e.local_mtime_ns,
|
e.local_mtime_ns,
|
||||||
|
e.remote_revision_id,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -306,7 +315,7 @@ impl Db {
|
|||||||
{
|
{
|
||||||
let mut select = tx.prepare(
|
let mut select = tx.prepare(
|
||||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||||
local_mtime_ns
|
local_mtime_ns, remote_revision_id
|
||||||
FROM sync_entry
|
FROM sync_entry
|
||||||
WHERE folder_id = ?1
|
WHERE folder_id = ?1
|
||||||
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
|
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
|
||||||
@@ -321,6 +330,7 @@ impl Db {
|
|||||||
remote_rev: r.get(4)?,
|
remote_rev: r.get(4)?,
|
||||||
remote_hash: r.get(5)?,
|
remote_hash: r.get(5)?,
|
||||||
local_mtime_ns: r.get(6)?,
|
local_mtime_ns: r.get(6)?,
|
||||||
|
remote_revision_id: r.get(7)?,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
@@ -332,8 +342,8 @@ impl Db {
|
|||||||
let mut insert = tx.prepare(
|
let mut insert = tx.prepare(
|
||||||
"INSERT OR REPLACE INTO sync_entry
|
"INSERT OR REPLACE INTO sync_entry
|
||||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||||
remote_hash, local_mtime_ns)
|
remote_hash, local_mtime_ns, remote_revision_id)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
)?;
|
)?;
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]);
|
let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]);
|
||||||
@@ -346,6 +356,7 @@ impl Db {
|
|||||||
row.remote_rev,
|
row.remote_rev,
|
||||||
row.remote_hash,
|
row.remote_hash,
|
||||||
row.local_mtime_ns,
|
row.local_mtime_ns,
|
||||||
|
row.remote_revision_id,
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1283,6 +1283,90 @@ fn a_corrupt_database_is_moved_aside_and_rebuilt() {
|
|||||||
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A rebuilt database must not lose what only this machine has: writes Drive
|
||||||
|
/// has not received, the mirror baselines and the pins (`docs/BUGS.md` B216).
|
||||||
|
#[test]
|
||||||
|
fn a_rebuild_keeps_the_queue_baselines_and_pins_of_the_damaged_file() {
|
||||||
|
let id = now_test_id();
|
||||||
|
let damaged =
|
||||||
|
std::env::temp_dir().join(format!("pdfs-salvage-old-{}-{id}.db", std::process::id()));
|
||||||
|
let fresh =
|
||||||
|
std::env::temp_dir().join(format!("pdfs-salvage-new-{}-{id}.db", std::process::id()));
|
||||||
|
|
||||||
|
let db = Db::open(&damaged).unwrap();
|
||||||
|
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||||
|
db.upsert_node(&file("listed", "root", "listed.txt", 1))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&file("queued", "root", "queued.txt", 2))
|
||||||
|
.unwrap();
|
||||||
|
db.set_listed(&uid("root"), true).unwrap();
|
||||||
|
db.enqueue_op(&PendingOp {
|
||||||
|
id: 0,
|
||||||
|
kind: OP_REVISION.to_string(),
|
||||||
|
uid: uid("queued").to_string(),
|
||||||
|
parent_uid: None,
|
||||||
|
name: None,
|
||||||
|
blob_path: Some("/staging/queued".into()),
|
||||||
|
meta_json: None,
|
||||||
|
created_at: 1,
|
||||||
|
attempts: 3,
|
||||||
|
last_error: None,
|
||||||
|
next_attempt_at: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let folder_id = db
|
||||||
|
.sync_folder_add("/home/me/Docs", "vol~docs", "share")
|
||||||
|
.unwrap();
|
||||||
|
db.sync_entry_upsert(
|
||||||
|
folder_id,
|
||||||
|
&StoredSyncEntry {
|
||||||
|
rel_path: "a.txt".into(),
|
||||||
|
remote_uid: Some("vol~a".into()),
|
||||||
|
local_mtime: 5,
|
||||||
|
local_mtime_ns: None,
|
||||||
|
local_size: 6,
|
||||||
|
remote_rev: Some("7".into()),
|
||||||
|
remote_hash: Some("8".into()),
|
||||||
|
remote_revision_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
db.pin_add("vol~pinned", "/pinned", true).unwrap();
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
let db = Db::open(&fresh).unwrap();
|
||||||
|
let results = salvage(&db.conn.lock(), &damaged);
|
||||||
|
assert!(results.iter().all(|(_, r)| r.is_ok()), "{results:?}");
|
||||||
|
|
||||||
|
let ops = db.pending_ops().unwrap();
|
||||||
|
assert_eq!(ops.len(), 1);
|
||||||
|
assert_eq!(ops[0].uid, uid("queued").to_string());
|
||||||
|
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/queued"));
|
||||||
|
// The op's node came with it; the rest of the tree is fetched again.
|
||||||
|
assert!(
|
||||||
|
db.node_by_uid(&uid("queued").to_string())
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
db.node_by_uid(&uid("listed").to_string())
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(db.sync_entries(folder_id).unwrap().len(), 1);
|
||||||
|
assert!(db.is_pinned("vol~pinned").unwrap());
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
for path in [&damaged, &fresh] {
|
||||||
|
for suffix in ["", "-wal", "-shm"] {
|
||||||
|
let mut file = path.as_os_str().to_owned();
|
||||||
|
file.push(suffix);
|
||||||
|
let _ = std::fs::remove_file(std::path::PathBuf::from(file));
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry {
|
fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry {
|
||||||
LocalEntry {
|
LocalEntry {
|
||||||
path: path.into(),
|
path: path.into(),
|
||||||
@@ -1346,6 +1430,77 @@ fn rescanning_an_unchanged_file_keeps_exactly_one_index_entry() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A scan that stopped early or lost a batch did not see every file, so it
|
||||||
|
/// must not prune the ones it missed (`docs/BUGS.md` B217).
|
||||||
|
#[test]
|
||||||
|
fn a_partial_local_scan_prunes_nothing() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let g = db.local_begin_scan().unwrap();
|
||||||
|
db.local_upsert_batch(
|
||||||
|
g,
|
||||||
|
&[
|
||||||
|
local("/home/u/a.txt", "a.txt", false),
|
||||||
|
local("/home/u/b.txt", "b.txt", false),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(db.local_finish_scan(g, 1).unwrap(), 2);
|
||||||
|
|
||||||
|
let g = db.local_begin_scan().unwrap();
|
||||||
|
db.local_upsert_batch(g, &[local("/home/u/a.txt", "a.txt", false)])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(db.local_finish_partial_scan(2).unwrap(), 2);
|
||||||
|
assert_eq!(db.search_local("b.txt", 10).unwrap().len(), 1);
|
||||||
|
assert_eq!(db.local_indexed_at().unwrap(), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deleted entries pile up in an FTS5 index until it is compacted. The daily
|
||||||
|
/// compaction must leave both indexes whole and searchable, and must not run
|
||||||
|
/// again the same day (`docs/BUGS.md` B217).
|
||||||
|
#[test]
|
||||||
|
fn compacting_the_search_indexes_keeps_them_whole_and_runs_once_a_day() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||||
|
db.upsert_node(&file("f1", "root", "quarterly-report.pdf", 1))
|
||||||
|
.unwrap();
|
||||||
|
for round in 0..20 {
|
||||||
|
let g = db.local_begin_scan().unwrap();
|
||||||
|
let name = format!("churn-{round}.txt");
|
||||||
|
db.local_upsert_batch(
|
||||||
|
g,
|
||||||
|
&[
|
||||||
|
local("/home/u/keep.txt", "keep.txt", false),
|
||||||
|
local(&format!("/home/u/{name}"), &name, false),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Far in the past, so no scan compacts on its own.
|
||||||
|
db.local_finish_scan(g, 0).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let day = 24 * 60 * 60;
|
||||||
|
db.compact_search_indexes_if_due(day).unwrap();
|
||||||
|
{
|
||||||
|
let conn = db.conn.lock();
|
||||||
|
for table in ["local_fts", "nodes_fts"] {
|
||||||
|
conn.execute(
|
||||||
|
&format!("INSERT INTO {table}({table}) VALUES('integrity-check')"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(db.search_local("keep", 10).unwrap().len(), 1);
|
||||||
|
assert_eq!(db.search_local("churn-19", 10).unwrap().len(), 1);
|
||||||
|
assert!(db.search_local("churn-3.txt", 10).unwrap().is_empty());
|
||||||
|
assert_eq!(db.search("quarterly", 10).unwrap().len(), 1);
|
||||||
|
let stamp = |db: &Db| db.state_str("fts_compacted_at").unwrap();
|
||||||
|
assert_eq!(stamp(&db).as_deref(), Some(day.to_string().as_str()));
|
||||||
|
|
||||||
|
db.compact_search_indexes_if_due(day + 60).unwrap();
|
||||||
|
assert_eq!(stamp(&db).as_deref(), Some(day.to_string().as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
/// Queries below the trigram minimum still match, via the `LIKE` fallback.
|
/// Queries below the trigram minimum still match, via the `LIKE` fallback.
|
||||||
#[test]
|
#[test]
|
||||||
fn local_search_short_query_like_fallback() {
|
fn local_search_short_query_like_fallback() {
|
||||||
@@ -2198,6 +2353,100 @@ fn refuses_a_schema_newer_than_the_running_build() {
|
|||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Queueing a write syncs it to disk, and leaves the writer at NORMAL after.
|
||||||
|
///
|
||||||
|
/// The op row is what makes a staged blob reachable once `close()` has
|
||||||
|
/// reported success, so its commit must survive a power loss. Everything else
|
||||||
|
/// the writer commits is metadata the next listing re-fetches, and pays only
|
||||||
|
/// NORMAL's cost.
|
||||||
|
#[test]
|
||||||
|
fn queueing_a_write_commits_at_full_sync_and_restores_normal() {
|
||||||
|
let path = std::env::temp_dir().join(format!("pdfs-db-sync-{}.db", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
let level = |db: &Db| -> i64 {
|
||||||
|
db.conn
|
||||||
|
.lock()
|
||||||
|
.query_row("PRAGMA synchronous", [], |r| r.get(0))
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
// 1 = NORMAL, 2 = FULL.
|
||||||
|
assert_eq!(level(&db), 1);
|
||||||
|
{
|
||||||
|
let durable = db.durable().unwrap();
|
||||||
|
let during: i64 = durable
|
||||||
|
.query_row("PRAGMA synchronous", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(during, 2);
|
||||||
|
}
|
||||||
|
assert_eq!(level(&db), 1);
|
||||||
|
|
||||||
|
db.enqueue_op(&PendingOp {
|
||||||
|
id: 0,
|
||||||
|
kind: OP_TRASH.to_string(),
|
||||||
|
uid: uid("a").to_string(),
|
||||||
|
parent_uid: None,
|
||||||
|
name: Some("a".to_string()),
|
||||||
|
blob_path: None,
|
||||||
|
meta_json: None,
|
||||||
|
created_at: 1,
|
||||||
|
attempts: 0,
|
||||||
|
last_error: None,
|
||||||
|
next_attempt_at: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(level(&db), 1);
|
||||||
|
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||||||
|
drop(db);
|
||||||
|
for suffix in ["", "-wal", "-shm"] {
|
||||||
|
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A gap-filled blob is recorded as whole only while the op still holds it.
|
||||||
|
///
|
||||||
|
/// A write that closes during the drain points the row at a newer blob with
|
||||||
|
/// its own sidecar. Overwriting that sidecar with the older blob's would
|
||||||
|
/// describe bytes the row no longer holds.
|
||||||
|
#[test]
|
||||||
|
fn marking_a_blob_whole_leaves_a_newer_blob_alone() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let op = PendingOp {
|
||||||
|
id: 0,
|
||||||
|
kind: OP_REVISION.to_string(),
|
||||||
|
uid: uid("a").to_string(),
|
||||||
|
parent_uid: None,
|
||||||
|
name: None,
|
||||||
|
blob_path: Some("/staging/first".to_string()),
|
||||||
|
meta_json: Some(r#"{"complete":false}"#.to_string()),
|
||||||
|
created_at: 1,
|
||||||
|
attempts: 0,
|
||||||
|
last_error: None,
|
||||||
|
next_attempt_at: 0,
|
||||||
|
};
|
||||||
|
db.enqueue_op(&op).unwrap();
|
||||||
|
let id = db.pending_ops().unwrap()[0].id;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
db.update_op_meta_for_blob(id, "/staging/first", r#"{"complete":true}"#)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
db.pending_ops().unwrap()[0].meta_json.as_deref(),
|
||||||
|
Some(r#"{"complete":true}"#)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!db.update_op_meta_for_blob(id, "/staging/older", r#"{"stale":true}"#)
|
||||||
|
.unwrap(),
|
||||||
|
"the row holds another blob"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
db.pending_ops().unwrap()[0].meta_json.as_deref(),
|
||||||
|
Some(r#"{"complete":true}"#)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A queued write whose baseline is restamped must keep everything else.
|
/// A queued write whose baseline is restamped must keep everything else.
|
||||||
///
|
///
|
||||||
/// The restamp happens after our *own* upload seals a new revision under a
|
/// The restamp happens after our *own* upload seals a new revision under a
|
||||||
@@ -3927,15 +4176,15 @@ fn what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(db.create_sent(id).unwrap().is_empty());
|
assert!(db.create_sent(id).unwrap().is_empty());
|
||||||
assert!(db.create_targets(id).unwrap().is_empty());
|
assert!(db.create_targets(id).unwrap().is_empty());
|
||||||
db.note_create_target(id, &parent, "c").unwrap();
|
db.note_create_target(id, &parent, "c", 10).unwrap();
|
||||||
db.note_create_target(id, &parent, "c").unwrap();
|
db.note_create_target(id, &parent, "c", 20).unwrap();
|
||||||
db.note_create_sent(id, "aaaa").unwrap();
|
db.note_create_sent(id, "aaaa").unwrap();
|
||||||
db.note_create_sent(id, "aaaa").unwrap();
|
db.note_create_sent(id, "aaaa").unwrap();
|
||||||
db.record_op_failure(id, "no answer", 0).unwrap();
|
db.record_op_failure(id, "no answer", 0).unwrap();
|
||||||
db.attach_blob_to_create("local~sent", "/staging/second", "{}")
|
db.attach_blob_to_create("local~sent", "/staging/second", "{}")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("the create is still queued");
|
.expect("the create is still queued");
|
||||||
db.note_create_target(id, &parent, "d").unwrap();
|
db.note_create_target(id, &parent, "d", 30).unwrap();
|
||||||
db.note_create_sent(id, "bbbb").unwrap();
|
db.note_create_sent(id, "bbbb").unwrap();
|
||||||
db.record_op_failure(id, "no answer", 0).unwrap();
|
db.record_op_failure(id, "no answer", 0).unwrap();
|
||||||
db.delete_ops_for_uid("local~sent").unwrap();
|
db.delete_ops_for_uid("local~sent").unwrap();
|
||||||
@@ -3958,6 +4207,73 @@ fn what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart() {
|
|||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A queued create knows when it first went out, which is where the window
|
||||||
|
/// for adopting a node its lost answer left starts. Records written before
|
||||||
|
/// the time was kept still list their targets, and one that does not parse is
|
||||||
|
/// not replaced by the next target.
|
||||||
|
#[test]
|
||||||
|
fn a_create_remembers_when_it_was_first_sent() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let parent = uid("parent").to_string();
|
||||||
|
let (id, _) = db
|
||||||
|
.enqueue_op(&PendingOp {
|
||||||
|
id: 0,
|
||||||
|
kind: OP_CREATE.to_string(),
|
||||||
|
uid: "local~first".to_string(),
|
||||||
|
parent_uid: Some(parent.clone()),
|
||||||
|
name: Some("a.txt".to_string()),
|
||||||
|
blob_path: None,
|
||||||
|
meta_json: None,
|
||||||
|
created_at: 1,
|
||||||
|
attempts: 0,
|
||||||
|
last_error: None,
|
||||||
|
next_attempt_at: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(db.create_first_sent(id).unwrap(), None, "never sent");
|
||||||
|
db.note_create_target(id, &parent, "a.txt", 500).unwrap();
|
||||||
|
db.note_create_target(id, &parent, "a.txt", 900).unwrap();
|
||||||
|
db.note_create_target(id, &parent, "b.txt", 700).unwrap();
|
||||||
|
assert_eq!(db.create_first_sent(id).unwrap(), Some(500));
|
||||||
|
|
||||||
|
let set = |json: &str| {
|
||||||
|
db.conn
|
||||||
|
.lock()
|
||||||
|
.execute(
|
||||||
|
"UPDATE pending_op SET sent_to = ?2 WHERE id = ?1",
|
||||||
|
rusqlite::params![id, json],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
};
|
||||||
|
set(&format!(r#"[["{parent}","old.txt"]]"#));
|
||||||
|
assert_eq!(
|
||||||
|
db.create_targets(id).unwrap(),
|
||||||
|
vec![(parent.clone(), "old.txt".to_string())]
|
||||||
|
);
|
||||||
|
assert_eq!(db.create_first_sent(id).unwrap(), None);
|
||||||
|
db.note_create_target(id, &parent, "a.txt", 900).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.create_targets(id).unwrap().len(),
|
||||||
|
2,
|
||||||
|
"the old target stays"
|
||||||
|
);
|
||||||
|
assert_eq!(db.create_first_sent(id).unwrap(), Some(900));
|
||||||
|
|
||||||
|
set("not json");
|
||||||
|
db.note_create_target(id, &parent, "c.txt", 1000).unwrap();
|
||||||
|
let raw: String = db
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.query_row("SELECT sent_to FROM pending_op WHERE id = ?1", [id], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
raw, "not json",
|
||||||
|
"an unreadable record is kept, not replaced"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A write after a create failed clears its attempts, so the next try goes
|
/// A write after a create failed clears its attempts, so the next try goes
|
||||||
/// out at once. The create was still sent: deleting the file then left no
|
/// out at once. The create was still sent: deleting the file then left no
|
||||||
/// trash, and the file a lost answer made stayed on Drive under the name it
|
/// trash, and the file a lost answer made stayed on Drive under the name it
|
||||||
@@ -3981,7 +4297,7 @@ fn a_create_rewritten_after_it_failed_still_leaves_a_trash_when_deleted() {
|
|||||||
next_attempt_at: 0,
|
next_attempt_at: 0,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
db.note_create_target(id, &parent, "a.txt").unwrap();
|
db.note_create_target(id, &parent, "a.txt", 10).unwrap();
|
||||||
db.note_create_sent(id, "aaaa").unwrap();
|
db.note_create_sent(id, "aaaa").unwrap();
|
||||||
db.record_op_failure(id, "no answer", 0).unwrap();
|
db.record_op_failure(id, "no answer", 0).unwrap();
|
||||||
db.attach_blob_to_create("local~sent", "/staging/second", "{}")
|
db.attach_blob_to_create("local~sent", "/staging/second", "{}")
|
||||||
@@ -5028,6 +5344,7 @@ fn baseline_row(rel: &str) -> StoredSyncEntry {
|
|||||||
local_size: 7,
|
local_size: 7,
|
||||||
remote_rev: Some("1700000000".into()),
|
remote_rev: Some("1700000000".into()),
|
||||||
remote_hash: Some("7".into()),
|
remote_hash: Some("7".into()),
|
||||||
|
remote_revision_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5122,6 +5439,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
|||||||
local_size: 2,
|
local_size: 2,
|
||||||
remote_rev: Some("3".into()),
|
remote_rev: Some("3".into()),
|
||||||
remote_hash: Some("2".into()),
|
remote_hash: Some("2".into()),
|
||||||
|
remote_revision_id: None,
|
||||||
local_mtime_ns: None,
|
local_mtime_ns: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -5139,6 +5457,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
|||||||
local_size: 2,
|
local_size: 2,
|
||||||
remote_rev: Some("3".into()),
|
remote_rev: Some("3".into()),
|
||||||
remote_hash: Some("2".into()),
|
remote_hash: Some("2".into()),
|
||||||
|
remote_revision_id: None,
|
||||||
local_mtime_ns: None,
|
local_mtime_ns: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -6316,6 +6635,71 @@ fn migration_v27_adds_the_access_deferral_column_to_a_v26_queue() {
|
|||||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V44 adds the remote revision a baseline row was synced at. A row carried
|
||||||
|
/// over from V43 has none, and the mirror then compares the modification time
|
||||||
|
/// and size as before, so upgrading changes nothing about what is in step
|
||||||
|
/// (`docs/BUGS.md` B249).
|
||||||
|
#[test]
|
||||||
|
fn migration_v44_adds_the_synced_revision_without_disturbing_a_v43_baseline() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-db-v43-fixture-{}-{}.db",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
let row = StoredSyncEntry {
|
||||||
|
rel_path: "notes.txt".into(),
|
||||||
|
remote_uid: Some("vol~notes".into()),
|
||||||
|
local_mtime: 1_700_000_000,
|
||||||
|
local_mtime_ns: Some(1_700_000_000_123_456_789),
|
||||||
|
local_size: 42,
|
||||||
|
remote_rev: Some("1700000000".into()),
|
||||||
|
remote_hash: Some("42".into()),
|
||||||
|
remote_revision_id: Some("rev-1".into()),
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
let folder = db
|
||||||
|
.sync_folder_add("/home/me/Mirror", "vol~root", "share")
|
||||||
|
.unwrap();
|
||||||
|
db.sync_entry_upsert(folder, &row).unwrap();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Put the file back in the state a released V43 database was in.
|
||||||
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE sync_entry DROP COLUMN remote_revision_id;
|
||||||
|
UPDATE sync_state SET value = '43' WHERE key = 'schema_version';",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
let entries = db.sync_entries(1).unwrap();
|
||||||
|
let entry = entries.get("notes.txt").expect("the baseline row survives");
|
||||||
|
assert_eq!(
|
||||||
|
entry,
|
||||||
|
&StoredSyncEntry {
|
||||||
|
remote_revision_id: None,
|
||||||
|
..row.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the column is live, including through a move of the row.
|
||||||
|
db.sync_entry_upsert(1, &row).unwrap();
|
||||||
|
db.sync_entries_move(1, "notes.txt", 1, "moved.txt")
|
||||||
|
.unwrap();
|
||||||
|
let entries = db.sync_entries(1).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
entries["moved.txt"].remote_revision_id.as_deref(),
|
||||||
|
Some("rev-1")
|
||||||
|
);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
/// A mirror baseline carried over from V27 keeps its rows, and each one reads
|
/// A mirror baseline carried over from V27 keeps its rows, and each one reads
|
||||||
/// back with no nanosecond time. That `None` is the whole point: it makes the
|
/// back with no nanosecond time. That `None` is the whole point: it makes the
|
||||||
/// comparison fall back to whole seconds for rows written before the column
|
/// comparison fall back to whole seconds for rows written before the column
|
||||||
@@ -6346,6 +6730,7 @@ fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline()
|
|||||||
local_size: 42,
|
local_size: 42,
|
||||||
remote_rev: Some("1700000000".into()),
|
remote_rev: Some("1700000000".into()),
|
||||||
remote_hash: Some("42".into()),
|
remote_hash: Some("42".into()),
|
||||||
|
remote_revision_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -7216,6 +7601,56 @@ fn a_refused_change_names_its_node_by_local_id() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name checks the drain makes before each op look the name up in an
|
||||||
|
/// index. Scanning the queue instead made a large offline batch quadratic.
|
||||||
|
#[test]
|
||||||
|
fn queue_name_checks_use_an_index() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let conn = db.conn.lock();
|
||||||
|
let plan = |sql: &str| -> String {
|
||||||
|
let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}")).unwrap();
|
||||||
|
stmt.query_map([], |r| r.get::<_, String>(3))
|
||||||
|
.unwrap()
|
||||||
|
.map(|row| row.unwrap())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ")
|
||||||
|
};
|
||||||
|
for sql in [
|
||||||
|
"SELECT 1 FROM pending_op WHERE kind = 'trash' AND name = 'a'",
|
||||||
|
"SELECT 1 FROM pending_op WHERE name = 'a' AND kind IN ('create', 'mkdir', 'rename')",
|
||||||
|
"SELECT 1 FROM pending_op WHERE kind = 'rename'",
|
||||||
|
] {
|
||||||
|
let plan = plan(sql);
|
||||||
|
assert!(plan.contains("pending_op_kind_name"), "{sql}: {plan}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reconnect moves up the ops that failed for want of a link. One Drive
|
||||||
|
/// refused, for quota or access, keeps its backoff: the link coming back
|
||||||
|
/// changes nothing about it. "Retry all" still moves both.
|
||||||
|
#[test]
|
||||||
|
fn a_reconnect_leaves_refused_ops_to_their_backoff() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let offline = queued_op(&db, "vol~a", 2, 90_000);
|
||||||
|
let refused = queued_op(&db, "vol~b", 2, 90_000);
|
||||||
|
db.set_op_issue(refused, Some("quota")).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(db.retry_unrefused_ops_now(5_000).unwrap(), 1);
|
||||||
|
let due = |id: i64| {
|
||||||
|
db.pending_ops()
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|op| op.id == id)
|
||||||
|
.unwrap()
|
||||||
|
.next_attempt_at
|
||||||
|
};
|
||||||
|
assert_eq!(due(offline), 5_000);
|
||||||
|
assert_eq!(due(refused), 90_000);
|
||||||
|
|
||||||
|
assert_eq!(db.retry_failed_ops_now(5_000).unwrap(), 1);
|
||||||
|
assert_eq!(due(refused), 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn retry_all_touches_only_ops_that_have_failed() {
|
fn retry_all_touches_only_ops_that_have_failed() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//! When a photo was taken, as its own EXIF tags say.
|
||||||
|
//!
|
||||||
|
//! The fallback for a photo nothing else dates: a Takeout photo without a
|
||||||
|
//! sidecar or a date in its name, and a photo uploaded from the app. Without
|
||||||
|
//! it the first was filed under the moment of the import and the second under
|
||||||
|
//! the file's modification time, which a copy resets.
|
||||||
|
|
||||||
|
use std::io::{BufRead, Seek};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::takeout::days_from_civil;
|
||||||
|
|
||||||
|
/// The capture time in the EXIF of the file at `path`, in epoch seconds.
|
||||||
|
pub fn capture_time_of(path: &Path) -> Option<i64> {
|
||||||
|
let file = std::fs::File::open(path).ok()?;
|
||||||
|
capture_time(&mut std::io::BufReader::new(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The capture time in the EXIF `reader` holds: `DateTimeOriginal`, then
|
||||||
|
/// `DateTimeDigitized`, each adjusted by its `OffsetTime…` tag when the camera
|
||||||
|
/// wrote one. Without an offset the time is read as UTC, as a date in a file
|
||||||
|
/// name is (`takeout::capture_time_from_name`).
|
||||||
|
pub fn capture_time(reader: &mut (impl BufRead + Seek)) -> Option<i64> {
|
||||||
|
let exif = exif::Reader::new().read_from_container(reader).ok()?;
|
||||||
|
let ascii = |tag: exif::Tag| -> Option<String> {
|
||||||
|
let field = exif.get_field(tag, exif::In::PRIMARY)?;
|
||||||
|
match &field.value {
|
||||||
|
exif::Value::Ascii(parts) => parts.first().map(|part| {
|
||||||
|
String::from_utf8_lossy(part)
|
||||||
|
.trim_matches('\0')
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
[
|
||||||
|
(exif::Tag::DateTimeOriginal, exif::Tag::OffsetTimeOriginal),
|
||||||
|
(exif::Tag::DateTimeDigitized, exif::Tag::OffsetTimeDigitized),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|(time, offset)| {
|
||||||
|
let local = parse_exif_time(&ascii(time)?)?;
|
||||||
|
let offset = ascii(offset).and_then(|o| parse_offset(&o)).unwrap_or(0);
|
||||||
|
Some(local - offset)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `YYYY:MM:DD HH:MM:SS` as epoch seconds, read as UTC. `None` for the
|
||||||
|
/// all-zero or blank value cameras write when they do not know.
|
||||||
|
fn parse_exif_time(text: &str) -> Option<i64> {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
if bytes.len() < 19 || bytes[4] != b':' || bytes[7] != b':' || bytes[13] != b':' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let number = |range: std::ops::Range<usize>| text.get(range)?.parse::<i64>().ok();
|
||||||
|
let (year, month, day) = (number(0..4)?, number(5..7)?, number(8..10)?);
|
||||||
|
let (hour, minute, second) = (number(11..13)?, number(14..16)?, number(17..19)?);
|
||||||
|
if !(1800..=9999).contains(&year)
|
||||||
|
|| !(1..=12).contains(&month)
|
||||||
|
|| !(1..=31).contains(&day)
|
||||||
|
|| hour > 23
|
||||||
|
|| minute > 59
|
||||||
|
|| second > 60
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `+HH:MM` or `-HH:MM` as seconds east of UTC.
|
||||||
|
fn parse_offset(text: &str) -> Option<i64> {
|
||||||
|
let sign = match text.as_bytes().first()? {
|
||||||
|
b'+' => 1,
|
||||||
|
b'-' => -1,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let (hours, minutes) = text.get(1..)?.split_once(':')?;
|
||||||
|
let (hours, minutes) = (hours.parse::<i64>().ok()?, minutes.parse::<i64>().ok()?);
|
||||||
|
(hours <= 14 && minutes < 60).then_some(sign * (hours * 3600 + minutes * 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_exif_time_reads_as_utc_and_its_offset_moves_it() {
|
||||||
|
// 2023-06-01 12:00:00 UTC.
|
||||||
|
let noon = 1_685_620_800;
|
||||||
|
assert_eq!(parse_exif_time("2023:06:01 12:00:00"), Some(noon));
|
||||||
|
assert_eq!(parse_offset("+02:00"), Some(7200));
|
||||||
|
assert_eq!(parse_offset("-05:30"), Some(-19_800));
|
||||||
|
assert_eq!(
|
||||||
|
parse_exif_time("2023:06:01 14:00:00").map(|t| t - parse_offset("+02:00").unwrap()),
|
||||||
|
Some(noon)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_exif_time_is_no_time() {
|
||||||
|
assert_eq!(parse_exif_time("0000:00:00 00:00:00"), None);
|
||||||
|
assert_eq!(parse_exif_time(" : : : : "), None);
|
||||||
|
assert_eq!(parse_exif_time(""), None);
|
||||||
|
assert_eq!(parse_offset("02:00"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_photo_is_dated_by_its_original_time_and_offset() {
|
||||||
|
let ascii = |tag, text: &str| exif::Field {
|
||||||
|
tag,
|
||||||
|
ifd_num: exif::In::PRIMARY,
|
||||||
|
value: exif::Value::Ascii(vec![text.as_bytes().to_vec()]),
|
||||||
|
};
|
||||||
|
let taken = ascii(exif::Tag::DateTimeOriginal, "2023:06:01 14:00:00");
|
||||||
|
let offset = ascii(exif::Tag::OffsetTimeOriginal, "+02:00");
|
||||||
|
let digitized = ascii(exif::Tag::DateTimeDigitized, "2024:01:01 00:00:00");
|
||||||
|
let mut writer = exif::experimental::Writer::new();
|
||||||
|
writer.push_field(&taken);
|
||||||
|
writer.push_field(&offset);
|
||||||
|
writer.push_field(&digitized);
|
||||||
|
let mut tiff = std::io::Cursor::new(Vec::new());
|
||||||
|
writer.write(&mut tiff, false).unwrap();
|
||||||
|
tiff.set_position(0);
|
||||||
|
assert_eq!(capture_time(&mut tiff), Some(1_685_620_800));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_without_exif_has_no_capture_time() {
|
||||||
|
let mut png = std::io::Cursor::new(b"\x89PNG\r\n\x1a\n".to_vec());
|
||||||
|
assert_eq!(capture_time(&mut png), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ pub mod config;
|
|||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod exiftime;
|
||||||
pub mod kernelkey;
|
pub mod kernelkey;
|
||||||
pub mod localindex;
|
pub mod localindex;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
@@ -23,6 +24,7 @@ pub mod service;
|
|||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod similar;
|
pub mod similar;
|
||||||
pub mod syncignore;
|
pub mod syncignore;
|
||||||
|
pub mod syncroot;
|
||||||
pub mod takeout;
|
pub mod takeout;
|
||||||
pub mod tray;
|
pub mod tray;
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const SKIP_PATH_SUFFIXES: &[&str] = &["go/pkg/mod"];
|
|||||||
|
|
||||||
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
||||||
/// database without bound. Reaching it stops the walk early.
|
/// database without bound. Reaching it stops the walk early.
|
||||||
const MAX_ENTRIES: usize = 400_000;
|
pub const MAX_ENTRIES: usize = 400_000;
|
||||||
|
|
||||||
/// Rows handed to the sink at a time. Large enough that the per-transaction cost
|
/// Rows handed to the sink at a time. Large enough that the per-transaction cost
|
||||||
/// amortises, small enough that the writer lock is released often (FUSE
|
/// amortises, small enough that the writer lock is released often (FUSE
|
||||||
|
|||||||
@@ -189,11 +189,21 @@ fn spawn(argv: &[String], path: &Path) {
|
|||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.process_group(0)
|
.process_group(0)
|
||||||
.spawn();
|
.spawn();
|
||||||
if let Err(e) = spawned {
|
match spawned {
|
||||||
tracing::error!(command = ?argv, "open failed: {e}");
|
Ok(child) => reap(child),
|
||||||
|
Err(e) => tracing::error!(command = ?argv, "open failed: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wait for `child` on a thread of its own. A child nobody waits for stays a
|
||||||
|
/// zombie for as long as its parent lives, which for the app and the tray is
|
||||||
|
/// the whole session.
|
||||||
|
pub fn reap(mut child: std::process::Child) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// [`open`] with the policy from the user's `config.json`, read once per
|
/// [`open`] with the policy from the user's `config.json`, read once per
|
||||||
/// process. For callers that open the occasional path and have no reason to
|
/// process. For callers that open the occasional path and have no reason to
|
||||||
/// carry a policy around; long-lived windows should hold their own copy.
|
/// carry a policy around; long-lived windows should hold their own copy.
|
||||||
@@ -472,6 +482,21 @@ fn glob_match(pattern: &str, name: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reaped_child_does_not_stay_a_zombie() {
|
||||||
|
let child = std::process::Command::new("true").spawn().unwrap();
|
||||||
|
let proc_entry = std::path::PathBuf::from(format!("/proc/{}", child.id()));
|
||||||
|
reap(child);
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
while proc_entry.exists() && std::time::Instant::now() < deadline {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!proc_entry.exists(),
|
||||||
|
"the child is still in the process table"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule {
|
fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule {
|
||||||
OpenRule {
|
OpenRule {
|
||||||
patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
|
patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
|
||||||
|
|||||||
@@ -19,9 +19,31 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::mounts::MountMode;
|
use crate::mounts::MountMode;
|
||||||
|
|
||||||
/// File name of the profile.
|
/// File name of the profile clients before 3.5.0 wrote, one per device.
|
||||||
|
///
|
||||||
|
/// Read, never written: two computers sharing a device each overwrote the
|
||||||
|
/// other's. See [`profile_file_name`].
|
||||||
pub const PROFILE_FILE_NAME: &str = "profile.json";
|
pub const PROFILE_FILE_NAME: &str = "profile.json";
|
||||||
|
|
||||||
|
/// File name of one machine's profile: `profile-<machine id>.json`.
|
||||||
|
///
|
||||||
|
/// One per machine, because several computers may sync into one device, and
|
||||||
|
/// each describes its own arrangement. A single shared file went to whichever
|
||||||
|
/// computer saved last.
|
||||||
|
pub fn profile_file_name(machine_id: &str) -> String {
|
||||||
|
format!("profile-{machine_id}.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `name` is a profile: [`PROFILE_FILE_NAME`] or one machine's
|
||||||
|
/// [`profile_file_name`].
|
||||||
|
pub fn is_profile_file_name(name: &str) -> bool {
|
||||||
|
name == PROFILE_FILE_NAME
|
||||||
|
|| name
|
||||||
|
.strip_prefix("profile-")
|
||||||
|
.and_then(|rest| rest.strip_suffix(".json"))
|
||||||
|
.is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Folder, directly under the device root, that holds the profile.
|
/// Folder, directly under the device root, that holds the profile.
|
||||||
///
|
///
|
||||||
/// The API refuses `Cannot create file at the root of a device` (HTTP 422), so
|
/// The API refuses `Cannot create file at the root of a device` (HTTP 422), so
|
||||||
@@ -74,6 +96,10 @@ pub struct Profile {
|
|||||||
/// Hostname of the machine that wrote it, so a restore prompt can say
|
/// Hostname of the machine that wrote it, so a restore prompt can say
|
||||||
/// *"restore settings from laptop-xyz?"* rather than quoting a uid.
|
/// *"restore settings from laptop-xyz?"* rather than quoting a uid.
|
||||||
pub hostname: String,
|
pub hostname: String,
|
||||||
|
/// The writing machine's id, as in its [`profile_file_name`]. `None` in a
|
||||||
|
/// profile written before 3.5.0.
|
||||||
|
#[serde(default)]
|
||||||
|
pub machine_id: Option<String>,
|
||||||
/// When it was written, epoch seconds.
|
/// When it was written, epoch seconds.
|
||||||
pub saved_at: i64,
|
pub saved_at: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -92,6 +118,25 @@ pub struct Profile {
|
|||||||
pub mountpoint: Option<String>,
|
pub mountpoint: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The saved arrangement of the folder with uid `remote_uid`, from the
|
||||||
|
/// profiles of every computer that syncs the device.
|
||||||
|
///
|
||||||
|
/// The profile of the machine named `hostname` wins, since it describes this
|
||||||
|
/// computer; otherwise the most recently saved one that has the folder.
|
||||||
|
pub fn saved_folder<'a>(
|
||||||
|
profiles: &'a [Profile],
|
||||||
|
remote_uid: &str,
|
||||||
|
hostname: &str,
|
||||||
|
) -> Option<&'a ProfileFolder> {
|
||||||
|
let holding = |p: &&'a Profile| p.folders.iter().any(|f| f.remote_uid == remote_uid);
|
||||||
|
let profile = profiles
|
||||||
|
.iter()
|
||||||
|
.filter(holding)
|
||||||
|
.find(|p| p.hostname == hostname)
|
||||||
|
.or_else(|| profiles.iter().filter(holding).max_by_key(|p| p.saved_at))?;
|
||||||
|
profile.folders.iter().find(|f| f.remote_uid == remote_uid)
|
||||||
|
}
|
||||||
|
|
||||||
impl Profile {
|
impl Profile {
|
||||||
/// Parse a profile document, refusing one written by a newer client.
|
/// Parse a profile document, refusing one written by a newer client.
|
||||||
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
|
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
|
||||||
@@ -122,6 +167,7 @@ mod tests {
|
|||||||
version: PROFILE_VERSION,
|
version: PROFILE_VERSION,
|
||||||
device_uid: "dev-1".to_string(),
|
device_uid: "dev-1".to_string(),
|
||||||
hostname: "laptop-xyz".to_string(),
|
hostname: "laptop-xyz".to_string(),
|
||||||
|
machine_id: Some("0123456789abcdef0123456789abcdef".to_string()),
|
||||||
saved_at: 1_700_000_000,
|
saved_at: 1_700_000_000,
|
||||||
folders: vec![ProfileFolder {
|
folders: vec![ProfileFolder {
|
||||||
remote_uid: "vol~link".to_string(),
|
remote_uid: "vol~link".to_string(),
|
||||||
@@ -219,6 +265,52 @@ mod tests {
|
|||||||
assert!(p.ignore_patterns.is_none());
|
assert!(p.ignore_patterns.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_profile_name_is_the_legacy_one_or_one_per_machine() {
|
||||||
|
assert!(is_profile_file_name(PROFILE_FILE_NAME));
|
||||||
|
let name = profile_file_name("0123456789abcdef0123456789abcdef");
|
||||||
|
assert_eq!(name, "profile-0123456789abcdef0123456789abcdef.json");
|
||||||
|
assert!(is_profile_file_name(&name));
|
||||||
|
assert!(!is_profile_file_name("profile-.json"));
|
||||||
|
assert!(!is_profile_file_name("profile-a/b.json"));
|
||||||
|
assert!(!is_profile_file_name("profile (1).json"));
|
||||||
|
assert!(!is_profile_file_name("notes.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A profile written before 3.5.0 has no machine id and still parses.
|
||||||
|
#[test]
|
||||||
|
fn a_profile_without_a_machine_id_parses() {
|
||||||
|
let json = br#"{"version":1,"device_uid":"d","hostname":"h","saved_at":0}"#;
|
||||||
|
assert_eq!(Profile::parse(json).unwrap().machine_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Several computers sync one device, each with its own profile. This
|
||||||
|
/// computer's own wins, then the newest that has the folder.
|
||||||
|
#[test]
|
||||||
|
fn the_saved_folder_comes_from_this_computer_then_the_newest_profile() {
|
||||||
|
let profile = |hostname: &str, saved_at: i64, path: &str| Profile {
|
||||||
|
hostname: hostname.to_string(),
|
||||||
|
saved_at,
|
||||||
|
folders: vec![ProfileFolder {
|
||||||
|
remote_uid: "vol~docs".to_string(),
|
||||||
|
local_path: path.to_string(),
|
||||||
|
mode: MountMode::Mirror,
|
||||||
|
}],
|
||||||
|
..sample()
|
||||||
|
};
|
||||||
|
let profiles = vec![
|
||||||
|
profile("desktop", 10, "/home/a/Docs"),
|
||||||
|
profile("laptop", 5, "/home/b/Docs"),
|
||||||
|
profile("old", 1, "/home/c/Docs"),
|
||||||
|
];
|
||||||
|
let path =
|
||||||
|
|host: &str| saved_folder(&profiles, "vol~docs", host).map(|f| f.local_path.as_str());
|
||||||
|
assert_eq!(path("laptop"), Some("/home/b/Docs"));
|
||||||
|
assert_eq!(path("new-machine"), Some("/home/a/Docs"));
|
||||||
|
assert_eq!(saved_folder(&profiles, "vol~other", "laptop"), None);
|
||||||
|
assert_eq!(saved_folder(&[], "vol~docs", "laptop"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn future_version_is_refused() {
|
fn future_version_is_refused() {
|
||||||
let mut p = sample();
|
let mut p = sample();
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ const SCRIPTS: [(&str, &str); 2] = [
|
|||||||
/// `NAUTILUS_SCRIPT_SELECTED_FILE_PATHS`; the positional args carry the same
|
/// `NAUTILUS_SCRIPT_SELECTED_FILE_PATHS`; the positional args carry the same
|
||||||
/// selection when the script runs on a local (non-URI) folder, so we prefer the
|
/// selection when the script runs on a local (non-URI) folder, so we prefer the
|
||||||
/// environment and fall back to `"$@"` for the Nemo/Caja variants.
|
/// environment and fall back to `"$@"` for the Nemo/Caja variants.
|
||||||
|
///
|
||||||
|
/// The `--` keeps a file named like an option, such as `--json`, a path. A
|
||||||
|
/// name with a newline in it still arrives split in two from Nautilus, whose
|
||||||
|
/// list is newline-separated; `pdfs` then fails on both halves, which touches
|
||||||
|
/// nothing.
|
||||||
fn script_body(subcommand: &str) -> String {
|
fn script_body(subcommand: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"#!/bin/sh\n\
|
"#!/bin/sh\n\
|
||||||
@@ -41,7 +46,7 @@ fn script_body(subcommand: &str) -> String {
|
|||||||
\n\
|
\n\
|
||||||
printf '%s' \"$paths\" | while IFS= read -r path; do\n\
|
printf '%s' \"$paths\" | while IFS= read -r path; do\n\
|
||||||
\x20 [ -n \"$path\" ] || continue\n\
|
\x20 [ -n \"$path\" ] || continue\n\
|
||||||
\x20 pdfs {subcommand} \"$path\" || exit 1\n\
|
\x20 pdfs {subcommand} -- \"$path\" || exit 1\n\
|
||||||
done\n"
|
done\n"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -95,3 +100,13 @@ fn write_scripts(dir: &PathBuf) -> io::Result<()> {
|
|||||||
tracing::debug!("installed file-manager scripts in {dir:?}");
|
tracing::debug!("installed file-manager scripts in {dir:?}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::script_body;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_script_ends_options_before_each_path() {
|
||||||
|
assert!(script_body("pin").contains(r#"pdfs pin -- "$path""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
//! Which local folders may become a sync root.
|
||||||
|
//!
|
||||||
|
//! A mirror uploads everything under its root and deletes what Drive deleted,
|
||||||
|
//! so a root that holds more than the user meant is dangerous. `$HOME` takes
|
||||||
|
//! the decrypted content cache, `cache.db` and `~/.ssh` up to Drive, and walks
|
||||||
|
//! into the mount, which downloads the whole Drive only to upload it again.
|
||||||
|
//! Every place a root is added or restored goes through [`problem`].
|
||||||
|
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::config::{AppConfig, AppDirs};
|
||||||
|
|
||||||
|
/// The paths a sync root must stay clear of.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Reserved {
|
||||||
|
/// The user's home folder. A root may sit inside it, not be it or above it.
|
||||||
|
pub home: Option<PathBuf>,
|
||||||
|
/// The Proton Drive mount.
|
||||||
|
pub mountpoint: Option<PathBuf>,
|
||||||
|
/// This app's own configuration, state and cache folders.
|
||||||
|
pub app_dirs: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reserved {
|
||||||
|
/// The reserved paths of this user and `config`, resolved like a root.
|
||||||
|
pub fn current(config: &AppConfig) -> Self {
|
||||||
|
let Ok(dirs) = AppDirs::new() else {
|
||||||
|
return Self::default();
|
||||||
|
};
|
||||||
|
let resolved = |path: PathBuf| resolve(&path).unwrap_or(path);
|
||||||
|
let mut app_dirs = vec![dirs.state_dir(), dirs.cache_dir()];
|
||||||
|
if let Some(config_dir) = dirs.config_path().parent() {
|
||||||
|
app_dirs.push(config_dir.to_path_buf());
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
home: dirs.home_dir().map(resolved),
|
||||||
|
mountpoint: Some(resolved(dirs.resolved_mountpoint(config))),
|
||||||
|
app_dirs: app_dirs.into_iter().map(resolved).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `path` as a root is compared: absolute, with symlinks resolved as far as the
|
||||||
|
/// path exists. A folder a restore is about to create does not exist yet, so
|
||||||
|
/// the part past the deepest existing folder is kept as written.
|
||||||
|
///
|
||||||
|
/// A `..` is refused rather than resolved: before a symlink it means something
|
||||||
|
/// else than after it, and a path recorded on another machine has no business
|
||||||
|
/// holding one.
|
||||||
|
pub fn resolve(path: &Path) -> Result<PathBuf, String> {
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err(format!("{} is not an absolute path", path.display()));
|
||||||
|
}
|
||||||
|
if path.components().any(|c| c == Component::ParentDir) {
|
||||||
|
return Err(format!("{} goes up with `..`", path.display()));
|
||||||
|
}
|
||||||
|
let mut existing = path.to_path_buf();
|
||||||
|
let mut rest = Vec::new();
|
||||||
|
loop {
|
||||||
|
match existing.canonicalize() {
|
||||||
|
Ok(canonical) => {
|
||||||
|
return Ok(rest
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.fold(canonical, |path: PathBuf, part| path.join(part)));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let Some(name) = existing.file_name().map(|n| n.to_os_string()) else {
|
||||||
|
return Ok(path.to_path_buf());
|
||||||
|
};
|
||||||
|
rest.push(name);
|
||||||
|
if !existing.pop() {
|
||||||
|
return Ok(path.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why `root` cannot be synced, or `None` when it can. `root` is resolved
|
||||||
|
/// ([`resolve`]); `others` are the roots already synced.
|
||||||
|
pub fn problem(root: &Path, others: &[PathBuf], reserved: &Reserved) -> Option<String> {
|
||||||
|
let shown = root.display();
|
||||||
|
if root.parent().is_none() {
|
||||||
|
return Some(format!("{shown} is the root of the file system"));
|
||||||
|
}
|
||||||
|
if let Some(home) = &reserved.home {
|
||||||
|
if root == home {
|
||||||
|
return Some(format!(
|
||||||
|
"{shown} is your home folder; pick a folder inside it"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if home.starts_with(root) {
|
||||||
|
return Some(format!("{shown} holds your home folder"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(mountpoint) = &reserved.mountpoint
|
||||||
|
&& overlaps(root, mountpoint)
|
||||||
|
{
|
||||||
|
return Some(format!(
|
||||||
|
"{shown} overlaps the Proton Drive folder {}",
|
||||||
|
mountpoint.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(dir) = reserved.app_dirs.iter().find(|dir| overlaps(root, dir)) {
|
||||||
|
return Some(format!(
|
||||||
|
"{shown} overlaps this app's own data in {}",
|
||||||
|
dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if others.iter().any(|other| other == root) {
|
||||||
|
return Some(format!("{shown} is already synced"));
|
||||||
|
}
|
||||||
|
if let Some(other) = others.iter().find(|other| overlaps(root, other)) {
|
||||||
|
return Some(format!(
|
||||||
|
"{shown} overlaps the synced folder {}",
|
||||||
|
other.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether one of `a` and `b` is the other or inside it.
|
||||||
|
fn overlaps(a: &Path, b: &Path) -> bool {
|
||||||
|
a.starts_with(b) || b.starts_with(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn reserved() -> Reserved {
|
||||||
|
Reserved {
|
||||||
|
home: Some("/home/u".into()),
|
||||||
|
mountpoint: Some("/home/u/ProtonDrive".into()),
|
||||||
|
app_dirs: vec![
|
||||||
|
"/home/u/.local/state/proton-drive-linux".into(),
|
||||||
|
"/home/u/.cache/proton-drive-linux".into(),
|
||||||
|
"/home/u/.config/proton-drive-linux".into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refused(root: &str, others: &[&str]) -> bool {
|
||||||
|
let others: Vec<PathBuf> = others.iter().map(PathBuf::from).collect();
|
||||||
|
problem(Path::new(root), &others, &reserved()).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_inside_home_can_be_synced() {
|
||||||
|
assert!(!refused("/home/u/Documents", &[]));
|
||||||
|
assert!(!refused("/home/u/Pictures", &["/home/u/Documents"]));
|
||||||
|
assert!(!refused("/mnt/data/photos", &[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_file_system_root_home_and_its_parents_are_refused() {
|
||||||
|
assert!(refused("/", &[]));
|
||||||
|
assert!(refused("/home/u", &[]));
|
||||||
|
assert!(refused("/home", &[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_mount_and_the_apps_own_data_are_refused_either_way_round() {
|
||||||
|
assert!(refused("/home/u/ProtonDrive", &[]));
|
||||||
|
assert!(refused("/home/u/ProtonDrive/Documents", &[]));
|
||||||
|
assert!(refused("/home/u/.cache", &[]));
|
||||||
|
assert!(refused(
|
||||||
|
"/home/u/.local/state/proton-drive-linux/staging",
|
||||||
|
&[]
|
||||||
|
));
|
||||||
|
assert!(refused("/home/u/.config", &[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_root_inside_or_around_another_root_is_refused() {
|
||||||
|
assert!(refused("/home/u/Documents", &["/home/u/Documents"]));
|
||||||
|
assert!(refused("/home/u/Documents/Work", &["/home/u/Documents"]));
|
||||||
|
assert!(refused("/home/u/stuff", &["/home/u/stuff/Documents"]));
|
||||||
|
// A shared prefix of characters is not a shared folder.
|
||||||
|
assert!(!refused("/home/u/Documents2", &["/home/u/Documents"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_path_that_goes_up_or_is_relative_does_not_resolve() {
|
||||||
|
assert!(resolve(Path::new("/home/u/../../etc")).is_err());
|
||||||
|
assert!(resolve(Path::new("Documents")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_still_to_be_created_resolves_under_its_existing_parent() {
|
||||||
|
let base = std::env::temp_dir().join(format!("pdfs-syncroot-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let canonical = base.canonicalize().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&base.join("new/inner")).unwrap(),
|
||||||
|
canonical.join("new/inner")
|
||||||
|
);
|
||||||
|
let link = base.join("link");
|
||||||
|
let _ = std::fs::remove_file(&link);
|
||||||
|
std::os::unix::fs::symlink(&canonical, &link).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&link.join("later")).unwrap(),
|
||||||
|
canonical.join("later"),
|
||||||
|
"a symlink is followed to where it points"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&base).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
+251
-25
@@ -346,6 +346,11 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
.insert(file.to_string(), body);
|
.insert(file.to_string(), body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let indexes: HashMap<&str, SidecarIndex<'_>> = per_folder_sidecars
|
||||||
|
.iter()
|
||||||
|
.map(|(folder, in_folder)| (folder.as_str(), SidecarIndex::new(in_folder)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let mut scan = TakeoutScan::default();
|
let mut scan = TakeoutScan::default();
|
||||||
let mut seen_albums: HashMap<String, ()> = HashMap::new();
|
let mut seen_albums: HashMap<String, ()> = HashMap::new();
|
||||||
|
|
||||||
@@ -364,9 +369,13 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let sidecar = per_folder_sidecars
|
let found = indexes
|
||||||
.get(&folder)
|
.get(folder.as_str())
|
||||||
.and_then(|in_folder| find_sidecar(&file_name, in_folder))
|
.and_then(|index| find_sidecar(&file_name, index));
|
||||||
|
// An edited copy keeps its own name: the title is its original's.
|
||||||
|
let title_is_ours = found.is_some_and(|((rule, _), _)| rule != EDITED_COPY);
|
||||||
|
let sidecar = found
|
||||||
|
.map(|(_, body)| body)
|
||||||
.and_then(|body| serde_json::from_slice::<PhotoSidecar>(body).ok())
|
.and_then(|body| serde_json::from_slice::<PhotoSidecar>(body).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@@ -388,6 +397,7 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
let name = sidecar
|
let name = sidecar
|
||||||
.title
|
.title
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
.filter(|_| title_is_ours)
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|title| !title.is_empty() && same_extension(title, &file_name))
|
.filter(|title| !title.is_empty() && same_extension(title, &file_name))
|
||||||
.unwrap_or(&file_name)
|
.unwrap_or(&file_name)
|
||||||
@@ -598,7 +608,7 @@ pub fn capture_time_from_name(name: &str) -> Option<i64> {
|
|||||||
|
|
||||||
/// Days between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's
|
/// Days between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's
|
||||||
/// `days_from_civil`). Avoids pulling a date crate in for one conversion.
|
/// `days_from_civil`). Avoids pulling a date crate in for one conversion.
|
||||||
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
|
pub(crate) fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
|
||||||
let year = year - i64::from(month <= 2);
|
let year = year - i64::from(month <= 2);
|
||||||
let era = if year >= 0 { year } else { year - 399 } / 400;
|
let era = if year >= 0 { year } else { year - 399 } / 400;
|
||||||
let year_of_era = year - era * 400;
|
let year_of_era = year - era * 400;
|
||||||
@@ -627,17 +637,106 @@ fn swap_duplicate_marker(file_name: &str) -> Option<String> {
|
|||||||
Some(format!("{}{}{}", &file_name[..marker_start], tail, marker))
|
Some(format!("{}{}{}", &file_name[..marker_start], tail, marker))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the sidecar belonging to `file_name` among the JSON files of its folder.
|
/// How long Google lets a sidecar's file name get, `.json` included. A longer
|
||||||
|
/// one is cut, into its suffix first and then into the photo name.
|
||||||
|
const SIDECAR_NAME_CAP: usize = 51;
|
||||||
|
|
||||||
|
/// The rank [`sidecar_rank`] gives an edited copy that shares its original's
|
||||||
|
/// sidecar. Its date is the original's, but its name is its own.
|
||||||
|
const EDITED_COPY: u8 = 4;
|
||||||
|
|
||||||
|
/// The sidecars of one folder by their `.json`-stripped stem, so a photo is
|
||||||
|
/// ranked only against those that can match it.
|
||||||
|
///
|
||||||
|
/// Every rule in [`sidecar_rank`] needs the stem to start with a prefix of the
|
||||||
|
/// photo name that the name itself determines, and a sorted map finds the
|
||||||
|
/// stems with a given prefix as one range. Ranking every photo against every
|
||||||
|
/// sidecar instead took 27 s for a folder of 10,000 photos.
|
||||||
|
struct SidecarIndex<'a> {
|
||||||
|
by_stem: BTreeMap<&'a str, Vec<(&'a str, &'a Vec<u8>)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SidecarIndex<'a> {
|
||||||
|
fn new(in_folder: &'a BTreeMap<String, &'a Vec<u8>>) -> Self {
|
||||||
|
let mut by_stem: BTreeMap<&'a str, Vec<(&'a str, &'a Vec<u8>)>> = BTreeMap::new();
|
||||||
|
for (name, body) in in_folder {
|
||||||
|
by_stem
|
||||||
|
.entry(strip_json_suffix(name))
|
||||||
|
.or_default()
|
||||||
|
.push((name.as_str(), *body));
|
||||||
|
}
|
||||||
|
Self { by_stem }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add every sidecar whose stem starts with `prefix` to `out`.
|
||||||
|
fn starting_with(&self, prefix: &str, out: &mut BTreeMap<&'a str, &'a Vec<u8>>) {
|
||||||
|
let from = (
|
||||||
|
std::ops::Bound::Included(prefix),
|
||||||
|
std::ops::Bound::Unbounded,
|
||||||
|
);
|
||||||
|
for (stem, sidecars) in self.by_stem.range::<str, _>(from) {
|
||||||
|
if !stem.starts_with(prefix) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.extend(sidecars.iter().copied());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add every sidecar whose stem is `head`, or starts with `head` and a `.`:
|
||||||
|
/// those that have `head` as a boundary ([`suffix_boundaries`]).
|
||||||
|
fn bounded_by(&self, head: &str, out: &mut BTreeMap<&'a str, &'a Vec<u8>>) {
|
||||||
|
if let Some(sidecars) = self.by_stem.get(head) {
|
||||||
|
out.extend(sidecars.iter().copied());
|
||||||
|
}
|
||||||
|
self.starting_with(&format!("{head}."), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every sidecar [`sidecar_rank`] could match `file_name` to, by name. A
|
||||||
|
/// superset: each one is still ranked.
|
||||||
|
fn candidates(&self, file_name: &str) -> BTreeMap<&'a str, &'a Vec<u8>> {
|
||||||
|
let mut out = BTreeMap::new();
|
||||||
|
// Rules 0 and 2: the stem starts with the name, or with its swapped form.
|
||||||
|
self.starting_with(file_name, &mut out);
|
||||||
|
if let Some(swapped) = swap_duplicate_marker(file_name) {
|
||||||
|
self.starting_with(&swapped, &mut out);
|
||||||
|
}
|
||||||
|
// Rule 1: the stem of `A(N).B` starts with `A.B(N)`, so with `A.`.
|
||||||
|
for (paren, _) in file_name.match_indices('(') {
|
||||||
|
self.starting_with(&format!("{}.", &file_name[..paren]), &mut out);
|
||||||
|
}
|
||||||
|
// Rule 3: a boundary of the stem is a prefix of the name.
|
||||||
|
for end in file_name
|
||||||
|
.char_indices()
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
.skip(1)
|
||||||
|
.chain([file_name.len()])
|
||||||
|
{
|
||||||
|
self.bounded_by(&file_name[..end], &mut out);
|
||||||
|
}
|
||||||
|
// Rule 4: `<original>-<word>.<ext>` has `<original>.<ext>` as a boundary.
|
||||||
|
if let Some((_, extension)) = file_name.rsplit_once('.') {
|
||||||
|
for (dash, _) in file_name.match_indices('-') {
|
||||||
|
self.bounded_by(&format!("{}.{extension}", &file_name[..dash]), &mut out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the sidecar belonging to `file_name` among the JSON files of its folder,
|
||||||
|
/// with the rank it matched at.
|
||||||
///
|
///
|
||||||
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, an
|
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, an
|
||||||
/// unknown trailing suffix, then a prefix match for the case where truncation
|
/// unknown trailing suffix, a prefix match for the case where truncation cut
|
||||||
/// cut into the photo name itself. The best-ranked candidate in the folder wins.
|
/// into the photo name itself, and last an edited copy's original. The
|
||||||
|
/// best-ranked candidate in the folder wins; of equal ranks, the first by
|
||||||
|
/// sidecar name.
|
||||||
fn find_sidecar<'a>(
|
fn find_sidecar<'a>(
|
||||||
file_name: &str,
|
file_name: &str,
|
||||||
in_folder: &BTreeMap<String, &'a Vec<u8>>,
|
index: &SidecarIndex<'a>,
|
||||||
) -> Option<&'a Vec<u8>> {
|
) -> Option<((u8, usize), &'a Vec<u8>)> {
|
||||||
let mut best: Option<((u8, usize), &'a Vec<u8>)> = None;
|
let mut best: Option<((u8, usize), &'a Vec<u8>)> = None;
|
||||||
for (sidecar_name, body) in in_folder {
|
for (sidecar_name, body) in index.candidates(file_name) {
|
||||||
let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else {
|
let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -645,7 +744,7 @@ fn find_sidecar<'a>(
|
|||||||
best = Some((rank, body));
|
best = Some((rank, body));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
best.map(|(_, body)| body)
|
best
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How well a sidecar's `.json`-stripped `stem` matches `file_name`, as a sort
|
/// How well a sidecar's `.json`-stripped `stem` matches `file_name`, as a sort
|
||||||
@@ -683,19 +782,44 @@ fn sidecar_rank(stem: &str, file_name: &str) -> Option<(u8, usize)> {
|
|||||||
}
|
}
|
||||||
// 3 — truncation ate into the photo name itself, leaving only a prefix of
|
// 3 — truncation ate into the photo name itself, leaving only a prefix of
|
||||||
// it. Every suffix boundary in the stem is tried, so this holds for a
|
// it. Every suffix boundary in the stem is tried, so this holds for a
|
||||||
// localized suffix too; the longest surviving prefix wins, because a folder
|
// localized suffix too; the longest surviving prefix wins.
|
||||||
// with `IMG_1.jpg` and `IMG_12.jpg` must not let the shorter stem claim the
|
//
|
||||||
// longer photo.
|
// Only for a sidecar whose name is as long as Google lets one get: a name
|
||||||
let mut longest: Option<usize> = None;
|
// that short of the cap was not cut, so a prefix of it is the name of a
|
||||||
for head in suffix_boundaries(stem) {
|
// different photo. Without this, `IMG_1.jpg`'s sidecar gave its name and
|
||||||
if !head.is_empty()
|
// date to an `IMG_12.jpg` that had none (`docs/BUGS.md` B225).
|
||||||
&& file_name.starts_with(head)
|
if stem.chars().count() + ".json".len() >= SIDECAR_NAME_CAP - 4 {
|
||||||
&& longest.is_none_or(|len| head.len() > len)
|
let mut longest: Option<usize> = None;
|
||||||
{
|
for head in suffix_boundaries(stem) {
|
||||||
longest = Some(head.len());
|
if !head.is_empty()
|
||||||
|
&& file_name.starts_with(head)
|
||||||
|
&& longest.is_none_or(|len| head.len() > len)
|
||||||
|
{
|
||||||
|
longest = Some(head.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(len) = longest {
|
||||||
|
return Some((3, usize::MAX - len));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
longest.map(|len| (3, usize::MAX - len))
|
// 4 — an edited copy, `IMG_1-edited.jpg` (the word is localized), which
|
||||||
|
// Google exports without a sidecar of its own next to `IMG_1.jpg`'s.
|
||||||
|
suffix_boundaries(stem)
|
||||||
|
.filter_map(|head| head.rsplit_once('.'))
|
||||||
|
.any(|(original, extension)| is_edited_copy_of(file_name, original, extension))
|
||||||
|
.then_some((EDITED_COPY, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `file_name` is `<original>-<word>.<extension>`, the name Google
|
||||||
|
/// gives an edited copy of `<original>.<extension>`.
|
||||||
|
fn is_edited_copy_of(file_name: &str, original: &str, extension: &str) -> bool {
|
||||||
|
!original.is_empty()
|
||||||
|
&& file_name
|
||||||
|
.strip_prefix(original)
|
||||||
|
.and_then(|rest| rest.strip_prefix('-'))
|
||||||
|
.and_then(|rest| rest.strip_suffix(extension))
|
||||||
|
.and_then(|rest| rest.strip_suffix('.'))
|
||||||
|
.is_some_and(|word| !word.is_empty() && word.chars().all(char::is_alphabetic))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stem plus each of its prefixes ending before a `.`, longest first — the
|
/// The stem plus each of its prefixes ending before a `.`, longest first — the
|
||||||
@@ -994,21 +1118,25 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn longest_prefix_wins_when_the_photo_name_itself_was_truncated() {
|
fn longest_prefix_wins_when_the_photo_name_itself_was_truncated() {
|
||||||
|
// Both sidecar names are at Google's cap, so both were cut into the
|
||||||
|
// photo name; the longer surviving prefix is the better claim.
|
||||||
|
let short = "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_E.json";
|
||||||
|
let long = "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPO.json";
|
||||||
let mut sidecars = HashMap::new();
|
let mut sidecars = HashMap::new();
|
||||||
sidecar(
|
sidecar(
|
||||||
&mut sidecars,
|
&mut sidecars,
|
||||||
"Takeout/Google Photos/Photos from 2019/IMG_1.json",
|
short,
|
||||||
r#"{"photoTakenTime":{"timestamp":"1"}}"#,
|
r#"{"photoTakenTime":{"timestamp":"1"}}"#,
|
||||||
);
|
);
|
||||||
sidecar(
|
sidecar(
|
||||||
&mut sidecars,
|
&mut sidecars,
|
||||||
"Takeout/Google Photos/Photos from 2019/IMG_12.json",
|
long,
|
||||||
r#"{"photoTakenTime":{"timestamp":"2"}}"#,
|
r#"{"photoTakenTime":{"timestamp":"2"}}"#,
|
||||||
);
|
);
|
||||||
let scan = assemble(
|
let scan = assemble(
|
||||||
vec![entry(
|
vec![entry(
|
||||||
0,
|
0,
|
||||||
"Takeout/Google Photos/Photos from 2019/IMG_12345.jpg",
|
"Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPOSURE.jpg",
|
||||||
10,
|
10,
|
||||||
)],
|
)],
|
||||||
&sidecars,
|
&sidecars,
|
||||||
@@ -1017,6 +1145,104 @@ mod tests {
|
|||||||
assert_eq!(scan.photos[0].capture_time, Some(2));
|
assert_eq!(scan.photos[0].capture_time, Some(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A short sidecar name was not cut, so a prefix of it names another photo:
|
||||||
|
/// `IMG_1.jpg`'s sidecar gave its title and date to `IMG_12.jpg`
|
||||||
|
/// (`docs/BUGS.md` B225).
|
||||||
|
#[test]
|
||||||
|
fn a_photo_without_a_sidecar_does_not_take_a_shorter_named_siblings() {
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg.supplemental-metadata.json",
|
||||||
|
r#"{"title":"IMG_1.jpg","photoTakenTime":{"timestamp":"1"}}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
vec![
|
||||||
|
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_1.jpg", 10),
|
||||||
|
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_12.jpg", 10),
|
||||||
|
],
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos[0].capture_time, Some(1));
|
||||||
|
assert_eq!(scan.photos[1].name, "IMG_12.jpg");
|
||||||
|
assert_eq!(scan.photos[1].capture_time, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An edited copy has no sidecar of its own. It takes its original's date,
|
||||||
|
/// but keeps its own name rather than the original's title.
|
||||||
|
#[test]
|
||||||
|
fn an_edited_copy_takes_its_originals_date_and_keeps_its_name() {
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Fotos/Urlaub/IMG_2.jpg.ergänzende-Metadaten.json",
|
||||||
|
r#"{"title":"Holiday.jpg","photoTakenTime":{"timestamp":"7"}}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
vec![
|
||||||
|
entry(0, "Takeout/Google Fotos/Urlaub/IMG_2.jpg", 10),
|
||||||
|
entry(0, "Takeout/Google Fotos/Urlaub/IMG_2-bearbeitet.jpg", 10),
|
||||||
|
],
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos[0].name, "Holiday.jpg");
|
||||||
|
assert_eq!(scan.photos[1].name, "IMG_2-bearbeitet.jpg");
|
||||||
|
assert_eq!(scan.photos[1].capture_time, Some(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The index looks only at the sidecars that can match, and must find the
|
||||||
|
/// same one that ranking every sidecar in the folder finds.
|
||||||
|
#[test]
|
||||||
|
fn the_sidecar_index_finds_what_ranking_every_sidecar_finds() {
|
||||||
|
let long = "A_very_long_holiday_photo_name_from_the_beach_2019";
|
||||||
|
let names = [
|
||||||
|
"IMG_1.jpg".to_string(),
|
||||||
|
"IMG_12.jpg".to_string(),
|
||||||
|
"IMG_1(1).jpg".to_string(),
|
||||||
|
"IMG_1-edited.jpg".to_string(),
|
||||||
|
"IMG_1-bearbeitet.jpg".to_string(),
|
||||||
|
"my.photo(2).jpg".to_string(),
|
||||||
|
"x(1).jpg".to_string(),
|
||||||
|
"Ölbild.png".to_string(),
|
||||||
|
"PXL_20230101_101010.mp4".to_string(),
|
||||||
|
format!("{long}.jpg"),
|
||||||
|
format!("{long}_2.jpg"),
|
||||||
|
"nothing.heic".to_string(),
|
||||||
|
];
|
||||||
|
let sidecar_names = [
|
||||||
|
"IMG_1.jpg.supplemental-metadata.json".to_string(),
|
||||||
|
"IMG_1.jpg(1).supplemental-metadata.json".to_string(),
|
||||||
|
"IMG_12.jpg.ergänzende-Metadaten.json".to_string(),
|
||||||
|
"my.photo.jpg(2).json".to_string(),
|
||||||
|
"x.jpg(1).json".to_string(),
|
||||||
|
"Ölbild.png.suppl.json".to_string(),
|
||||||
|
"PXL_20230101_101010.mp4.JSON".to_string(),
|
||||||
|
format!("{}.json", &long[..44]),
|
||||||
|
format!("{long}.jp.json"),
|
||||||
|
"IMG.json".to_string(),
|
||||||
|
"unrelated.json".to_string(),
|
||||||
|
];
|
||||||
|
let bodies: Vec<Vec<u8>> = (0..sidecar_names.len())
|
||||||
|
.map(|i| i.to_string().into_bytes())
|
||||||
|
.collect();
|
||||||
|
let in_folder: BTreeMap<String, &Vec<u8>> =
|
||||||
|
sidecar_names.iter().cloned().zip(bodies.iter()).collect();
|
||||||
|
let index = SidecarIndex::new(&in_folder);
|
||||||
|
for name in &names {
|
||||||
|
let mut every: Option<((u8, usize), &Vec<u8>)> = None;
|
||||||
|
for (sidecar_name, body) in &in_folder {
|
||||||
|
if let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), name)
|
||||||
|
&& every.as_ref().is_none_or(|(current, _)| rank < *current)
|
||||||
|
{
|
||||||
|
every = Some((rank, *body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(find_sidecar(name, &index), every, "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidecar_title_replaces_a_mangled_on_disk_name() {
|
fn sidecar_title_replaces_a_mangled_on_disk_name() {
|
||||||
let mut sidecars = HashMap::new();
|
let mut sidecars = HashMap::new();
|
||||||
|
|||||||
@@ -723,16 +723,24 @@ fn scan_local_once(
|
|||||||
// has seen and stays indeterminate.
|
// has seen and stays indeterminate.
|
||||||
let job = transfers.begin_background_job("Indexing this computer");
|
let job = transfers.begin_background_job("Indexing this computer");
|
||||||
job.detail("Scanning your files");
|
job.detail("Scanning your files");
|
||||||
|
let mut failed = false;
|
||||||
let walked = localindex::scan(&[home], &excludes, |batch| {
|
let walked = localindex::scan(&[home], &excludes, |batch| {
|
||||||
if let Err(e) = db.local_upsert_batch(generation, &batch) {
|
if let Err(e) = db.local_upsert_batch(generation, &batch) {
|
||||||
warn!(error = %e, "local index: batch write failed");
|
warn!(error = %e, "local index: batch write failed");
|
||||||
|
failed = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prune what this scan did not see and rebuild the FTS index over the rest,
|
// Prune what this scan did not see, unless it did not see everything:
|
||||||
// even if some batches failed — a partial index still beats none.
|
// a path it never reached, or one whose batch failed to write, is not a
|
||||||
|
// path that is gone (`docs/BUGS.md` B217).
|
||||||
job.detail("Building the search index");
|
job.detail("Building the search index");
|
||||||
match db.local_finish_scan(generation, now_secs()) {
|
let finished = if failed || walked >= localindex::MAX_ENTRIES {
|
||||||
|
db.local_finish_partial_scan(now_secs())
|
||||||
|
} else {
|
||||||
|
db.local_finish_scan(generation, now_secs())
|
||||||
|
};
|
||||||
|
match finished {
|
||||||
Ok(indexed) => info!(
|
Ok(indexed) => info!(
|
||||||
walked,
|
walked,
|
||||||
indexed,
|
indexed,
|
||||||
|
|||||||
@@ -911,10 +911,22 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
))),
|
))),
|
||||||
Ok((file, len)) => {
|
Ok((file, len)) => {
|
||||||
let photos = core.photos();
|
let photos = core.photos();
|
||||||
|
// Unless the caller knows better: the photo's own EXIF,
|
||||||
|
// then the file's modification time, which a copy resets.
|
||||||
|
let capture_time = capture_time.or_else(|| {
|
||||||
|
let path = Path::new(&source_path);
|
||||||
|
pdfs_core::exiftime::capture_time_of(path).or_else(|| {
|
||||||
|
let modified =
|
||||||
|
std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
|
||||||
|
let since = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||||
|
Some(since.as_secs() as i64)
|
||||||
|
})
|
||||||
|
});
|
||||||
let metadata = proton_drive_rs::PhotoUploadMetadata {
|
let metadata = proton_drive_rs::PhotoUploadMetadata {
|
||||||
capture_time,
|
capture_time,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name);
|
||||||
let guard =
|
let guard =
|
||||||
core.transfers
|
core.transfers
|
||||||
.begin(name.clone(), "", TransferDirection::Upload, len);
|
.begin(name.clone(), "", TransferDirection::Upload, len);
|
||||||
@@ -924,7 +936,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
&media_type,
|
&media_type,
|
||||||
reader,
|
reader,
|
||||||
len as i64,
|
len as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
metadata,
|
metadata,
|
||||||
false,
|
false,
|
||||||
)) {
|
)) {
|
||||||
|
|||||||
@@ -105,6 +105,15 @@ pub(crate) fn adopted_device_uid() -> Option<String> {
|
|||||||
(!uid.is_empty()).then_some(uid)
|
(!uid.is_empty()).then_some(uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The paths no sync root may overlap, with the mountpoint as the config says
|
||||||
|
/// now.
|
||||||
|
pub(crate) fn sync_root_reserved() -> pdfs_core::syncroot::Reserved {
|
||||||
|
let config = pdfs_core::config::AppDirs::new()
|
||||||
|
.map(|dirs| dirs.load_config())
|
||||||
|
.unwrap_or_default();
|
||||||
|
pdfs_core::syncroot::Reserved::current(&config)
|
||||||
|
}
|
||||||
|
|
||||||
/// Pin (or, with `None`, unpin) the adopted device uid in `config.json`.
|
/// Pin (or, with `None`, unpin) the adopted device uid in `config.json`.
|
||||||
pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
||||||
let dirs = pdfs_core::config::AppDirs::new()
|
let dirs = pdfs_core::config::AppDirs::new()
|
||||||
@@ -517,18 +526,20 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?;
|
.map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?;
|
||||||
let local_str = local.to_string_lossy().to_string();
|
let local_str = local.to_string_lossy().to_string();
|
||||||
|
|
||||||
// Reject duplicates up front for a clear error (UNIQUE would also catch it).
|
// `/`, the home folder, the mount, this app's own data, and a folder
|
||||||
if self
|
// inside or around another root are refused (`syncroot::problem`). A
|
||||||
|
// duplicate is one of those, refused here for a clear error before
|
||||||
|
// the UNIQUE constraint would catch it.
|
||||||
|
let others = self
|
||||||
.db
|
.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
.iter()
|
.iter()
|
||||||
.any(|f| f.local_path == local_str)
|
.map(|f| PathBuf::from(&f.local_path))
|
||||||
{
|
.collect::<Vec<_>>();
|
||||||
return Err(CoreError::invalid(format!(
|
let reserved = sync_root_reserved();
|
||||||
"{} is already synced",
|
if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) {
|
||||||
local.display()
|
return Err(CoreError::invalid(problem));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = local
|
let name = local
|
||||||
|
|||||||
@@ -389,13 +389,7 @@ impl Core {
|
|||||||
// A name a change of ours is about to free is a wait, not a
|
// A name a change of ours is about to free is a wait, not a
|
||||||
// failure: the trash or rename holding it lands in moments.
|
// failure: the trash or rename holding it lands in moments.
|
||||||
let held = waits_for_name(e.as_ref());
|
let held = waits_for_name(e.as_ref());
|
||||||
let backoff = if held {
|
let backoff = drain_backoff(attempts, held, op.id);
|
||||||
DRAIN_BACKOFF_MIN
|
|
||||||
} else {
|
|
||||||
DRAIN_BACKOFF_MIN
|
|
||||||
.saturating_mul(1u32 << attempts.min(6))
|
|
||||||
.min(DRAIN_BACKOFF_MAX)
|
|
||||||
};
|
|
||||||
warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed; will retry");
|
warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed; will retry");
|
||||||
if let Err(e) = self.db.record_attempt_failure(
|
if let Err(e) = self.db.record_attempt_failure(
|
||||||
op.id,
|
op.id,
|
||||||
@@ -1118,7 +1112,7 @@ impl Core {
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let sent = self.create_sent_digests(op.id, current);
|
let sent = self.create_sent_digests(op.id, current);
|
||||||
Ok(adoptable(is_dir, &twin, op.created_at, &sent).then_some(twin.uid))
|
Ok(adoptable(is_dir, &twin, self.first_sent(op), &sent).then_some(twin.uid))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record the name the remote actually gave a node, after a conflict forced
|
/// Record the name the remote actually gave a node, after a conflict forced
|
||||||
@@ -1513,8 +1507,10 @@ impl Core {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|sha| holds_bytes(&twin, sha));
|
.is_some_and(|sha| holds_bytes(&twin, sha));
|
||||||
let sent = self.create_sent_digests(op.id, current);
|
let sent = self.create_sent_digests(op.id, current);
|
||||||
Ok(adoptable(op.kind == OP_MKDIR, &twin, op.created_at, &sent)
|
Ok(
|
||||||
.then_some((twin.uid, holds_blob)))
|
adoptable(op.kind == OP_MKDIR, &twin, self.first_sent(op), &sent)
|
||||||
|
.then_some((twin.uid, holds_blob)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The SHA-1s of the bytes queued create `id` may have made its file with:
|
/// The SHA-1s of the bytes queued create `id` may have made its file with:
|
||||||
@@ -1529,6 +1525,22 @@ impl Core {
|
|||||||
sent
|
sent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When create `op` first went out, which is the earliest a node its lost
|
||||||
|
/// answer left can have been made. An op queued offline may wait for days
|
||||||
|
/// before that; measured from when it was queued, the adoption window let
|
||||||
|
/// it take a node another device made in the meantime. Ops sent before the
|
||||||
|
/// time was recorded fall back to when they were queued.
|
||||||
|
fn first_sent(&self, op: &PendingOp) -> i64 {
|
||||||
|
match self.db.create_first_sent(op.id) {
|
||||||
|
Ok(Some(at)) => at.max(op.created_at),
|
||||||
|
Ok(None) => op.created_at,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(id = op.id, error = %e, "reading when a queued create was sent failed");
|
||||||
|
op.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Make one queued `create`/`mkdir` real under a given name, and hand back
|
/// Make one queued `create`/`mkdir` real under a given name, and hand back
|
||||||
/// the API's own error so the caller can tell a name clash from a failure.
|
/// the API's own error so the caller can tell a name clash from a failure.
|
||||||
///
|
///
|
||||||
@@ -1541,7 +1553,7 @@ impl Core {
|
|||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<NodeUid, Box<dyn std::error::Error>> {
|
) -> Result<NodeUid, Box<dyn std::error::Error>> {
|
||||||
self.db
|
self.db
|
||||||
.note_create_target(op.id, &parent.to_string(), name)?;
|
.note_create_target(op.id, &parent.to_string(), name, now_millis())?;
|
||||||
match op.kind == OP_MKDIR {
|
match op.kind == OP_MKDIR {
|
||||||
true => Ok(self.block_on_bounded(self.drive.create_folder(
|
true => Ok(self.block_on_bounded(self.drive.create_folder(
|
||||||
parent,
|
parent,
|
||||||
@@ -1586,7 +1598,7 @@ impl Core {
|
|||||||
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
let reader = CountingReader::new(File::open(blob)?, &guard);
|
let reader = CountingReader::new(File::open(blob)?, &guard);
|
||||||
let uid = self.block_on_within(
|
let uid = self.block_on_within(
|
||||||
link::upload_deadline(meta.len),
|
link::upload_deadline(meta.len, self.transfers.upload_cap()),
|
||||||
self.drive.upload_file_from(
|
self.drive.upload_file_from(
|
||||||
parent,
|
parent,
|
||||||
name,
|
name,
|
||||||
@@ -1983,7 +1995,7 @@ impl Core {
|
|||||||
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
let reader = CountingReader::new(File::open(blob)?, &guard);
|
let reader = CountingReader::new(File::open(blob)?, &guard);
|
||||||
self.block_on_within(
|
self.block_on_within(
|
||||||
link::upload_deadline(meta.len),
|
link::upload_deadline(meta.len, self.transfers.upload_cap()),
|
||||||
self.drive.upload_file_from(
|
self.drive.upload_file_from(
|
||||||
&parent,
|
&parent,
|
||||||
&alt,
|
&alt,
|
||||||
@@ -2236,7 +2248,7 @@ impl Core {
|
|||||||
.clone()
|
.clone()
|
||||||
.ok_or("pending op has no staged blob")?;
|
.ok_or("pending op has no staged blob")?;
|
||||||
let blob = PathBuf::from(blob);
|
let blob = PathBuf::from(blob);
|
||||||
let meta: StagedWrite = serde_json::from_str(op.meta_json.as_deref().unwrap_or(""))?;
|
let mut meta: StagedWrite = serde_json::from_str(op.meta_json.as_deref().unwrap_or(""))?;
|
||||||
let uid = parse_node_uid(&meta.uid).ok_or("staged write has an unparseable uid")?;
|
let uid = parse_node_uid(&meta.uid).ok_or("staged write has an unparseable uid")?;
|
||||||
|
|
||||||
if let Some(reason) = self.revision_conflict(&uid, &meta, &blob)? {
|
if let Some(reason) = self.revision_conflict(&uid, &meta, &blob)? {
|
||||||
@@ -2275,6 +2287,22 @@ impl Core {
|
|||||||
&written,
|
&written,
|
||||||
)
|
)
|
||||||
.map_err(|e| self.errno_error(e, "gap-fill from base failed"))?;
|
.map_err(|e| self.errno_error(e, "gap-fill from base failed"))?;
|
||||||
|
// The blob is whole now. Recorded, so that a retry after a lost
|
||||||
|
// answer can match these bytes against the server's revision
|
||||||
|
// (`revision_conflict`) instead of forking a conflict copy of the
|
||||||
|
// user's own write.
|
||||||
|
meta.complete = true;
|
||||||
|
match serde_json::to_string(&meta) {
|
||||||
|
Ok(json) => {
|
||||||
|
let path = op.blob_path.as_deref().unwrap_or_default();
|
||||||
|
if let Err(e) = self.db.update_op_meta_for_blob(op.id, path, &json) {
|
||||||
|
warn!(%uid, error = %e,
|
||||||
|
"recording a gap-filled blob as whole failed; \
|
||||||
|
a lost answer may land it as a conflict copy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!(%uid, error = %e, "serializing a gap-filled sidecar failed"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = self.node_name(&uid);
|
let name = self.node_name(&uid);
|
||||||
@@ -2295,7 +2323,7 @@ impl Core {
|
|||||||
let reader = CountingReader::new(File::open(&blob)?, &guard).with_cancel(cancel.flag());
|
let reader = CountingReader::new(File::open(&blob)?, &guard).with_cancel(cancel.flag());
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let sent = self.block_on_within(
|
let sent = self.block_on_within(
|
||||||
link::upload_deadline(meta.len),
|
link::upload_deadline(meta.len, self.transfers.upload_cap()),
|
||||||
self.drive
|
self.drive
|
||||||
.upload_new_revision_from(&uid, reader, meta.len as i64, thumbnails, None),
|
.upload_new_revision_from(&uid, reader, meta.len as i64, thumbnails, None),
|
||||||
);
|
);
|
||||||
@@ -2753,24 +2781,43 @@ fn waits_for_name(e: &(dyn std::error::Error + 'static)) -> bool {
|
|||||||
std::iter::successors(Some(e), |e| e.source()).any(|e| e.is::<NameHeld>())
|
std::iter::successors(Some(e), |e| e.source()).any(|e| e.is::<NameHeld>())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long an op waits after its `attempts`-th failure: doubling from
|
||||||
|
/// [`DRAIN_BACKOFF_MIN`] up to [`DRAIN_BACKOFF_MAX`], plus up to a fifth more,
|
||||||
|
/// different for each op (`seed`), so that ops that failed together are not
|
||||||
|
/// all retried in the same second. One that waits for a name (`held`) waits
|
||||||
|
/// the minimum.
|
||||||
|
///
|
||||||
|
/// The doubling stopped at 2^6, 128 s, so the maximum was never reached.
|
||||||
|
fn drain_backoff(attempts: i64, held: bool, seed: i64) -> Duration {
|
||||||
|
if held {
|
||||||
|
return DRAIN_BACKOFF_MIN;
|
||||||
|
}
|
||||||
|
let base = DRAIN_BACKOFF_MIN
|
||||||
|
.saturating_mul(1u32 << attempts.clamp(0, 8))
|
||||||
|
.min(DRAIN_BACKOFF_MAX);
|
||||||
|
// A cheap, stable spread: the same op and attempt always wait the same.
|
||||||
|
let spread = (seed.wrapping_mul(0x9E37_79B9).wrapping_add(attempts) as u64 % 21) as u32;
|
||||||
|
base + base * spread / 100
|
||||||
|
}
|
||||||
|
|
||||||
/// How many attempts an op waits for a rename or a create on the wire to free
|
/// How many attempts an op waits for a rename or a create on the wire to free
|
||||||
/// the name it wants. Two files swapping names each wait for the other, so the
|
/// the name it wants. Two files swapping names each wait for the other, so the
|
||||||
/// wait has to end; a conflict name ends it.
|
/// wait has to end; a conflict name ends it.
|
||||||
const NAME_HOLD_ATTEMPTS: i64 = 4;
|
const NAME_HOLD_ATTEMPTS: i64 = 4;
|
||||||
|
|
||||||
/// How much older than its op a remote node may be and still be the one an
|
/// How much older than its op's first send a remote node may be and still be
|
||||||
/// unanswered call made. The op is queued the moment the call gives up, so the
|
/// the one an unanswered call made. The node is made during that call, so it
|
||||||
/// node is at most one call's deadline older; the rest is clock skew between
|
/// is at most one call's deadline older; the rest is clock skew between this
|
||||||
/// this machine and Proton.
|
/// machine and Proton.
|
||||||
const ADOPT_WINDOW_MS: i64 = 2 * 60 * 1000;
|
const ADOPT_WINDOW_MS: i64 = 2 * 60 * 1000;
|
||||||
|
|
||||||
/// Whether `twin`, found holding the name a queued create wants, is the node an
|
/// Whether `twin`, found holding the name a queued create wants, is the node an
|
||||||
/// earlier, unanswered attempt at that create made: the same kind, created
|
/// earlier, unanswered attempt at that create made: the same kind, created no
|
||||||
/// around when the op was queued, and for a file still empty, as the mount's
|
/// earlier than around when the op was first sent (`first_sent_ms`), and for a file still empty, as the mount's
|
||||||
/// create leaves it, or holding exactly bytes the op uploaded, whose SHA-1 is
|
/// create leaves it, or holding exactly bytes the op uploaded, whose SHA-1 is
|
||||||
/// one of `sent`.
|
/// one of `sent`.
|
||||||
fn adoptable(is_dir: bool, twin: &Node, op_created_ms: i64, sent: &[String]) -> bool {
|
fn adoptable(is_dir: bool, twin: &Node, first_sent_ms: i64, sent: &[String]) -> bool {
|
||||||
if twin.creation_time.saturating_mul(1000) < op_created_ms - ADOPT_WINDOW_MS {
|
if twin.creation_time.saturating_mul(1000) < first_sent_ms - ADOPT_WINDOW_MS {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
match &twin.kind {
|
match &twin.kind {
|
||||||
@@ -3352,6 +3399,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failing_op_backs_off_to_the_maximum_with_a_spread() {
|
||||||
|
let at = |attempts| drain_backoff(attempts, false, 7);
|
||||||
|
assert!(at(1) >= DRAIN_BACKOFF_MIN * 2);
|
||||||
|
assert!(at(2) > at(1));
|
||||||
|
// Reached, which the doubling capped at 2^6 never did.
|
||||||
|
assert!(at(8) >= DRAIN_BACKOFF_MAX);
|
||||||
|
assert!(at(30) <= DRAIN_BACKOFF_MAX + DRAIN_BACKOFF_MAX / 5);
|
||||||
|
// Ops that failed together do not all come back together.
|
||||||
|
let waits: std::collections::HashSet<_> =
|
||||||
|
(1..=20).map(|id| drain_backoff(8, false, id)).collect();
|
||||||
|
assert!(waits.len() > 5, "{waits:?}");
|
||||||
|
// A wait for a name stays short however often it repeats.
|
||||||
|
assert_eq!(drain_backoff(30, true, 7), DRAIN_BACKOFF_MIN);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_a_fresh_empty_twin_of_the_same_kind_is_adopted() {
|
fn only_a_fresh_empty_twin_of_the_same_kind_is_adopted() {
|
||||||
// The op was queued at t=1000 s, after a create that got no answer.
|
// The op was queued at t=1000 s, after a create that got no answer.
|
||||||
|
|||||||
@@ -261,10 +261,13 @@ impl Filesystem for ProtonFs {
|
|||||||
// in-flight (possibly unsaved) content: authored bytes come from the
|
// in-flight (possibly unsaved) content: authored bytes come from the
|
||||||
// scratch file, untouched bytes from the base.
|
// scratch file, untouched bytes from the base.
|
||||||
//
|
//
|
||||||
// This path stays on the dispatch loop. `write` runs there too, so
|
// Answered on the dispatch loop when everything it needs is on this
|
||||||
// keeping its reads there as well preserves the ordering the write path
|
// machine, which keeps the order `write` has with reads of the same
|
||||||
// has always had between a write and a read of the same handle; a mostly
|
// handle. A base range that has to be downloaded goes to a worker
|
||||||
// local read is not worth reasoning about that for.
|
// instead, with the handle as it was when the read came in: the kernel
|
||||||
|
// issued the read before any write that comes after it, so that is
|
||||||
|
// the content it asked for. On the loop, the download held every
|
||||||
|
// request of the mount (`docs/BUGS.md` B227).
|
||||||
let handle = {
|
let handle = {
|
||||||
let st = self.core.state();
|
let st = self.core.state();
|
||||||
st.active_writes.get(&ino.0).map(|h| {
|
st.active_writes.get(&ino.0).map(|h| {
|
||||||
@@ -279,7 +282,7 @@ impl Filesystem for ProtonFs {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
if let Some((file, len, uid, base_mtime, base_size, written)) = handle {
|
if let Some((file, len, uid, base_mtime, base_size, written)) = handle {
|
||||||
match self.core.serve_open_read(
|
match self.core.serve_open_read_local(
|
||||||
&file,
|
&file,
|
||||||
len,
|
len,
|
||||||
&uid,
|
&uid,
|
||||||
@@ -289,7 +292,25 @@ impl Filesystem for ProtonFs {
|
|||||||
offset,
|
offset,
|
||||||
size as u64,
|
size as u64,
|
||||||
) {
|
) {
|
||||||
Ok(bytes) => reply.data(&bytes),
|
Ok(Some(bytes)) => reply.data(&bytes),
|
||||||
|
Ok(None) => {
|
||||||
|
let fs = self.clone();
|
||||||
|
self.core.workers.run(Lane::Transfer, "read", move || {
|
||||||
|
match fs.core.serve_open_read(
|
||||||
|
&file,
|
||||||
|
len,
|
||||||
|
&uid,
|
||||||
|
base_mtime,
|
||||||
|
base_size,
|
||||||
|
&written,
|
||||||
|
offset,
|
||||||
|
size as u64,
|
||||||
|
) {
|
||||||
|
Ok(bytes) => reply.data(&bytes),
|
||||||
|
Err(e) => reply.error(e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Err(e) => reply.error(e),
|
Err(e) => reply.error(e),
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -556,48 +577,62 @@ impl Filesystem for ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let new_end = offset.saturating_add(length);
|
let new_end = offset.saturating_add(length);
|
||||||
let keep_size = (mode & 1) != 0; // FALLOC_FL_KEEP_SIZE
|
// Only the modes whose effect on the scratch file this handle can
|
||||||
|
// record. Anything else went through to the scratch file untracked:
|
||||||
let is_punch_hole = (mode & 0x02) != 0; // FALLOC_FL_PUNCH_HOLE
|
// a zeroed range was refilled from the base at commit, and a collapse
|
||||||
|
// or insert shifted bytes that `len` and `written` did not follow
|
||||||
let res = {
|
// (`docs/BUGS.md` B224). `EOPNOTSUPP` is also what makes glibc's
|
||||||
|
// `posix_fallocate` fall back to writing zeros.
|
||||||
|
let Some(kind) = falloc_kind(mode) else {
|
||||||
|
reply.error(Errno::EOPNOTSUPP);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let file = {
|
||||||
|
let st = self.core.state();
|
||||||
|
match st.handles.get(&_fh.0).and_then(|i| st.active_writes.get(i)) {
|
||||||
|
Some(aw) => aw.file.clone(),
|
||||||
|
None => {
|
||||||
|
reply.error(if st.entries.contains_key(&ino.0) {
|
||||||
|
Errno::EBADF
|
||||||
|
} else {
|
||||||
|
Errno::ENOENT
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The syscall can take a while on a large range; the state lock is
|
||||||
|
// not held across it, as `write` does not hold it across its I/O.
|
||||||
|
let ret = {
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
unsafe { libc::fallocate(file.as_raw_fd(), mode, offset as i64, length as i64) }
|
||||||
|
};
|
||||||
|
let res = if ret != 0 {
|
||||||
|
Err(Errno::from(std::io::Error::last_os_error()))
|
||||||
|
} else {
|
||||||
let mut st = self.core.state();
|
let mut st = self.core.state();
|
||||||
match st
|
if let Some(aw) = st
|
||||||
.handles
|
.handles
|
||||||
.get(&_fh.0)
|
.get(&_fh.0)
|
||||||
.copied()
|
.copied()
|
||||||
.and_then(|i| st.active_writes.get_mut(&i))
|
.and_then(|i| st.active_writes.get_mut(&i))
|
||||||
{
|
{
|
||||||
Some(aw) => {
|
let len = aw.len;
|
||||||
use std::os::unix::io::AsRawFd;
|
let (authored, grown) = kind.effect(offset, new_end, len);
|
||||||
let fd = aw.file.as_raw_fd();
|
if let Some((from, to)) = authored {
|
||||||
let ret = unsafe { libc::fallocate(fd, mode, offset as i64, length as i64) };
|
// Zeroed or punched bytes read as zero. Marked authored so
|
||||||
if ret == 0 {
|
// queue_revision does not refill them from the remote
|
||||||
if is_punch_hole {
|
// baseline and silently undo them at commit.
|
||||||
// A punched range reads as zero. Mark it authored so
|
aw.written.add(from, to);
|
||||||
// queue_revision does not refill it from the remote
|
aw.dirty = true;
|
||||||
// baseline and silently undo the hole at commit.
|
|
||||||
aw.written.add(offset, new_end.min(aw.len));
|
|
||||||
aw.dirty = true;
|
|
||||||
} else if !keep_size && new_end > aw.len {
|
|
||||||
aw.written.add(aw.len, new_end);
|
|
||||||
aw.len = new_end;
|
|
||||||
aw.dirty = true;
|
|
||||||
st.set_size(ino.0, new_end);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(Errno::EIO)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => {
|
if let Some(end) = grown {
|
||||||
if st.entries.contains_key(&ino.0) {
|
aw.len = end;
|
||||||
Err(Errno::EBADF)
|
aw.dirty = true;
|
||||||
} else {
|
st.set_size(ino.0, end);
|
||||||
Err(Errno::ENOENT)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
};
|
};
|
||||||
|
|
||||||
match res {
|
match res {
|
||||||
@@ -606,18 +641,47 @@ impl Filesystem for ProtonFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `close(2)` calls flush before release. The upload is queued in `release`
|
/// `close(2)` calls flush before release, and flush is the only one of the
|
||||||
/// and performed in the background, so there is nothing
|
/// two whose answer reaches the program: the kernel drops `release`'s. So
|
||||||
/// to push here — the written bytes are already in the scratch file.
|
/// the scratch file of a handle with writes on it is synced here, on a
|
||||||
|
/// worker, and a full disk or a failing one is what `close` returns.
|
||||||
|
///
|
||||||
|
/// Before, flush answered `ok` at once and `release` synced the file, so an
|
||||||
|
/// `ENOSPC` that delayed allocation held back until then was reported to
|
||||||
|
/// nobody, and the program took the save as done (`docs/BUGS.md` B222).
|
||||||
|
/// It also means `release`, which stays on the dispatch loop for ordering,
|
||||||
|
/// finds the pages already on disk instead of writing back a whole file
|
||||||
|
/// while every other request on the mount waits.
|
||||||
fn flush(
|
fn flush(
|
||||||
&self,
|
&self,
|
||||||
_req: &Request,
|
_req: &Request,
|
||||||
_ino: INodeNo,
|
_ino: INodeNo,
|
||||||
_fh: FileHandle,
|
fh: FileHandle,
|
||||||
_lock_owner: LockOwner,
|
_lock_owner: LockOwner,
|
||||||
reply: ReplyEmpty,
|
reply: ReplyEmpty,
|
||||||
) {
|
) {
|
||||||
reply.ok();
|
let file = {
|
||||||
|
let st = self.core.state();
|
||||||
|
st.handles
|
||||||
|
.get(&fh.0)
|
||||||
|
.and_then(|ino| st.active_writes.get(ino))
|
||||||
|
.filter(|aw| aw.dirty)
|
||||||
|
.map(|aw| aw.file.clone())
|
||||||
|
};
|
||||||
|
let Some(file) = file else {
|
||||||
|
reply.ok();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let fs = self.clone();
|
||||||
|
self.core
|
||||||
|
.workers
|
||||||
|
.run(Lane::Transfer, "flush", move || match file.sync_data() {
|
||||||
|
Ok(()) => reply.ok(),
|
||||||
|
Err(e) => {
|
||||||
|
error!(fh = fh.0, error = %e, "flush of scratch file failed");
|
||||||
|
reply.error(fs.scratch_sync_errno(&e));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Durability here means the scratch file, not the remote: a caller asking
|
/// Durability here means the scratch file, not the remote: a caller asking
|
||||||
@@ -1022,6 +1086,52 @@ fn build_listing(st: &State, ino: u64) -> DirListing {
|
|||||||
/// The cookie handed back is the index of the *next* entry, so a resumed call
|
/// The cookie handed back is the index of the *next* entry, so a resumed call
|
||||||
/// continues exactly where the last one stopped — which is only true because
|
/// continues exactly where the last one stopped — which is only true because
|
||||||
/// the listing behind those indexes is a snapshot (`docs/BUGS.md` B43).
|
/// the listing behind those indexes is a snapshot (`docs/BUGS.md` B43).
|
||||||
|
/// What an `fallocate` mode does to a write handle, for the modes it can
|
||||||
|
/// record (`docs/BUGS.md` B224).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum FallocKind {
|
||||||
|
/// Reserve space; past the end it grows the file unless `keep_size`.
|
||||||
|
Allocate { keep_size: bool },
|
||||||
|
/// Deallocate a range inside the file, which then reads as zero.
|
||||||
|
Punch,
|
||||||
|
/// Zero a range; past the end it grows the file unless `keep_size`.
|
||||||
|
Zero { keep_size: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn falloc_kind(mode: i32) -> Option<FallocKind> {
|
||||||
|
const KEEP: i32 = libc::FALLOC_FL_KEEP_SIZE;
|
||||||
|
const PUNCH: i32 = libc::FALLOC_FL_PUNCH_HOLE;
|
||||||
|
const ZERO: i32 = libc::FALLOC_FL_ZERO_RANGE;
|
||||||
|
match mode {
|
||||||
|
0 => Some(FallocKind::Allocate { keep_size: false }),
|
||||||
|
KEEP => Some(FallocKind::Allocate { keep_size: true }),
|
||||||
|
m if m == PUNCH | KEEP => Some(FallocKind::Punch),
|
||||||
|
ZERO => Some(FallocKind::Zero { keep_size: false }),
|
||||||
|
m if m == ZERO | KEEP => Some(FallocKind::Zero { keep_size: true }),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FallocKind {
|
||||||
|
/// For `[offset, end)` on a file `len` bytes long: the range that now
|
||||||
|
/// reads as zero and must count as written, and the new length if it grew.
|
||||||
|
fn effect(self, offset: u64, end: u64, len: u64) -> (Option<(u64, u64)>, Option<u64>) {
|
||||||
|
let grow = |keep_size: bool| (!keep_size && end > len).then_some(end);
|
||||||
|
let zeroed = |to: u64| (offset < to).then_some((offset, to));
|
||||||
|
match self {
|
||||||
|
// Space past the old end reads as zero, and nothing before it moved.
|
||||||
|
Self::Allocate { keep_size } => {
|
||||||
|
(grow(keep_size).map(|end| (len, end)), grow(keep_size))
|
||||||
|
}
|
||||||
|
Self::Punch => (zeroed(end.min(len)), None),
|
||||||
|
Self::Zero { keep_size } => {
|
||||||
|
let to = if keep_size { end.min(len) } else { end };
|
||||||
|
(zeroed(to), grow(keep_size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) {
|
fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) {
|
||||||
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
|
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
|
||||||
let ft = if *is_dir {
|
let ft = if *is_dir {
|
||||||
@@ -1435,6 +1545,20 @@ impl ProtonFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The body of [`Filesystem::fsync`], run off the dispatch loop.
|
/// The body of [`Filesystem::fsync`], run off the dispatch loop.
|
||||||
|
/// The errno to give a program for a failed sync of a scratch file.
|
||||||
|
/// Delayed allocation means a full disk usually surfaces at the sync
|
||||||
|
/// rather than at `write(2)`, and `ENOSPC` is what a program checks for.
|
||||||
|
fn scratch_sync_errno(&self, e: &std::io::Error) -> Errno {
|
||||||
|
match e.raw_os_error() {
|
||||||
|
Some(libc::ENOSPC) => {
|
||||||
|
self.core.cache.emergency_evict();
|
||||||
|
Errno::ENOSPC
|
||||||
|
}
|
||||||
|
Some(libc::EDQUOT) => Errno::EDQUOT,
|
||||||
|
_ => Errno::EIO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn serve_fsync(&self, fh: FileHandle, reply: ReplyEmpty) {
|
fn serve_fsync(&self, fh: FileHandle, reply: ReplyEmpty) {
|
||||||
// Everything the durability sidecar needs, copied out so no lock is held
|
// Everything the durability sidecar needs, copied out so no lock is held
|
||||||
// across the sync.
|
// across the sync.
|
||||||
@@ -1455,22 +1579,18 @@ impl ProtonFs {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
// A handle opened for reading has nothing of its own to make durable,
|
||||||
|
// and POSIX allows `fsync` on it; `EBADF` failed `sync FILE` and
|
||||||
|
// backup tools that sync what they read (`docs/BUGS.md` B222).
|
||||||
let Some((f, path, uid, dirty, written, len, base_size, base_mtime, base_revision_id)) =
|
let Some((f, path, uid, dirty, written, len, base_size, base_mtime, base_revision_id)) =
|
||||||
handle
|
handle
|
||||||
else {
|
else {
|
||||||
reply.error(Errno::EBADF);
|
reply.ok();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Err(e) = f.sync_all() {
|
if let Err(e) = f.sync_all() {
|
||||||
error!(fh = fh.0, error = %e, "fsync of scratch file failed");
|
error!(fh = fh.0, error = %e, "fsync of scratch file failed");
|
||||||
// Delayed allocation means a full disk usually surfaces here rather
|
reply.error(self.scratch_sync_errno(&e));
|
||||||
// than at `write(2)`; `ENOSPC` is what an application checks for.
|
|
||||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
|
||||||
self.core.cache.emergency_evict();
|
|
||||||
reply.error(Errno::ENOSPC);
|
|
||||||
} else {
|
|
||||||
reply.error(Errno::EIO);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A clean handle has nothing on it that the remote does not already
|
// A clean handle has nothing on it that the remote does not already
|
||||||
@@ -1717,9 +1837,7 @@ impl ProtonFs {
|
|||||||
// empty, which it is, so reads stay offline-clean across a restart too.
|
// empty, which it is, so reads stay offline-clean across a restart too.
|
||||||
if local {
|
if local {
|
||||||
st.children.insert(ino, Vec::new());
|
st.children.insert(ino, Vec::new());
|
||||||
if let Err(e) = self.core.db.set_listed(&uid, true) {
|
st.record_listed(uid);
|
||||||
warn!(%uid, error = %e, "db set_listed(true) failed for local folder");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let entry = st.entries.get(&ino).unwrap();
|
let entry = st.entries.get(&ino).unwrap();
|
||||||
let attr = self.attr(ino, &entry.node, entry.access);
|
let attr = self.attr(ino, &entry.node, entry.access);
|
||||||
@@ -2337,3 +2455,60 @@ impl ProtonFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod falloc_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_modes_a_handle_can_record_are_accepted() {
|
||||||
|
let keep = libc::FALLOC_FL_KEEP_SIZE;
|
||||||
|
assert_eq!(
|
||||||
|
falloc_kind(0),
|
||||||
|
Some(FallocKind::Allocate { keep_size: false })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
falloc_kind(keep),
|
||||||
|
Some(FallocKind::Allocate { keep_size: true })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
falloc_kind(libc::FALLOC_FL_PUNCH_HOLE | keep),
|
||||||
|
Some(FallocKind::Punch)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
falloc_kind(libc::FALLOC_FL_ZERO_RANGE),
|
||||||
|
Some(FallocKind::Zero { keep_size: false })
|
||||||
|
);
|
||||||
|
for mode in [
|
||||||
|
libc::FALLOC_FL_PUNCH_HOLE,
|
||||||
|
libc::FALLOC_FL_COLLAPSE_RANGE,
|
||||||
|
libc::FALLOC_FL_INSERT_RANGE,
|
||||||
|
libc::FALLOC_FL_UNSHARE_RANGE,
|
||||||
|
] {
|
||||||
|
assert_eq!(falloc_kind(mode), None, "{mode:#x}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A zeroed range was left unwritten, so the commit filled it back in
|
||||||
|
/// from the remote base (`docs/BUGS.md` B224).
|
||||||
|
#[test]
|
||||||
|
fn a_zeroed_or_punched_range_counts_as_written() {
|
||||||
|
let zero = FallocKind::Zero { keep_size: false };
|
||||||
|
assert_eq!(zero.effect(10, 20, 100), (Some((10, 20)), None));
|
||||||
|
assert_eq!(zero.effect(90, 120, 100), (Some((90, 120)), Some(120)));
|
||||||
|
let zero_keep = FallocKind::Zero { keep_size: true };
|
||||||
|
assert_eq!(zero_keep.effect(90, 120, 100), (Some((90, 100)), None));
|
||||||
|
assert_eq!(
|
||||||
|
FallocKind::Punch.effect(90, 120, 100),
|
||||||
|
(Some((90, 100)), None)
|
||||||
|
);
|
||||||
|
assert_eq!(FallocKind::Punch.effect(120, 130, 100), (None, None));
|
||||||
|
let alloc = FallocKind::Allocate { keep_size: false };
|
||||||
|
assert_eq!(alloc.effect(0, 50, 100), (None, None));
|
||||||
|
assert_eq!(alloc.effect(90, 120, 100), (Some((100, 120)), Some(120)));
|
||||||
|
assert_eq!(
|
||||||
|
FallocKind::Allocate { keep_size: true }.effect(90, 120, 100),
|
||||||
|
(None, None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
-36
@@ -116,6 +116,7 @@ mod upload;
|
|||||||
mod r#virtual;
|
mod r#virtual;
|
||||||
mod workers;
|
mod workers;
|
||||||
use background::{forget_and_notify, run_event_sync, run_local_index, run_photos_event_sync};
|
use background::{forget_and_notify, run_event_sync, run_local_index, run_photos_event_sync};
|
||||||
|
use link::BATCH_CALL_TIMEOUT;
|
||||||
pub(crate) use mount::is_stale_mount;
|
pub(crate) use mount::is_stale_mount;
|
||||||
pub use mount::{MountOptions, MountOutcome, mount};
|
pub use mount::{MountOptions, MountOutcome, mount};
|
||||||
use mount::{
|
use mount::{
|
||||||
@@ -2120,12 +2121,39 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `ino`'s listing is already in memory, i.e. whether
|
/// Whether [`Core::ensure_children`] would answer for `ino` without
|
||||||
/// [`Core::ensure_children`] would return without touching the network.
|
/// touching the network. Lets a handler decide between answering inline
|
||||||
/// Lets a handler decide between answering inline and handing off to a
|
/// and handing off to a worker.
|
||||||
/// worker, at the cost of one uncontended map lookup.
|
///
|
||||||
|
/// A resident listing is not enough on its own. A shared folder's, or the
|
||||||
|
/// Shared with me root's, is fetched again once it is a minute old, and
|
||||||
|
/// that fetch ran on the dispatch loop, so every request on the mount
|
||||||
|
/// waited for Drive to answer (`docs/BUGS.md` B219).
|
||||||
fn children_cached(&self, ino: u64) -> bool {
|
fn children_cached(&self, ino: u64) -> bool {
|
||||||
self.state().children.contains_key(&ino)
|
let (uid, resident) = {
|
||||||
|
let st = self.state();
|
||||||
|
match st.entries.get(&ino) {
|
||||||
|
Some(e) => (e.uid.clone(), st.children.contains_key(&ino)),
|
||||||
|
// `list_children` answers ENOENT without the network.
|
||||||
|
None => return true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !resident {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let freshness = if is_virtual_uid(&uid) {
|
||||||
|
Some(SHARED_WITH_ME_SYNCED_MS.to_string())
|
||||||
|
} else if !self.is_own_or_virtual(&uid) && !is_local_uid(&uid) {
|
||||||
|
Some(Self::shared_folder_freshness_key(&uid))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
freshness.is_none_or(|key| {
|
||||||
|
!listing_needs_refresh(
|
||||||
|
self.online.load(Ordering::Relaxed),
|
||||||
|
self.shared_listing_stale(&key),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-apply the optimistic size of any queued write to `nodes`.
|
/// Re-apply the optimistic size of any queued write to `nodes`.
|
||||||
@@ -2293,8 +2321,7 @@ impl Core {
|
|||||||
|
|
||||||
let generation = self.shared_generation.load(Ordering::SeqCst);
|
let generation = self.shared_generation.load(Ordering::SeqCst);
|
||||||
let accepted = accepted_share_provenance(
|
let accepted = accepted_share_provenance(
|
||||||
self.rt
|
self.block_on_within(BATCH_CALL_TIMEOUT, self.client.enumerate_shared_with_me())
|
||||||
.block_on(self.client.enumerate_shared_with_me())
|
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
error!(%error, "enumerating shared roots failed");
|
error!(%error, "enumerating shared roots failed");
|
||||||
Errno::EIO
|
Errno::EIO
|
||||||
@@ -2303,12 +2330,14 @@ impl Core {
|
|||||||
let nodes = if accepted.uids.is_empty() {
|
let nodes = if accepted.uids.is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
self.rt
|
self.block_on_within(
|
||||||
.block_on(self.drive.enumerate_nodes_light(&accepted.uids))
|
BATCH_CALL_TIMEOUT,
|
||||||
.map_err(|error| {
|
self.drive.enumerate_nodes_light(&accepted.uids),
|
||||||
error!(%error, "materializing shared roots failed");
|
)
|
||||||
Errno::EIO
|
.map_err(|error| {
|
||||||
})?
|
error!(%error, "materializing shared roots failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?
|
||||||
};
|
};
|
||||||
let virtual_uid = shared_with_me_uid();
|
let virtual_uid = shared_with_me_uid();
|
||||||
let published = prepare_shared_roots(&accepted, nodes, &virtual_uid);
|
let published = prepare_shared_roots(&accepted, nodes, &virtual_uid);
|
||||||
@@ -2687,6 +2716,7 @@ impl Core {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let preassigned = state::inos_for(&self.db, &filtered_nodes);
|
||||||
let mut st = self.state();
|
let mut st = self.state();
|
||||||
// Lost the race? Another thread already populated it.
|
// Lost the race? Another thread already populated it.
|
||||||
if st.children.contains_key(&ino) {
|
if st.children.contains_key(&ino) {
|
||||||
@@ -2708,15 +2738,14 @@ impl Core {
|
|||||||
})
|
})
|
||||||
.map(|n| n.uid.clone())
|
.map(|n| n.uid.clone())
|
||||||
.collect();
|
.collect();
|
||||||
let inos = st.intern_batch(ino, filtered_nodes);
|
let inos = st.intern_batch_with_inos(ino, filtered_nodes, preassigned);
|
||||||
child_inos.extend(inos);
|
child_inos.extend(inos);
|
||||||
st.children.insert(ino, child_inos);
|
st.children.insert(ino, child_inos);
|
||||||
let root_snapshot = primary_root.then(|| RootListingSnapshot::capture(&st, ino).unwrap());
|
let root_snapshot = primary_root.then(|| RootListingSnapshot::capture(&st, ino).unwrap());
|
||||||
// Record the listing as complete so a later restart (or a trimmed hot
|
// Record the listing as complete so a later restart (or a trimmed hot
|
||||||
// cache) can rebuild it from the DB without the API.
|
// cache) can rebuild it from the DB without the API. Written with the
|
||||||
if let Err(e) = self.db.set_listed(&folder_uid, true) {
|
// children's rows when the lock goes, not under it.
|
||||||
warn!(%folder_uid, error = %e, "db set_listed(true) failed");
|
st.record_listed(folder_uid.clone());
|
||||||
}
|
|
||||||
drop(st);
|
drop(st);
|
||||||
self.flush_access_changes();
|
self.flush_access_changes();
|
||||||
if let Some(snapshot) = root_snapshot {
|
if let Some(snapshot) = root_snapshot {
|
||||||
@@ -2879,7 +2908,8 @@ impl Core {
|
|||||||
let (mut key, mut uids, mut slot) = (key, uids, slot);
|
let (mut key, mut uids, mut slot) = (key, uids, slot);
|
||||||
loop {
|
loop {
|
||||||
for chunk in uids.chunks(SIZE_UPGRADE_CHUNK) {
|
for chunk in uids.chunks(SIZE_UPGRADE_CHUNK) {
|
||||||
let result = self.rt.block_on(self.drive.enumerate_nodes(chunk));
|
let result =
|
||||||
|
self.block_on_within(BATCH_CALL_TIMEOUT, self.drive.enumerate_nodes(chunk));
|
||||||
self.apply_size_upgrade(key, result);
|
self.apply_size_upgrade(key, result);
|
||||||
slot.chunk_done();
|
slot.chunk_done();
|
||||||
}
|
}
|
||||||
@@ -4378,9 +4408,15 @@ impl Core {
|
|||||||
if self.thumbnail_misses.lock().remote_contains(&key, mtime) {
|
if self.thumbnail_misses.lock().remote_contains(&key, mtime) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
// A file manager asks for one per file in a folder, on the metadata
|
||||||
|
// workers. Offline, or on a stalled link, each one held a worker for
|
||||||
|
// the SDK's whole retry budget, and every cold lookup, create and
|
||||||
|
// rename on the mount queued behind them (`docs/BUGS.md` B220).
|
||||||
|
if !self.is_online() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
let bytes = self
|
let bytes = self
|
||||||
.rt
|
.block_on_bounded(self.client.download_thumbnail(&uid, ttype))
|
||||||
.block_on(self.client.download_thumbnail(&uid, ttype))
|
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
warn!(%uid, error = %e, "download thumbnail failed");
|
warn!(%uid, error = %e, "download thumbnail failed");
|
||||||
Errno::EIO
|
Errno::EIO
|
||||||
@@ -6129,11 +6165,61 @@ fn is_gone(e: &(dyn std::error::Error + 'static)) -> bool {
|
|||||||
/// (`sync.rs`, `conflict_path`), so the two halves of the product name the same
|
/// (`sync.rs`, `conflict_path`), so the two halves of the product name the same
|
||||||
/// situation the same way and a user only has to learn it once.
|
/// situation the same way and a user only has to learn it once.
|
||||||
fn conflict_name(name: &str, stamp: i64) -> String {
|
fn conflict_name(name: &str, stamp: i64) -> String {
|
||||||
|
conflict_name_as(name, conflict_label(), stamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`conflict_name`] with the machine's label given.
|
||||||
|
fn conflict_name_as(name: &str, label: &str, stamp: i64) -> String {
|
||||||
let path = Path::new(name);
|
let path = Path::new(name);
|
||||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name);
|
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name);
|
||||||
|
let tag = conflict_tag(label, stamp, 0);
|
||||||
match path.extension().and_then(|s| s.to_str()) {
|
match path.extension().and_then(|s| s.to_str()) {
|
||||||
Some(ext) => format!("{stem} (sync-conflict {stamp}).{ext}"),
|
Some(ext) => format!("{stem} (sync-conflict {tag}).{ext}"),
|
||||||
None => format!("{stem} (sync-conflict {stamp})"),
|
None => format!("{stem} (sync-conflict {tag})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This machine's name as its conflict copies carry it (`docs/BUGS.md` B251).
|
||||||
|
///
|
||||||
|
/// Two computers syncing one folder that each keep a copy of the same file in
|
||||||
|
/// the same second would otherwise make the same name on Drive, and nobody
|
||||||
|
/// could tell which computer a copy came from. Read once, from the hostname.
|
||||||
|
fn conflict_label() -> &'static str {
|
||||||
|
static LABEL: OnceLock<String> = OnceLock::new();
|
||||||
|
LABEL.get_or_init(|| conflict_label_of(&this_hostname()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `host` as one token: anything but ASCII letters, digits, `-`, `_` and `.`
|
||||||
|
/// becomes `-`, so the label holds no space for `conflict_base_name` to trip
|
||||||
|
/// on and no `/` or `)` to break the name.
|
||||||
|
fn conflict_label_of(host: &str) -> String {
|
||||||
|
let label: String = host
|
||||||
|
.chars()
|
||||||
|
.take(32)
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
label.trim_matches('-').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inside of a conflict copy's marker: `<label> <stamp>`, with `-<n>`
|
||||||
|
/// when an earlier copy already holds that name. Without a label it is the
|
||||||
|
/// bare stamp that copies had before B251.
|
||||||
|
fn conflict_tag(label: &str, stamp: i64, suffix: u32) -> String {
|
||||||
|
let suffix = if suffix == 0 {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("-{suffix}")
|
||||||
|
};
|
||||||
|
if label.is_empty() {
|
||||||
|
format!("{stamp}{suffix}")
|
||||||
|
} else {
|
||||||
|
format!("{label} {stamp}{suffix}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6743,7 +6829,27 @@ fn fuse_name(name: &OsStr) -> Result<String, Errno> {
|
|||||||
name.to_str().map(str::to_owned).ok_or(Errno::EILSEQ)
|
name.to_str().map(str::to_owned).ok_or(Errno::EILSEQ)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a decrypted remote name can stand as one local path component.
|
||||||
|
///
|
||||||
|
/// Drive checks names only when a client creates or renames a node, and only
|
||||||
|
/// for length, so a name made by another client, or by a server that should
|
||||||
|
/// not be trusted, can hold anything. Joined onto a local path, a `/` or a `..`
|
||||||
|
/// in it walks out of the folder it belongs to, and the mirror engine would
|
||||||
|
/// then write or delete outside the sync root. In the mount, the kernel fails
|
||||||
|
/// the whole listing with EIO on a single such name (`docs/BUGS.md` B215).
|
||||||
|
pub(crate) fn remote_name_is_safe(name: &str) -> bool {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name != "."
|
||||||
|
&& name != ".."
|
||||||
|
&& name.len() <= 255
|
||||||
|
&& !name.contains(['/', '\0'])
|
||||||
|
}
|
||||||
|
|
||||||
fn node_visible(node: &Node, folder_uid: &NodeUid, hidden: &HashSet<NodeUid>) -> bool {
|
fn node_visible(node: &Node, folder_uid: &NodeUid, hidden: &HashSet<NodeUid>) -> bool {
|
||||||
|
if !remote_name_is_safe(&node.name) {
|
||||||
|
warn!(uid = %node.uid, "a remote name cannot be a local file name; leaving it out");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
!node.trashed && node.uid != *folder_uid && !hidden.contains(&node.uid)
|
!node.trashed && node.uid != *folder_uid && !hidden.contains(&node.uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7492,14 +7598,15 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
||||||
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
||||||
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
|
accepted_share_provenance, conflict_label, conflict_label_of, conflict_name,
|
||||||
first_seen_in_trash, fuse_name, is_stale_mount, node_visible, note_self_change,
|
conflict_name_as, copy_pending_for_truncate, expand_restore, first_seen_in_trash,
|
||||||
off_the_runtime, orphan_past_retention, parse_node_uid, prepare_shared_roots,
|
fuse_name, is_stale_mount, node_visible, note_self_change, off_the_runtime,
|
||||||
preserve_on_access_denied, publish_virtual_root_in_listing,
|
orphan_past_retention, parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
|
||||||
reconcile_virtual_root_in_listing, release_can_discard_unlinked,
|
publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
|
||||||
release_must_retain_queued_trash, release_unlinked_entry, rename_needs_queue,
|
release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
|
||||||
require_node_parent_access, require_rename_access, resolve_anywhere_with,
|
rename_needs_queue, require_node_parent_access, require_rename_access,
|
||||||
shared_with_me_uid, take_self_change, uid_write_authority, virtual_node,
|
resolve_anywhere_with, shared_with_me_uid, take_self_change, uid_write_authority,
|
||||||
|
virtual_node,
|
||||||
};
|
};
|
||||||
use super::{Db, WriteAuthority};
|
use super::{Db, WriteAuthority};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -8454,15 +8561,38 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn conflict_name_keeps_the_extension() {
|
fn conflict_name_keeps_the_extension() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
conflict_name("notes.txt", 1700),
|
conflict_name_as("notes.txt", "laptop", 1700),
|
||||||
"notes (sync-conflict 1700).txt"
|
"notes (sync-conflict laptop 1700).txt"
|
||||||
);
|
);
|
||||||
assert_eq!(conflict_name("README", 42), "README (sync-conflict 42)");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
conflict_name("archive.tar.gz", 7),
|
conflict_name_as("README", "laptop", 42),
|
||||||
"archive.tar (sync-conflict 7).gz",
|
"README (sync-conflict laptop 42)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conflict_name_as("archive.tar.gz", "laptop", 7),
|
||||||
|
"archive.tar (sync-conflict laptop 7).gz",
|
||||||
"only the last extension is one, as everywhere else"
|
"only the last extension is one, as everywhere else"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conflict_name_as("notes.txt", "", 1700),
|
||||||
|
"notes (sync-conflict 1700).txt"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conflict_name("notes.txt", 1700),
|
||||||
|
conflict_name_as("notes.txt", conflict_label(), 1700)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hostname goes into a file name, so it is cut down to one safe token
|
||||||
|
/// (B251).
|
||||||
|
#[test]
|
||||||
|
fn a_hostname_becomes_one_safe_token() {
|
||||||
|
assert_eq!(conflict_label_of("laptop"), "laptop");
|
||||||
|
assert_eq!(conflict_label_of("my-pc.local"), "my-pc.local");
|
||||||
|
assert_eq!(conflict_label_of("Linux device"), "Linux-device");
|
||||||
|
assert_eq!(conflict_label_of("a/b) c"), "a-b--c");
|
||||||
|
assert_eq!(conflict_label_of(" "), "");
|
||||||
|
assert_eq!(conflict_label_of(&"x".repeat(40)).len(), 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -8654,6 +8784,28 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_remote_name_that_is_not_one_path_component_is_left_out() {
|
||||||
|
let folder_uid = NodeUid::new(VolumeId::from("vol"), LinkId::from("folder"));
|
||||||
|
let none = std::collections::HashSet::new();
|
||||||
|
for name in [
|
||||||
|
"a/b",
|
||||||
|
"../../.bashrc",
|
||||||
|
"..",
|
||||||
|
".",
|
||||||
|
"",
|
||||||
|
"a\0b",
|
||||||
|
&"x".repeat(256),
|
||||||
|
] {
|
||||||
|
let node = node_helper("child", "folder", name, false);
|
||||||
|
assert!(!node_visible(&node, &folder_uid, &none), "{name:?}");
|
||||||
|
}
|
||||||
|
for name in [".hidden", "a..b", "unicodé-文件", &"x".repeat(255)] {
|
||||||
|
let node = node_helper("child", "folder", name, false);
|
||||||
|
assert!(node_visible(&node, &folder_uid, &none), "{name:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn denied_queue_guard_preserves_accepted_bytes_and_pending_count() {
|
fn denied_queue_guard_preserves_accepted_bytes_and_pending_count() {
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
@@ -9159,6 +9311,32 @@ mod tests {
|
|||||||
assert!(online.contains("self.relist_offline_listings();"));
|
assert!(online.contains("self.relist_offline_listings();"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stale_shared_listing_is_fetched_off_the_dispatch_loop() {
|
||||||
|
// A resident listing that `list_children` would fetch again anyway
|
||||||
|
// must not be answered inline (B219).
|
||||||
|
let cached = function_source(include_str!("lib.rs"), "fn children_cached(");
|
||||||
|
assert!(cached.contains("SHARED_WITH_ME_SYNCED_MS"));
|
||||||
|
assert!(cached.contains("Self::shared_folder_freshness_key(&uid)"));
|
||||||
|
assert!(cached.contains("listing_needs_refresh("));
|
||||||
|
assert!(cached.contains("self.shared_listing_stale(&key)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_handler_does_not_wait_out_the_sdk_retry_budget() {
|
||||||
|
// Thumbnails, size upgrades and the share list held mount threads for
|
||||||
|
// minutes on a stalled link (B220).
|
||||||
|
let lib = include_str!("lib.rs");
|
||||||
|
let thumbnail = function_source(lib, "fn thumbnail(");
|
||||||
|
assert_before(thumbnail, "if !self.is_online()", "download_thumbnail");
|
||||||
|
assert!(thumbnail.contains(".block_on_bounded(self.client.download_thumbnail("));
|
||||||
|
let upgrade = function_source(lib, "fn run_size_upgrade(");
|
||||||
|
assert!(upgrade.contains("block_on_within(BATCH_CALL_TIMEOUT"));
|
||||||
|
let shared = function_source(lib, "fn ensure_shared_children(");
|
||||||
|
assert!(shared.contains("block_on_within(BATCH_CALL_TIMEOUT"));
|
||||||
|
assert!(!shared.contains("self.rt"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_remote_read_gives_up_at_its_deadline() {
|
fn a_remote_read_gives_up_at_its_deadline() {
|
||||||
// The SDK bounds requests, not reads; a stalled link held one `read`
|
// The SDK bounds requests, not reads; a stalled link held one `read`
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ use crate::{Core, ONLINE_PROBE_MAX, ONLINE_PROBE_MIN};
|
|||||||
/// keep waiting for; queueing the change is the better answer.
|
/// keep waiting for; queueing the change is the better answer.
|
||||||
pub(crate) const INTERACTIVE_CALL_TIMEOUT: Duration = Duration::from_secs(20);
|
pub(crate) const INTERACTIVE_CALL_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
/// How long a mount thread waits on a remote read that answers a batch: a
|
||||||
|
/// chunk of file sizes, or the list of shares. Longer than an interactive call,
|
||||||
|
/// since its answer grows with the batch, but well short of the SDK's own retry
|
||||||
|
/// budget, which held a worker for minutes on a stalled link
|
||||||
|
/// (`docs/BUGS.md` B220).
|
||||||
|
pub(crate) const BATCH_CALL_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
/// How long one probe may wait for an answer. A probe is a single small request,
|
/// How long one probe may wait for an answer. A probe is a single small request,
|
||||||
/// and a probe stuck in the SDK's retry budget would delay noticing the link is
|
/// and a probe stuck in the SDK's retry budget would delay noticing the link is
|
||||||
/// back by as long as it waits.
|
/// back by as long as it waits.
|
||||||
@@ -112,10 +119,25 @@ pub(crate) fn no_answer(after: Duration) -> ProtonError {
|
|||||||
/// transfer that has stopped moving runs into it.
|
/// transfer that has stopped moving runs into it.
|
||||||
const SLOWEST_UPLOAD: u64 = 32 * 1024;
|
const SLOWEST_UPLOAD: u64 = 32 * 1024;
|
||||||
|
|
||||||
|
/// How many uploads can share the upload cap at once: one per drain worker
|
||||||
|
/// and one per mirror sync task.
|
||||||
|
const UPLOAD_SLOTS: u64 = (crate::DRAIN_WORKERS + crate::sync::SYNC_CONCURRENCY) as u64;
|
||||||
|
|
||||||
/// How long an upload of `len` bytes may take before the link is treated as
|
/// How long an upload of `len` bytes may take before the link is treated as
|
||||||
/// down: the budget of a metadata call, plus the bytes at [`SLOWEST_UPLOAD`].
|
/// down: the budget of a metadata call, plus the bytes at [`SLOWEST_UPLOAD`].
|
||||||
pub(crate) fn upload_deadline(len: u64) -> Duration {
|
///
|
||||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / SLOWEST_UPLOAD)
|
/// An upload cap of `cap` bytes per second (`0` for none) is shared by every
|
||||||
|
/// upload running, so each may get as little as its share of it. Below
|
||||||
|
/// [`SLOWEST_UPLOAD`] a share counts as the slowest rate instead: measured
|
||||||
|
/// against the fixed rate, a large file under a low cap ran out of time on
|
||||||
|
/// every attempt, took the mount offline each time, and started over from
|
||||||
|
/// its first byte.
|
||||||
|
pub(crate) fn upload_deadline(len: u64, cap: u64) -> Duration {
|
||||||
|
let slowest = match cap {
|
||||||
|
0 => SLOWEST_UPLOAD,
|
||||||
|
cap => SLOWEST_UPLOAD.min((cap / UPLOAD_SLOTS).max(1)),
|
||||||
|
};
|
||||||
|
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / slowest)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
|
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
|
||||||
@@ -258,8 +280,9 @@ impl Core {
|
|||||||
self.relist_offline_listings();
|
self.relist_offline_listings();
|
||||||
// Anything written while offline is queued and waiting on exactly this,
|
// Anything written while offline is queued and waiting on exactly this,
|
||||||
// including ops that failed on the way down and are sitting out a
|
// including ops that failed on the way down and are sitting out a
|
||||||
// backoff sized for a server that keeps refusing.
|
// backoff sized for a server that keeps refusing. Ops Drive refused
|
||||||
if let Err(e) = self.db.retry_failed_ops_now(crate::now_millis()) {
|
// keep theirs: the link was not why.
|
||||||
|
if let Err(e) = self.db.retry_unrefused_ops_now(crate::now_millis()) {
|
||||||
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
||||||
}
|
}
|
||||||
self.wake_drain();
|
self.wake_drain();
|
||||||
@@ -403,13 +426,31 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_upload_gets_longer_the_bigger_it_is() {
|
fn an_upload_gets_longer_the_bigger_it_is() {
|
||||||
assert_eq!(upload_deadline(0), INTERACTIVE_CALL_TIMEOUT);
|
assert_eq!(upload_deadline(0, 0), INTERACTIVE_CALL_TIMEOUT);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
upload_deadline(1 << 30),
|
upload_deadline(1 << 30, 0),
|
||||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(32_768)
|
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(32_768)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_upload_under_a_low_cap_gets_the_time_its_share_of_the_cap_needs() {
|
||||||
|
// A generous cap leaves the deadline as it was.
|
||||||
|
assert_eq!(
|
||||||
|
upload_deadline(1 << 30, 100 << 20),
|
||||||
|
upload_deadline(1 << 30, 0)
|
||||||
|
);
|
||||||
|
// 512 KiB/s shared by every upload slot is below the slowest rate.
|
||||||
|
let share = (512 << 10) / UPLOAD_SLOTS;
|
||||||
|
assert!(share < SLOWEST_UPLOAD);
|
||||||
|
assert_eq!(
|
||||||
|
upload_deadline(1 << 30, 512 << 10),
|
||||||
|
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs((1 << 30) / share)
|
||||||
|
);
|
||||||
|
// A cap below one byte per slot still gives a finite deadline.
|
||||||
|
assert!(upload_deadline(1 << 20, 1) > upload_deadline(1 << 20, 512 << 10));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_call_past_its_deadline_reads_as_a_lost_link() {
|
fn a_call_past_its_deadline_reads_as_a_lost_link() {
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
|||||||
@@ -226,11 +226,7 @@ fn scale_thumbnail_with_exiftool(
|
|||||||
exiftool: &OsStr,
|
exiftool: &OsStr,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> ScaleAttempt {
|
) -> ScaleAttempt {
|
||||||
let direct = image::ImageReader::new(std::io::Cursor::new(bytes))
|
let image = match decode_upright(bytes) {
|
||||||
.with_guessed_format()
|
|
||||||
.ok()
|
|
||||||
.and_then(|reader| reader.decode().ok());
|
|
||||||
let image = match direct {
|
|
||||||
Some(image) => image,
|
Some(image) => image,
|
||||||
None if is_raw_image_name(name) => {
|
None if is_raw_image_name(name) => {
|
||||||
let (preview, orientation) =
|
let (preview, orientation) =
|
||||||
@@ -256,6 +252,27 @@ fn scale_thumbnail_with_exiftool(
|
|||||||
encode_thumb(&image)
|
encode_thumb(&image)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode `bytes` and turn the picture the way its EXIF orientation says.
|
||||||
|
///
|
||||||
|
/// `ImageReader::decode` does not: a phone stores a portrait photo as landscape
|
||||||
|
/// pixels plus an orientation tag, so every such thumbnail came out on its
|
||||||
|
/// side, and the drain uploaded it to Drive that way for every client to show
|
||||||
|
/// (`docs/BUGS.md` B226).
|
||||||
|
fn decode_upright(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||||
|
use image::ImageDecoder as _;
|
||||||
|
let mut decoder = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||||
|
.with_guessed_format()
|
||||||
|
.ok()?
|
||||||
|
.into_decoder()
|
||||||
|
.ok()?;
|
||||||
|
let orientation = decoder
|
||||||
|
.orientation()
|
||||||
|
.unwrap_or(image::metadata::Orientation::NoTransforms);
|
||||||
|
let mut image = image::DynamicImage::from_decoder(decoder).ok()?;
|
||||||
|
image.apply_orientation(orientation);
|
||||||
|
Some(image)
|
||||||
|
}
|
||||||
|
|
||||||
/// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio.
|
/// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio.
|
||||||
fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt {
|
fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt {
|
||||||
let (width, height) = (image.width(), image.height());
|
let (width, height) = (image.width(), image.height());
|
||||||
@@ -417,6 +434,16 @@ fn exiftool_query(
|
|||||||
serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
|
serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether [`upload_thumbnails`] can make a thumbnail for a file `name` of
|
||||||
|
/// `size` bytes, asked before the file is on disk: an image small enough to
|
||||||
|
/// decode, or a video while ffmpeg is installed.
|
||||||
|
pub(crate) fn can_make_thumbnail(name: &str, size: u64) -> bool {
|
||||||
|
if is_video_name(name) {
|
||||||
|
return ffmpeg_available();
|
||||||
|
}
|
||||||
|
is_thumbnail_image_name(name) && size <= UPLOAD_IMAGE_MAX_BYTES
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather
|
/// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather
|
||||||
/// than a picture the image decoder can scale.
|
/// than a picture the image decoder can scale.
|
||||||
fn is_video_name(name: &str) -> bool {
|
fn is_video_name(name: &str) -> bool {
|
||||||
@@ -2092,9 +2119,10 @@ impl Core {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod thumb_tests {
|
mod thumb_tests {
|
||||||
use super::{
|
use super::{
|
||||||
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
|
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
|
||||||
fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
|
can_make_thumbnail, exiftool_binary, ffmpeg_available, fit_upload_thumb, is_video_name,
|
||||||
similar_hashes, thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool, similar_hashes,
|
||||||
|
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
||||||
};
|
};
|
||||||
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
||||||
use proton_drive_rs::ThumbnailType;
|
use proton_drive_rs::ThumbnailType;
|
||||||
@@ -2149,6 +2177,64 @@ mod thumb_tests {
|
|||||||
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
|
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `jpeg` with an EXIF APP1 segment that carries only `orientation`.
|
||||||
|
fn jpeg_with_orientation(width: u32, height: u32, orientation: u16) -> Vec<u8> {
|
||||||
|
let plain = jpeg(width, height);
|
||||||
|
let mut tiff = Vec::new();
|
||||||
|
tiff.extend_from_slice(b"II*\0");
|
||||||
|
tiff.extend_from_slice(&8u32.to_le_bytes());
|
||||||
|
tiff.extend_from_slice(&1u16.to_le_bytes());
|
||||||
|
tiff.extend_from_slice(&0x0112u16.to_le_bytes());
|
||||||
|
tiff.extend_from_slice(&3u16.to_le_bytes());
|
||||||
|
tiff.extend_from_slice(&1u32.to_le_bytes());
|
||||||
|
tiff.extend_from_slice(&orientation.to_le_bytes());
|
||||||
|
tiff.extend_from_slice(&[0, 0]);
|
||||||
|
tiff.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
let mut app1 = b"Exif\0\0".to_vec();
|
||||||
|
app1.extend_from_slice(&tiff);
|
||||||
|
let mut out = plain[..2].to_vec();
|
||||||
|
out.extend_from_slice(&[0xFF, 0xE1]);
|
||||||
|
out.extend_from_slice(&u16::try_from(app1.len() + 2).unwrap().to_be_bytes());
|
||||||
|
out.extend_from_slice(&app1);
|
||||||
|
out.extend_from_slice(&plain[2..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A phone stores a portrait photo as landscape pixels and an EXIF
|
||||||
|
/// orientation; its thumbnail came out on its side (`docs/BUGS.md` B226).
|
||||||
|
/// A Takeout photo is copied out of its archive only when a thumbnail can
|
||||||
|
/// be made from the copy.
|
||||||
|
#[test]
|
||||||
|
fn only_a_photo_that_can_have_a_thumbnail_is_worth_copying_out() {
|
||||||
|
assert!(can_make_thumbnail("IMG_1.jpg", 4 << 20));
|
||||||
|
assert!(can_make_thumbnail("Screenshot.png", 4 << 20));
|
||||||
|
assert!(!can_make_thumbnail("IMG_1.jpg", UPLOAD_IMAGE_MAX_BYTES + 1));
|
||||||
|
assert!(!can_make_thumbnail("notes.txt", 10));
|
||||||
|
assert_eq!(can_make_thumbnail("clip.mp4", 1 << 30), ffmpeg_available());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_thumbnail_is_turned_the_way_its_exif_says() {
|
||||||
|
let ScaleAttempt::Made(thumb) = scale_thumbnail(
|
||||||
|
&jpeg_with_orientation(2000, 1000, 6),
|
||||||
|
"portrait.jpg",
|
||||||
|
&std::env::temp_dir(),
|
||||||
|
) else {
|
||||||
|
panic!("a JPEG scales");
|
||||||
|
};
|
||||||
|
assert!(thumb.ratio < 1.0, "turned upright: {}", thumb.ratio);
|
||||||
|
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
|
||||||
|
|
||||||
|
let ScaleAttempt::Made(level) = scale_thumbnail(
|
||||||
|
&jpeg_with_orientation(2000, 1000, 1),
|
||||||
|
"landscape.jpg",
|
||||||
|
&std::env::temp_dir(),
|
||||||
|
) else {
|
||||||
|
panic!("a JPEG scales");
|
||||||
|
};
|
||||||
|
assert!(level.ratio > 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn undecodable_bytes_are_not_a_thumbnail() {
|
fn undecodable_bytes_are_not_a_thumbnail() {
|
||||||
// What a photo in a format this build has no decoder for looks like: the
|
// What a photo in a format this build has no decoder for looks like: the
|
||||||
|
|||||||
+113
-47
@@ -15,6 +15,7 @@ use pdfs_core::control::RestorableFolder;
|
|||||||
use pdfs_core::mounts::MountMode;
|
use pdfs_core::mounts::MountMode;
|
||||||
use pdfs_core::profile::{
|
use pdfs_core::profile::{
|
||||||
PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin,
|
PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin,
|
||||||
|
is_profile_file_name, profile_file_name, saved_folder,
|
||||||
};
|
};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
@@ -66,9 +67,14 @@ impl Core {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize this machine's arrangement and upload it to `profile.json`
|
/// Serialize this machine's arrangement and upload it to its own
|
||||||
/// inside the device's `.proton-drive-linux` folder, replacing any existing
|
/// `profile-<machine id>.json` inside the device's `.proton-drive-linux`
|
||||||
/// one.
|
/// folder, replacing any existing one.
|
||||||
|
///
|
||||||
|
/// Each machine writes its own file because several computers may sync
|
||||||
|
/// into one device. They used to share `profile.json`, and each save
|
||||||
|
/// replaced the other computers' folders with its own (B252). That file is
|
||||||
|
/// left as it is, so a client before 3.5.0 still finds it.
|
||||||
///
|
///
|
||||||
/// Does nothing when no device is registered: a machine that has never
|
/// Does nothing when no device is registered: a machine that has never
|
||||||
/// synced a folder has nothing to describe, and registering a device just to
|
/// synced a folder has nothing to describe, and registering a device just to
|
||||||
@@ -85,7 +91,11 @@ impl Core {
|
|||||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let profile = self.build_profile(&device.uid)?;
|
let machine_id = pdfs_core::config::AppDirs::new()
|
||||||
|
.and_then(|dirs| dirs.machine_id())
|
||||||
|
.map_err(|e| CoreError::internal(format!("machine id: {e}")))?;
|
||||||
|
let file_name = profile_file_name(&machine_id);
|
||||||
|
let profile = self.build_profile(&device.uid, &machine_id)?;
|
||||||
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
|
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
|
||||||
let len = bytes.len() as i64;
|
let len = bytes.len() as i64;
|
||||||
|
|
||||||
@@ -97,7 +107,7 @@ impl Core {
|
|||||||
// same name: the profile is a single mutable record, and a folder with
|
// same name: the profile is a single mutable record, and a folder with
|
||||||
// `profile.json` and `profile (1).json` in it teaches the restore
|
// `profile.json` and `profile (1).json` in it teaches the restore
|
||||||
// nothing about which to trust.
|
// nothing about which to trust.
|
||||||
let existing = self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?;
|
let existing = self.find_device_child_file(&dir_uid, &file_name)?;
|
||||||
match existing {
|
match existing {
|
||||||
Some(uid) => self
|
Some(uid) => self
|
||||||
.rt
|
.rt
|
||||||
@@ -114,7 +124,7 @@ impl Core {
|
|||||||
self.rt
|
self.rt
|
||||||
.block_on(self.drive.upload_file_replacing_draft_from(
|
.block_on(self.drive.upload_file_replacing_draft_from(
|
||||||
&dir_uid,
|
&dir_uid,
|
||||||
PROFILE_FILE_NAME,
|
&file_name,
|
||||||
"application/json",
|
"application/json",
|
||||||
Cursor::new(bytes),
|
Cursor::new(bytes),
|
||||||
len,
|
len,
|
||||||
@@ -130,7 +140,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot the local arrangement into a [`Profile`].
|
/// Snapshot the local arrangement into a [`Profile`].
|
||||||
fn build_profile(&self, device_uid: &str) -> CoreResult<Profile> {
|
fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult<Profile> {
|
||||||
let folders = self
|
let folders = self
|
||||||
.db
|
.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
@@ -162,6 +172,7 @@ impl Core {
|
|||||||
version: PROFILE_VERSION,
|
version: PROFILE_VERSION,
|
||||||
device_uid: device_uid.to_string(),
|
device_uid: device_uid.to_string(),
|
||||||
hostname: this_hostname(),
|
hostname: this_hostname(),
|
||||||
|
machine_id: Some(machine_id.to_string()),
|
||||||
saved_at: now_secs(),
|
saved_at: now_secs(),
|
||||||
folders,
|
folders,
|
||||||
pins,
|
pins,
|
||||||
@@ -197,6 +208,31 @@ impl Core {
|
|||||||
.map(|n| n.uid))
|
.map(|n| n.uid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The untrashed files directly under `parent_uid` whose name passes
|
||||||
|
/// `keep`.
|
||||||
|
fn device_child_files(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
keep: impl Fn(&str) -> bool,
|
||||||
|
) -> CoreResult<Vec<NodeUid>> {
|
||||||
|
let uids = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.drive.enumerate_folder_children_node_uids(parent_uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||||
|
if uids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let nodes = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.drive.enumerate_nodes(&uids))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||||
|
Ok(nodes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| !n.is_folder() && !n.trashed && keep(&n.name))
|
||||||
|
.map(|n| n.uid)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// The device's `.proton-drive-linux` folder, creating it if this is the
|
/// The device's `.proton-drive-linux` folder, creating it if this is the
|
||||||
/// first save on this device.
|
/// first save on this device.
|
||||||
///
|
///
|
||||||
@@ -218,32 +254,43 @@ impl Core {
|
|||||||
Ok(uid)
|
Ok(uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download and parse this device's profile, if there is one.
|
/// Download and parse the profiles of every computer that syncs this
|
||||||
|
/// device.
|
||||||
///
|
///
|
||||||
/// Looks in `.proton-drive-linux/` first and falls back to the device root,
|
/// Reads every profile in `.proton-drive-linux/`: one per machine, and the
|
||||||
/// where clients before the B78 fix wrote it. The fallback is read-only: the
|
/// shared `profile.json` of clients before 3.5.0. Falls back to the device
|
||||||
/// next save writes to the folder, and the legacy document is left alone
|
/// root, where clients before the B78 fix wrote it. The fallback is
|
||||||
/// rather than trashed, so an older client on another machine keeps working.
|
/// read-only: the next save writes to the folder, and the legacy document
|
||||||
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> {
|
/// is left alone rather than trashed, so an older client on another
|
||||||
let current = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
/// machine keeps working. A profile that cannot be read is left out and
|
||||||
Some(dir_uid) => self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?,
|
/// logged; the others still count.
|
||||||
None => None,
|
fn load_profiles(&self, root_uid: &NodeUid) -> CoreResult<Vec<Profile>> {
|
||||||
|
let mut uids = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||||
|
Some(dir_uid) => self.device_child_files(&dir_uid, is_profile_file_name)?,
|
||||||
|
None => Vec::new(),
|
||||||
};
|
};
|
||||||
let uid = match current {
|
if uids.is_empty() {
|
||||||
Some(uid) => uid,
|
uids = self.device_child_files(root_uid, |name| name == PROFILE_FILE_NAME)?;
|
||||||
None => match self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? {
|
if !uids.is_empty() {
|
||||||
Some(uid) => {
|
info!("reading legacy profile from the device root");
|
||||||
info!("reading legacy profile from the device root");
|
}
|
||||||
uid
|
}
|
||||||
|
let mut profiles = Vec::new();
|
||||||
|
for uid in uids {
|
||||||
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
|
let parsed = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.drive.download_file_to(&uid, &mut buf))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "download profile"))
|
||||||
|
.and_then(|()| Profile::parse(&buf).map_err(CoreError::invalid));
|
||||||
|
match parsed {
|
||||||
|
Ok(profile) => profiles.push(profile),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(%uid, error = %e.message, "could not read a profile; leaving it out")
|
||||||
}
|
}
|
||||||
None => return Ok(None),
|
}
|
||||||
},
|
}
|
||||||
};
|
Ok(profiles)
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
|
||||||
self.rt
|
|
||||||
.block_on(self.drive.download_file_to(&uid, &mut buf))
|
|
||||||
.map_err(|e| CoreError::from_api(&e, "download profile"))?;
|
|
||||||
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- restore ----------------------------------------
|
// ---- restore ----------------------------------------
|
||||||
@@ -265,15 +312,16 @@ impl Core {
|
|||||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let profile = match self.load_profile(&root_uid) {
|
let profiles = match self.load_profiles(&root_uid) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
// A missing or unreadable profile is not fatal: the remote tree
|
// A missing or unreadable profile is not fatal: the remote tree
|
||||||
// alone is enough to offer a restore, just with plainer defaults.
|
// alone is enough to offer a restore, just with plainer defaults.
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = %e.message, "could not read profile; proposing defaults");
|
warn!(error = %e.message, "could not read profiles; proposing defaults");
|
||||||
None
|
Vec::new()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let hostname = this_hostname();
|
||||||
|
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
@@ -304,11 +352,12 @@ impl Core {
|
|||||||
// offering it as a restorable folder would sync the bookkeeping
|
// offering it as a restorable folder would sync the bookkeeping
|
||||||
// back onto the machine it describes.
|
// back onto the machine it describes.
|
||||||
.filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME)
|
.filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME)
|
||||||
|
// The name becomes a folder under the home folder: one with a `/`
|
||||||
|
// or a `..` in it would put the restore somewhere else.
|
||||||
|
.filter(|n| crate::remote_name_is_safe(&n.name))
|
||||||
.map(|n| {
|
.map(|n| {
|
||||||
let uid = n.uid.to_string();
|
let uid = n.uid.to_string();
|
||||||
let saved = profile
|
let saved = saved_folder(&profiles, &uid, &hostname);
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p.folders.iter().find(|f| f.remote_uid == uid));
|
|
||||||
let proposed = saved
|
let proposed = saved
|
||||||
.map(|f| PathBuf::from(&f.local_path))
|
.map(|f| PathBuf::from(&f.local_path))
|
||||||
.filter(|p| path_is_usable_here(p))
|
.filter(|p| path_is_usable_here(p))
|
||||||
@@ -349,23 +398,29 @@ impl Core {
|
|||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
|
||||||
|
let reserved = crate::devices::sync_root_reserved();
|
||||||
|
let mut roots: Vec<PathBuf> = existing
|
||||||
|
.iter()
|
||||||
|
.map(|f| PathBuf::from(&f.local_path))
|
||||||
|
.collect();
|
||||||
let mut restored = 0usize;
|
let mut restored = 0usize;
|
||||||
let mut skipped: Vec<String> = Vec::new();
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
let local = PathBuf::from(&item.local_path);
|
// The path is proposed from the profile another machine wrote, and
|
||||||
if !local.is_absolute() {
|
// may hold a folder name from Drive: checked like any new root.
|
||||||
skipped.push(format!("{}: local path must be absolute", item.local_path));
|
let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) {
|
||||||
continue;
|
Ok(local) => local,
|
||||||
}
|
Err(e) => {
|
||||||
|
skipped.push(format!("{}: {e}", item.local_path));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
if existing.iter().any(|f| f.remote_uid == item.remote_uid) {
|
if existing.iter().any(|f| f.remote_uid == item.remote_uid) {
|
||||||
skipped.push(format!("{}: already synced", item.local_path));
|
skipped.push(format!("{}: already synced", item.local_path));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if existing
|
if let Some(problem) = pdfs_core::syncroot::problem(&local, &roots, &reserved) {
|
||||||
.iter()
|
skipped.push(format!("{}: {problem}", item.local_path));
|
||||||
.any(|f| Path::new(&f.local_path) == local.as_path())
|
|
||||||
{
|
|
||||||
skipped.push(format!("{}: path already in use", item.local_path));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::create_dir_all(&local) {
|
if let Err(e) = std::fs::create_dir_all(&local) {
|
||||||
@@ -390,6 +445,7 @@ impl Core {
|
|||||||
let _ = self.request_sync_folder_mode(id, "ondemand");
|
let _ = self.request_sync_folder_mode(id, "ondemand");
|
||||||
}
|
}
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||||
|
roots.push(local);
|
||||||
restored += 1;
|
restored += 1;
|
||||||
}
|
}
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
@@ -482,7 +538,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn loading_falls_back_to_a_legacy_device_root_profile() {
|
fn loading_falls_back_to_a_legacy_device_root_profile() {
|
||||||
let source = include_str!("profile.rs");
|
let source = include_str!("profile.rs");
|
||||||
let load = function_source(source, "fn load_profile(");
|
let load = function_source(source, "fn load_profiles(");
|
||||||
assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder");
|
assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder");
|
||||||
assert!(
|
assert!(
|
||||||
load.find("PROFILE_DIR_NAME").unwrap()
|
load.find("PROFILE_DIR_NAME").unwrap()
|
||||||
@@ -491,6 +547,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Several computers may share a device, so each saves to a file named
|
||||||
|
/// after its own machine id and never to the shared `profile.json`
|
||||||
|
/// (B252).
|
||||||
|
#[test]
|
||||||
|
fn each_machine_saves_its_own_profile() {
|
||||||
|
let save = function_source(include_str!("profile.rs"), "fn save_profile(");
|
||||||
|
assert!(save.contains("profile_file_name(&machine_id)"));
|
||||||
|
assert!(!save.contains("PROFILE_FILE_NAME"));
|
||||||
|
}
|
||||||
|
|
||||||
/// The profile folder is the client's own bookkeeping: neither the restore
|
/// The profile folder is the client's own bookkeeping: neither the restore
|
||||||
/// picker nor `add_sync_folder` may treat it as a user folder.
|
/// picker nor `add_sync_folder` may treat it as a user folder.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+123
-31
@@ -1070,37 +1070,35 @@ impl Core {
|
|||||||
offset: u64,
|
offset: u64,
|
||||||
size: u64,
|
size: u64,
|
||||||
) -> Result<Vec<u8>, Errno> {
|
) -> Result<Vec<u8>, Errno> {
|
||||||
if offset >= len || size == 0 {
|
let read = assemble_open_read(file, len, uid, base_size, written, offset, size, |s, n| {
|
||||||
return Ok(Vec::new());
|
self.read_range(uid, base_mtime, base_size, s, n, true)
|
||||||
}
|
.map(Some)
|
||||||
let end = offset.saturating_add(size).min(len);
|
})?;
|
||||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
Ok(read.unwrap_or_default())
|
||||||
for (s, e, authored) in written.segments(offset, end) {
|
}
|
||||||
if authored {
|
|
||||||
let mut buf = vec![0u8; (e - s) as usize];
|
/// [`Core::serve_open_read`] from bytes already on this machine only:
|
||||||
file.read_exact_at(&mut buf, s).map_err(|err| {
|
/// `Ok(None)` the moment a range of the base would have to be downloaded.
|
||||||
warn!(%uid, error = %err, "scratch read failed");
|
///
|
||||||
Errno::EIO
|
/// The dispatch loop answers a read on a write handle with this, and hands
|
||||||
})?;
|
/// it to a worker only when it says `None`. Fetching the base there held
|
||||||
out.extend_from_slice(&buf);
|
/// every request of the mount for as long as the download took, up to the
|
||||||
} else {
|
/// read deadline (`docs/BUGS.md` B227).
|
||||||
let bend = e.min(base_size);
|
#[allow(clippy::too_many_arguments)]
|
||||||
if s < bend {
|
pub(super) fn serve_open_read_local(
|
||||||
out.extend_from_slice(&self.read_range(
|
&self,
|
||||||
uid,
|
file: &Arc<File>,
|
||||||
base_mtime,
|
len: u64,
|
||||||
base_size,
|
uid: &NodeUid,
|
||||||
s,
|
base_mtime: i64,
|
||||||
bend - s,
|
base_size: u64,
|
||||||
true,
|
written: &Intervals,
|
||||||
)?);
|
offset: u64,
|
||||||
}
|
size: u64,
|
||||||
// Anything past the base is a hole: zero-fill.
|
) -> Result<Option<Vec<u8>>, Errno> {
|
||||||
let zeros = e.saturating_sub(s.max(base_size));
|
assemble_open_read(file, len, uid, base_size, written, offset, size, |s, n| {
|
||||||
out.resize(out.len() + zeros as usize, 0);
|
Ok(self.cached_range(uid, base_mtime, base_size, s, n))
|
||||||
}
|
})
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The largest amount [`Core::fill_gaps_cached`] will copy out of the local
|
/// The largest amount [`Core::fill_gaps_cached`] will copy out of the local
|
||||||
@@ -1242,6 +1240,49 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bytes `[offset, offset + size)` of a file open for writing: authored
|
||||||
|
/// ranges from its scratch `file`, the rest from its base through `base`,
|
||||||
|
/// and zeros past the base. `Ok(None)` when `base` says it cannot answer.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn assemble_open_read(
|
||||||
|
file: &File,
|
||||||
|
len: u64,
|
||||||
|
uid: &NodeUid,
|
||||||
|
base_size: u64,
|
||||||
|
written: &Intervals,
|
||||||
|
offset: u64,
|
||||||
|
size: u64,
|
||||||
|
mut base: impl FnMut(u64, u64) -> Result<Option<Vec<u8>>, Errno>,
|
||||||
|
) -> Result<Option<Vec<u8>>, Errno> {
|
||||||
|
if offset >= len || size == 0 {
|
||||||
|
return Ok(Some(Vec::new()));
|
||||||
|
}
|
||||||
|
let end = offset.saturating_add(size).min(len);
|
||||||
|
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||||
|
for (s, e, authored) in written.segments(offset, end) {
|
||||||
|
if authored {
|
||||||
|
let mut buf = vec![0u8; (e - s) as usize];
|
||||||
|
file.read_exact_at(&mut buf, s).map_err(|err| {
|
||||||
|
warn!(%uid, error = %err, "scratch read failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
|
out.extend_from_slice(&buf);
|
||||||
|
} else {
|
||||||
|
let bend = e.min(base_size);
|
||||||
|
if s < bend {
|
||||||
|
match base(s, bend - s)? {
|
||||||
|
Some(bytes) => out.extend_from_slice(&bytes),
|
||||||
|
None => return Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Anything past the base is a hole: zero-fill.
|
||||||
|
let zeros = e.saturating_sub(s.max(base_size));
|
||||||
|
out.resize(out.len() + zeros as usize, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(out))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod block_ring_tests {
|
mod block_ring_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1394,3 +1435,54 @@ mod prefetch_tests {
|
|||||||
assert!(prefetch.streams.lock().len() <= MAX_PREFETCH_STREAMS + 1);
|
assert!(prefetch.streams.lock().len() <= MAX_PREFETCH_STREAMS + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod open_read_tests {
|
||||||
|
use super::*;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||||||
|
|
||||||
|
fn scratch(bytes: &[u8]) -> (File, PathBuf) {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-open-read-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
let mut file = File::options()
|
||||||
|
.create_new(true)
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap();
|
||||||
|
file.write_all(bytes).unwrap();
|
||||||
|
(file, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dispatch loop answers from what is on this machine and says so
|
||||||
|
/// when the base would have to be downloaded, for a worker to do it
|
||||||
|
/// (`docs/BUGS.md` B227).
|
||||||
|
#[test]
|
||||||
|
fn a_read_needing_the_base_says_so_instead_of_fetching() {
|
||||||
|
let uid = NodeUid::new(VolumeId::from("v"), LinkId::from("f"));
|
||||||
|
let (file, path) = scratch(b"ABCDEFGH");
|
||||||
|
let mut written = Intervals::default();
|
||||||
|
written.add(0, 4);
|
||||||
|
|
||||||
|
let authored = assemble_open_read(&file, 8, &uid, 8, &written, 0, 4, |_, _| {
|
||||||
|
panic!("an authored range never asks the base")
|
||||||
|
});
|
||||||
|
assert_eq!(authored.unwrap().as_deref(), Some(&b"ABCD"[..]));
|
||||||
|
|
||||||
|
let missing = assemble_open_read(&file, 8, &uid, 8, &written, 2, 6, |_, _| Ok(None));
|
||||||
|
assert_eq!(missing.unwrap(), None);
|
||||||
|
|
||||||
|
let base = assemble_open_read(&file, 10, &uid, 8, &written, 2, 8, |s, n| {
|
||||||
|
assert_eq!((s, n), (4, 4));
|
||||||
|
Ok(Some(b"efgh".to_vec()))
|
||||||
|
});
|
||||||
|
assert_eq!(base.unwrap().as_deref(), Some(&b"CDefgh\0\0"[..]));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,6 +190,7 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))?
|
.map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))?
|
||||||
.len();
|
.len();
|
||||||
let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream");
|
let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream");
|
||||||
|
let thumbnails = self.upload_thumbnails(&path, &candidate.name);
|
||||||
|
|
||||||
let guard =
|
let guard =
|
||||||
self.transfers
|
self.transfers
|
||||||
@@ -206,7 +207,7 @@ impl Core {
|
|||||||
media_type,
|
media_type,
|
||||||
reader,
|
reader,
|
||||||
size as i64,
|
size as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
metadata,
|
metadata,
|
||||||
false,
|
false,
|
||||||
));
|
));
|
||||||
@@ -291,7 +292,14 @@ impl Core {
|
|||||||
let mut state = REDATE.lock();
|
let mut state = REDATE.lock();
|
||||||
state.running = false;
|
state.running = false;
|
||||||
state.cancel = None;
|
state.cancel = None;
|
||||||
state.last = outcome.as_ref().ok().cloned();
|
state.last = Some(match &outcome {
|
||||||
|
Ok(summary) => summary.clone(),
|
||||||
|
Err(e) => RedateSummary {
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
dry_run,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
});
|
||||||
match &outcome {
|
match &outcome {
|
||||||
Ok(summary) => info!(
|
Ok(summary) => info!(
|
||||||
candidates = summary.candidates,
|
candidates = summary.candidates,
|
||||||
|
|||||||
@@ -1303,6 +1303,44 @@ mod tests {
|
|||||||
db.unwrap()
|
db.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn close_syncs_a_written_file_and_fsync_accepts_a_read_only_one() {
|
||||||
|
// `close` is answered by flush, which now syncs the scratch file so a
|
||||||
|
// failure reaches the program; `fsync` on a descriptor opened for
|
||||||
|
// reading failed with EBADF (B222).
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("flush-fsync");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let path = daemon.mountpoint.join("notes.txt");
|
||||||
|
|
||||||
|
let mut file = std::fs::File::create(&path).unwrap();
|
||||||
|
file.write_all(b"written then closed").unwrap();
|
||||||
|
drop(file);
|
||||||
|
let mut again = std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap();
|
||||||
|
again.write_all(b", twice").unwrap();
|
||||||
|
again.flush().unwrap();
|
||||||
|
drop(again);
|
||||||
|
|
||||||
|
let mut read_only = std::fs::File::open(&path).unwrap();
|
||||||
|
read_only.sync_all().unwrap();
|
||||||
|
let mut text = String::new();
|
||||||
|
read_only.read_to_string(&mut text).unwrap();
|
||||||
|
assert_eq!(text, "written then closed, twice");
|
||||||
|
drop(read_only);
|
||||||
|
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
}));
|
||||||
|
assert!(drive.lookup("notes.txt").is_some());
|
||||||
|
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_stop_ends_a_mirror_pass_after_the_uploads_it_is_sending() {
|
fn a_stop_ends_a_mirror_pass_after_the_uploads_it_is_sending() {
|
||||||
@@ -1363,6 +1401,216 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A folder `mirrored` on Drive, synced in mirror mode with `dir/local`,
|
||||||
|
/// which holds `files` before the first pass. Returns the daemon, the
|
||||||
|
/// folder's id and the link it uses.
|
||||||
|
fn start_mirror(
|
||||||
|
drive: &Arc<FakeDrive>,
|
||||||
|
dir: &Path,
|
||||||
|
files: &[(&str, &[u8])],
|
||||||
|
) -> (Daemon, i64, FakeClient) {
|
||||||
|
let daemon = Daemon::start(dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("mirrored")).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
&& drive.lookup("mirrored").is_some()
|
||||||
|
}));
|
||||||
|
let remote = drive.lookup("mirrored").unwrap();
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let local = dir.join("local");
|
||||||
|
std::fs::create_dir_all(&local).unwrap();
|
||||||
|
for (name, bytes) in files {
|
||||||
|
std::fs::write(local.join(name), bytes).unwrap();
|
||||||
|
}
|
||||||
|
let folder = reopen(dir)
|
||||||
|
.sync_folder_add(local.to_str().unwrap(), &remote.to_string(), "")
|
||||||
|
.unwrap();
|
||||||
|
let client = drive.client(1, Faults::lan());
|
||||||
|
let daemon = Daemon::start(dir, client.clone()).unwrap();
|
||||||
|
(daemon, folder, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until a pass of the mirror that began after `since` (epoch
|
||||||
|
/// seconds) is over without errors.
|
||||||
|
fn wait_for_pass(daemon: &Daemon, folder: i64, since: i64) -> bool {
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
let Ok(Response::SyncFolders { items }) = daemon.request(&Request::ListSyncFolders)
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
items.iter().any(|f| {
|
||||||
|
f.id == folder && f.state == "idle" && f.progress.is_none() && f.last_sync >= since
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conflict_copies(dir: &Path, stem: &str) -> Vec<PathBuf> {
|
||||||
|
let prefix = format!("{stem} (sync-conflict ");
|
||||||
|
std::fs::read_dir(dir)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| {
|
||||||
|
p.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.starts_with(&prefix))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_drive(drive: &FakeDrive, path: &str) -> Option<Vec<u8>> {
|
||||||
|
match drive.tree().get(path) {
|
||||||
|
Some(Entry::File(content)) => Some(content.to_vec()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_joining_mirror_keeps_a_same_size_different_file_as_a_conflict() {
|
||||||
|
// A computer joining a folder another one syncs found a file of the
|
||||||
|
// same size at the same path and took it for the same file, so the two
|
||||||
|
// went on holding different content (B248). One that is the same is
|
||||||
|
// still taken as it is.
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("join-same-size");
|
||||||
|
let theirs: &[u8] = b"from the other computer";
|
||||||
|
let ours: &[u8] = b"written on this machine";
|
||||||
|
assert_eq!(theirs.len(), ours.len());
|
||||||
|
let daemon = {
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("mirrored")).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
&& drive.lookup("mirrored").is_some()
|
||||||
|
}));
|
||||||
|
daemon
|
||||||
|
};
|
||||||
|
drive.device().write("mirrored/notes.txt", theirs);
|
||||||
|
drive.device().write("mirrored/same.txt", b"identical");
|
||||||
|
let remote = drive.lookup("mirrored").unwrap();
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let local = dir.join("local");
|
||||||
|
std::fs::create_dir_all(&local).unwrap();
|
||||||
|
std::fs::write(local.join("notes.txt"), ours).unwrap();
|
||||||
|
std::fs::write(local.join("same.txt"), b"identical").unwrap();
|
||||||
|
reopen(&dir)
|
||||||
|
.sync_folder_add(local.to_str().unwrap(), &remote.to_string(), "")
|
||||||
|
.unwrap();
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
|
||||||
|
assert!(wait_until(Duration::from_secs(60), || {
|
||||||
|
conflict_copies(&local, "notes").len() == 1
|
||||||
|
&& std::fs::read(local.join("notes.txt")).is_ok_and(|b| b == theirs)
|
||||||
|
&& drive
|
||||||
|
.tree()
|
||||||
|
.keys()
|
||||||
|
.any(|k| k.starts_with("mirrored/notes (sync-conflict "))
|
||||||
|
}));
|
||||||
|
let copy = &conflict_copies(&local, "notes")[0];
|
||||||
|
assert_eq!(std::fs::read(copy).unwrap(), ours);
|
||||||
|
assert_eq!(on_drive(&drive, "mirrored/notes.txt").unwrap(), theirs);
|
||||||
|
assert!(conflict_copies(&local, "same").is_empty());
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_mirror_edit_does_not_replace_a_revision_another_computer_made_since() {
|
||||||
|
// A local edit went up as a new revision of whatever Drive held at the
|
||||||
|
// time of the upload, from a plan made before another computer saved
|
||||||
|
// its own edit. Its edit was left only in the version history (B249).
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("mirror-revision-race");
|
||||||
|
let (daemon, folder, client) =
|
||||||
|
start_mirror(&drive, &dir, &[("a.txt", b"one"), ("b.txt", b"two")]);
|
||||||
|
let local = dir.join("local");
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
drive.lookup("mirrored/a.txt").is_some() && drive.lookup("mirrored/b.txt").is_some()
|
||||||
|
}));
|
||||||
|
assert!(wait_for_pass(&daemon, folder, 0));
|
||||||
|
// The echoes of the first uploads read the files too.
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
|
||||||
|
let a = drive.lookup("mirrored/a.txt").unwrap();
|
||||||
|
let check = client.hold_next_read(&a);
|
||||||
|
std::fs::write(local.join("a.txt"), b"edited here").unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || check.reached()));
|
||||||
|
drive
|
||||||
|
.device()
|
||||||
|
.write("mirrored/a.txt", b"edited on the other computer");
|
||||||
|
drop(check);
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
take_logged()
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.contains("changed on Drive since this pass looked"))
|
||||||
|
}));
|
||||||
|
daemon
|
||||||
|
.request(&Request::SyncNow { id: Some(folder) })
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(wait_until(Duration::from_secs(60), || {
|
||||||
|
std::fs::read(local.join("a.txt")).is_ok_and(|b| b == b"edited on the other computer")
|
||||||
|
&& conflict_copies(&local, "a").len() == 1
|
||||||
|
&& drive
|
||||||
|
.tree()
|
||||||
|
.keys()
|
||||||
|
.any(|k| k.starts_with("mirrored/a (sync-conflict "))
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&conflict_copies(&local, "a")[0]).unwrap(),
|
||||||
|
b"edited here"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
on_drive(&drive, "mirrored/a.txt").unwrap(),
|
||||||
|
b"edited on the other computer"
|
||||||
|
);
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_revision_made_during_a_mirror_download_still_arrives() {
|
||||||
|
// The baseline after a download read the node again, and recorded a
|
||||||
|
// revision another computer made while the download ran as synced.
|
||||||
|
// The disk held the older one, and the newer never arrived (B250).
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("mirror-download-race");
|
||||||
|
let (daemon, folder, client) =
|
||||||
|
start_mirror(&drive, &dir, &[("a.txt", b"one"), ("b.txt", b"two")]);
|
||||||
|
let local = dir.join("local");
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
drive.lookup("mirrored/a.txt").is_some() && drive.lookup("mirrored/b.txt").is_some()
|
||||||
|
}));
|
||||||
|
assert!(wait_for_pass(&daemon, folder, 0));
|
||||||
|
|
||||||
|
let a = drive.lookup("mirrored/a.txt").unwrap();
|
||||||
|
drive.device().write("mirrored/a.txt", b"second");
|
||||||
|
let download = client.hold_next_download(&a);
|
||||||
|
daemon
|
||||||
|
.request(&Request::SyncNow { id: Some(folder) })
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || download.reached()));
|
||||||
|
drive.device().write("mirrored/a.txt", b"thirdd");
|
||||||
|
drop(download);
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
std::fs::read(local.join("a.txt")).is_ok_and(|b| b == b"second")
|
||||||
|
}));
|
||||||
|
assert!(wait_for_pass(&daemon, folder, 0));
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.request(&Request::SyncNow { id: Some(folder) })
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
std::fs::read(local.join("a.txt")).is_ok_and(|b| b == b"thirdd")
|
||||||
|
}));
|
||||||
|
assert!(conflict_copies(&local, "a").is_empty());
|
||||||
|
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_file_removed_with_its_folder_while_uploading_stays_removed() {
|
fn a_file_removed_with_its_folder_while_uploading_stays_removed() {
|
||||||
|
|||||||
@@ -212,6 +212,31 @@ pub(crate) fn lid_ino(lid: i64) -> u64 {
|
|||||||
lid.unsigned_abs() + crate::ROOT_INO
|
lid.unsigned_abs() + crate::ROOT_INO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The inodes of the stored nodes among `nodes`, from their local ids, for
|
||||||
|
/// [`State::intern_batch_with_inos`]. Called before the state lock is taken;
|
||||||
|
/// a node that already has a row keeps its id. Empty when the database fails,
|
||||||
|
/// which leaves the allocation to the lock holder, as before.
|
||||||
|
pub(crate) fn inos_for(db: &Db, nodes: &[Node]) -> HashMap<NodeUid, u64> {
|
||||||
|
let stored: Vec<&Node> = nodes
|
||||||
|
.iter()
|
||||||
|
.filter(|node| !crate::r#virtual::is_virtual_uid(&node.uid))
|
||||||
|
.collect();
|
||||||
|
if stored.is_empty() {
|
||||||
|
return HashMap::new();
|
||||||
|
}
|
||||||
|
match db.lids_for(&stored) {
|
||||||
|
Ok(lids) => stored
|
||||||
|
.iter()
|
||||||
|
.zip(lids)
|
||||||
|
.map(|(node, lid)| (node.uid.clone(), lid_ino(lid)))
|
||||||
|
.collect(),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "no local ids before the listing; allocating them under the lock");
|
||||||
|
HashMap::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The local id of the node at inode `ino`, the inverse of [`lid_ino`]:
|
/// The local id of the node at inode `ino`, the inverse of [`lid_ino`]:
|
||||||
/// `None` for the root and a fallback inode.
|
/// `None` for the root and a fallback inode.
|
||||||
pub(crate) fn ino_lid(ino: u64) -> Option<i64> {
|
pub(crate) fn ino_lid(ino: u64) -> Option<i64> {
|
||||||
@@ -1074,20 +1099,34 @@ impl State {
|
|||||||
///
|
///
|
||||||
/// This is what keeps `ls` on a large folder quick: one commit for the
|
/// This is what keeps `ls` on a large folder quick: one commit for the
|
||||||
/// listing, rather than one autocommit — and one fsync — per child.
|
/// listing, rather than one autocommit — and one fsync — per child.
|
||||||
pub(crate) fn intern_batch(&mut self, parent: u64, mut nodes: Vec<Node>) -> Vec<u64> {
|
#[cfg(test)]
|
||||||
|
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
||||||
|
self.intern_batch_with_inos(parent, nodes, HashMap::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`State::intern_batch`], with inodes for new entries that the caller
|
||||||
|
/// allocated before it took the lock, from [`inos_for`]. Allocating them
|
||||||
|
/// is a write transaction, and under the lock every inline handler of
|
||||||
|
/// every mount waited for it (`docs/BUGS.md` B221). A node missing from
|
||||||
|
/// `inos` still gets one here.
|
||||||
|
pub(crate) fn intern_batch_with_inos(
|
||||||
|
&mut self,
|
||||||
|
parent: u64,
|
||||||
|
mut nodes: Vec<Node>,
|
||||||
|
mut inos: HashMap<NodeUid, u64>,
|
||||||
|
) -> Vec<u64> {
|
||||||
for node in &mut nodes {
|
for node in &mut nodes {
|
||||||
self.keep_revision_mtime(node);
|
self.keep_revision_mtime(node);
|
||||||
}
|
}
|
||||||
self.outbox.push(DbWrite::Upsert(nodes.clone()));
|
self.outbox.push(DbWrite::Upsert(nodes.clone()));
|
||||||
let fresh: Vec<&Node> = nodes
|
let fresh: Vec<&Node> = nodes
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|node| !self.by_uid.contains_key(&node.uid))
|
.filter(|node| !self.by_uid.contains_key(&node.uid) && !inos.contains_key(&node.uid))
|
||||||
.collect();
|
.collect();
|
||||||
let mut inos = if fresh.is_empty() {
|
if !fresh.is_empty() {
|
||||||
HashMap::new()
|
let allocated = self.new_inos(&fresh);
|
||||||
} else {
|
inos.extend(allocated);
|
||||||
self.new_inos(&fresh)
|
}
|
||||||
};
|
|
||||||
nodes
|
nodes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|node| {
|
.map(|node| {
|
||||||
@@ -1098,6 +1137,13 @@ impl State {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record `uid`'s listing as complete once the lock is released, after the
|
||||||
|
/// rows of its children: written the other way round, a crash between the
|
||||||
|
/// two left a folder marked complete with no children to show.
|
||||||
|
pub(crate) fn record_listed(&mut self, uid: NodeUid) {
|
||||||
|
self.outbox.push(DbWrite::SetListed(uid, true));
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a directory inode has any child nodes in memory or in the database.
|
/// Check if a directory inode has any child nodes in memory or in the database.
|
||||||
pub(crate) fn has_children(&self, parent: u64) -> bool {
|
pub(crate) fn has_children(&self, parent: u64) -> bool {
|
||||||
// A resident listing is authoritative even when empty. Falling through
|
// A resident listing is authoritative even when empty. Falling through
|
||||||
@@ -1467,6 +1513,44 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A listing's inodes are allocated before the lock is taken, and are the
|
||||||
|
/// ones a node would get under it (`docs/BUGS.md` B221).
|
||||||
|
#[test]
|
||||||
|
fn inodes_allocated_before_the_lock_are_the_ones_the_node_keeps() {
|
||||||
|
let (mut st, _dir) = state();
|
||||||
|
let db = st.db.clone();
|
||||||
|
let listing = vec![
|
||||||
|
node("a", "root", "a.txt", false),
|
||||||
|
node("b", "root", "b.txt", false),
|
||||||
|
];
|
||||||
|
let early = inos_for(&db, &listing);
|
||||||
|
assert_eq!(early.len(), 2);
|
||||||
|
let inos = st.intern_batch_with_inos(0, listing, early.clone());
|
||||||
|
assert_eq!(inos, [early[&uid("a")], early[&uid("b")]]);
|
||||||
|
st.flush_outbox();
|
||||||
|
|
||||||
|
let mut restarted = State::new(db, HashMap::new());
|
||||||
|
assert_eq!(
|
||||||
|
restarted.intern(0, node("b", "root", "b.txt", false)),
|
||||||
|
early[&uid("b")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The listing is recorded complete with its children's rows, when the
|
||||||
|
/// lock goes, never ahead of them.
|
||||||
|
#[test]
|
||||||
|
fn a_listing_is_recorded_complete_after_its_children() {
|
||||||
|
let (mut st, _dir) = state();
|
||||||
|
let root = st.intern(0, node("root", "none", "root", true));
|
||||||
|
st.flush_outbox();
|
||||||
|
st.intern_batch(root, vec![node("a", "root", "a.txt", false)]);
|
||||||
|
st.record_listed(uid("root"));
|
||||||
|
assert!(st.db.children_if_listed(&uid("root")).unwrap().is_none());
|
||||||
|
st.flush_outbox();
|
||||||
|
let kids = st.db.children_if_listed(&uid("root")).unwrap().unwrap();
|
||||||
|
assert_eq!(kids.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
/// Audit A5. Moving a file out of a folder leaves that folder's DB row
|
/// Audit A5. Moving a file out of a folder leaves that folder's DB row
|
||||||
/// claiming a complete listing, so once the hot cache drops it, remote
|
/// claiming a complete listing, so once the hot cache drops it, remote
|
||||||
/// changes under it stop being seen. `relocate` has to clear both sides.
|
/// changes under it stop being seen. `relocate` has to clear both sides.
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ const CONFLICT_SWEEP_INTERVAL: Duration = Duration::from_secs(300);
|
|||||||
/// fresh mount does at startup (hydrate, recover, initial enumeration).
|
/// fresh mount does at startup (hydrate, recover, initial enumeration).
|
||||||
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
|
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// Strip a ` (sync-conflict <stamp>)` (optionally `-<n>`) marker from `name`,
|
/// Strip a ` (sync-conflict <label> <stamp>)` (optionally `-<n>`) marker from
|
||||||
/// returning the original file name it was a copy of. `None` when `name` is not a
|
/// `name`, returning the original file name it was a copy of. The label names
|
||||||
/// conflict copy. Inverse of the `conflict_name` / `conflict_path_with_suffix`
|
/// the computer that made the copy (B251); copies made before it have the bare
|
||||||
/// formats, so the two halves of the product agree on what a conflict is.
|
/// stamp. `None` when `name` is not a conflict copy. Inverse of the
|
||||||
|
/// `conflict_name` / `conflict_path_with_suffix` formats, so the two halves of
|
||||||
|
/// the product agree on what a conflict is.
|
||||||
pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
|
pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
|
||||||
const MARKER: &str = " (sync-conflict ";
|
const MARKER: &str = " (sync-conflict ";
|
||||||
let start = name.find(MARKER)?;
|
let start = name.find(MARKER)?;
|
||||||
@@ -48,6 +50,20 @@ pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
|
|||||||
let close_rel = name[after..].find(')')?;
|
let close_rel = name[after..].find(')')?;
|
||||||
let close = after + close_rel;
|
let close = after + close_rel;
|
||||||
let inner = &name[after..close];
|
let inner = &name[after..close];
|
||||||
|
// A label is one token of the characters `conflict_label_of` keeps.
|
||||||
|
let inner = match inner.rsplit_once(' ') {
|
||||||
|
Some((label, token)) => {
|
||||||
|
let label_ok = !label.is_empty()
|
||||||
|
&& label
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
|
||||||
|
if !label_ok {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
token
|
||||||
|
}
|
||||||
|
None => inner,
|
||||||
|
};
|
||||||
// The token is `<digits>` or `<digits>-<digits>`; reject anything else so a
|
// The token is `<digits>` or `<digits>-<digits>`; reject anything else so a
|
||||||
// file that merely happens to contain the phrase is not mistaken for a copy.
|
// file that merely happens to contain the phrase is not mistaken for a copy.
|
||||||
let mut parts = inner.splitn(2, '-');
|
let mut parts = inner.splitn(2, '-');
|
||||||
@@ -486,6 +502,24 @@ mod tests {
|
|||||||
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
|
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
|
||||||
Some("notes.txt")
|
Some("notes.txt")
|
||||||
);
|
);
|
||||||
|
// Named after the computer that made it (B251).
|
||||||
|
assert_eq!(
|
||||||
|
conflict_base_name("notes (sync-conflict laptop 1700).txt").as_deref(),
|
||||||
|
Some("notes.txt")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conflict_base_name("notes (sync-conflict my-pc.local 1700-2).txt").as_deref(),
|
||||||
|
Some("notes.txt")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conflict_base_name(&crate::conflict_name("notes.txt", 1700)).as_deref(),
|
||||||
|
Some("notes.txt")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conflict_base_name("x (sync-conflict two words 1).txt"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(conflict_base_name("x (sync-conflict 1).txt"), None);
|
||||||
// Not a conflict copy.
|
// Not a conflict copy.
|
||||||
assert_eq!(conflict_base_name("report.xml"), None);
|
assert_eq!(conflict_base_name("report.xml"), None);
|
||||||
// Looks close but the token is not a stamp.
|
// Looks close but the token is not a stamp.
|
||||||
|
|||||||
+609
-143
File diff suppressed because it is too large.
Load diff
@@ -22,7 +22,7 @@ const POLL_INTERVAL: Duration = Duration::from_secs(120);
|
|||||||
/// per-item work is a network round-trip, so a folder with thousands of files is
|
/// per-item work is a network round-trip, so a folder with thousands of files is
|
||||||
/// unusable done one-at-a-time; this bounds concurrency like the bulk-upload
|
/// unusable done one-at-a-time; this bounds concurrency like the bulk-upload
|
||||||
/// engine does.
|
/// engine does.
|
||||||
pub(super) const SYNC_CONCURRENCY: usize = 8;
|
pub(crate) const SYNC_CONCURRENCY: usize = 8;
|
||||||
|
|
||||||
/// A message to the sync engine's control thread.
|
/// A message to the sync engine's control thread.
|
||||||
pub(crate) enum SyncMsg {
|
pub(crate) enum SyncMsg {
|
||||||
|
|||||||
@@ -83,6 +83,46 @@ pub(super) struct RemoteItem {
|
|||||||
pub(super) is_dir: bool,
|
pub(super) is_dir: bool,
|
||||||
pub(super) mtime: i64,
|
pub(super) mtime: i64,
|
||||||
pub(super) size: i64,
|
pub(super) size: i64,
|
||||||
|
/// The active revision's id. The light walk carries it for every file, so
|
||||||
|
/// it is the signal that tells another client's new revision from a link
|
||||||
|
/// merely re-stamped.
|
||||||
|
pub(super) revision: Option<String>,
|
||||||
|
/// The active revision's content SHA-1, read only for files the walk
|
||||||
|
/// enumerated in full (no baseline, or a moved mtime). `None` is never
|
||||||
|
/// proof of anything.
|
||||||
|
pub(super) sha1: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a pass saw of a remote file: the signature its baseline records once
|
||||||
|
/// the file is synced, and what an upload checks Drive still holds before it
|
||||||
|
/// supersedes it.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub(super) struct RemoteSeen {
|
||||||
|
pub(super) mtime: i64,
|
||||||
|
pub(super) size: i64,
|
||||||
|
pub(super) revision: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteSeen {
|
||||||
|
/// Whether `node` is still the revision this was taken from. The revision
|
||||||
|
/// id decides it when both sides have one; otherwise the modification time
|
||||||
|
/// and size, which is all a baseline row from before schema 44 holds.
|
||||||
|
pub(super) fn still_current(&self, node: &Node) -> bool {
|
||||||
|
match (&self.revision, crate::node_revision_id(node)) {
|
||||||
|
(Some(seen), Some(now)) => *seen == now,
|
||||||
|
_ => (self.mtime, self.size) == remote_file_sig(node),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&RemoteItem> for RemoteSeen {
|
||||||
|
fn from(item: &RemoteItem) -> Self {
|
||||||
|
Self {
|
||||||
|
mtime: item.mtime,
|
||||||
|
size: item.size,
|
||||||
|
revision: item.revision.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The result of a reconcile pass: what it moved, how many paths were kept as
|
/// The result of a reconcile pass: what it moved, how many paths were kept as
|
||||||
@@ -113,7 +153,7 @@ impl Outcome {
|
|||||||
Applied::Uploaded => self.uploaded += 1,
|
Applied::Uploaded => self.uploaded += 1,
|
||||||
Applied::Downloaded => self.downloaded += 1,
|
Applied::Downloaded => self.downloaded += 1,
|
||||||
Applied::Conflict => self.conflicts += 1,
|
Applied::Conflict => self.conflicts += 1,
|
||||||
Applied::Vanished => {}
|
Applied::Vanished | Applied::Adopted => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,10 +197,16 @@ pub(super) enum Pending {
|
|||||||
CreateDir { rel: String, parent: NodeUid },
|
CreateDir { rel: String, parent: NodeUid },
|
||||||
/// Upload a brand-new local file into `parent`.
|
/// Upload a brand-new local file into `parent`.
|
||||||
UploadNew { rel: String, parent: NodeUid },
|
UploadNew { rel: String, parent: NodeUid },
|
||||||
/// Upload a changed local file as a new revision of `uid`.
|
/// Upload a changed local file as a new revision of `uid`, if Drive still
|
||||||
UploadRevision { rel: String, uid: NodeUid },
|
/// holds the revision `seen` describes (B249).
|
||||||
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
|
UploadRevision {
|
||||||
/// remote's reported size, used as the transfer's expected total.
|
rel: String,
|
||||||
|
uid: NodeUid,
|
||||||
|
seen: RemoteSeen,
|
||||||
|
},
|
||||||
|
/// Download remote `uid` to the local path, stamping `seen.mtime`.
|
||||||
|
/// `seen.size` is the remote's reported size, used as the transfer's
|
||||||
|
/// expected total.
|
||||||
///
|
///
|
||||||
/// `local` is what the planning walk saw at this path, or `None` if nothing
|
/// `local` is what the planning walk saw at this path, or `None` if nothing
|
||||||
/// was there. A download can run for minutes, so the apply step revalidates
|
/// was there. A download can run for minutes, so the apply step revalidates
|
||||||
@@ -170,16 +216,26 @@ pub(super) enum Pending {
|
|||||||
Download {
|
Download {
|
||||||
rel: String,
|
rel: String,
|
||||||
uid: NodeUid,
|
uid: NodeUid,
|
||||||
mtime: i64,
|
seen: RemoteSeen,
|
||||||
size: i64,
|
|
||||||
local: Option<LocalSig>,
|
local: Option<LocalSig>,
|
||||||
},
|
},
|
||||||
|
/// First sync of a path present on both sides at the same size, with a
|
||||||
|
/// digest on Drive: hash the local file, and record the baseline if it holds
|
||||||
|
/// the same bytes. A different file of the same size goes the way of
|
||||||
|
/// [`Pending::Conflict`]: two machines syncing one folder must not read as
|
||||||
|
/// in step while they hold different content.
|
||||||
|
Adopt {
|
||||||
|
rel: String,
|
||||||
|
uid: NodeUid,
|
||||||
|
seen: RemoteSeen,
|
||||||
|
sha1: String,
|
||||||
|
local: LocalSig,
|
||||||
|
},
|
||||||
/// Both sides changed: set the local copy aside, then download remote `uid`.
|
/// Both sides changed: set the local copy aside, then download remote `uid`.
|
||||||
Conflict {
|
Conflict {
|
||||||
rel: String,
|
rel: String,
|
||||||
uid: NodeUid,
|
uid: NodeUid,
|
||||||
mtime: i64,
|
seen: RemoteSeen,
|
||||||
size: i64,
|
|
||||||
},
|
},
|
||||||
/// Both sides changed during a [`push pass`](Core::push_pass): upload the local
|
/// Both sides changed during a [`push pass`](Core::push_pass): upload the local
|
||||||
/// copy into `parent` under a conflict name, leaving the remote file as it is.
|
/// copy into `parent` under a conflict name, leaving the remote file as it is.
|
||||||
@@ -194,6 +250,7 @@ impl Pending {
|
|||||||
| Pending::UploadNew { rel, .. }
|
| Pending::UploadNew { rel, .. }
|
||||||
| Pending::UploadRevision { rel, .. }
|
| Pending::UploadRevision { rel, .. }
|
||||||
| Pending::Download { rel, .. }
|
| Pending::Download { rel, .. }
|
||||||
|
| Pending::Adopt { rel, .. }
|
||||||
| Pending::Conflict { rel, .. }
|
| Pending::Conflict { rel, .. }
|
||||||
| Pending::PushConflict { rel, .. } => rel,
|
| Pending::PushConflict { rel, .. } => rel,
|
||||||
}
|
}
|
||||||
@@ -206,7 +263,9 @@ impl Pending {
|
|||||||
Pending::UploadNew { .. }
|
Pending::UploadNew { .. }
|
||||||
| Pending::UploadRevision { .. }
|
| Pending::UploadRevision { .. }
|
||||||
| Pending::PushConflict { .. } => ActivityKind::Upload,
|
| Pending::PushConflict { .. } => ActivityKind::Upload,
|
||||||
Pending::Download { .. } | Pending::Conflict { .. } => ActivityKind::Download,
|
Pending::Download { .. } | Pending::Adopt { .. } | Pending::Conflict { .. } => {
|
||||||
|
ActivityKind::Download
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +276,7 @@ impl Pending {
|
|||||||
Pending::UploadNew { .. } => "new file",
|
Pending::UploadNew { .. } => "new file",
|
||||||
Pending::UploadRevision { .. } => "new version",
|
Pending::UploadRevision { .. } => "new version",
|
||||||
Pending::Download { .. } => "from Drive",
|
Pending::Download { .. } => "from Drive",
|
||||||
|
Pending::Adopt { .. } => "already the same on Drive",
|
||||||
Pending::Conflict { .. } => "local changes kept as a conflict copy",
|
Pending::Conflict { .. } => "local changes kept as a conflict copy",
|
||||||
Pending::PushConflict { .. } => "local changes uploaded as a conflict copy",
|
Pending::PushConflict { .. } => "local changes uploaded as a conflict copy",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,14 +199,160 @@ pub(super) fn guard_local_wipe<B, L>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refuse to run a pass whose remote side has vanished in its entirety: the
|
||||||
|
/// remote twin of [`guard_local_wipe`].
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// one synced path is left on Drive, a failed listing is far likelier than a
|
||||||
|
/// deliberate wipe of the whole folder, so the pass stops instead.
|
||||||
|
pub(super) fn guard_remote_wipe<B, R>(
|
||||||
|
baseline: &HashMap<String, B>,
|
||||||
|
remote: &HashMap<String, R>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if !baseline.is_empty() && baseline.keys().all(|rel| !remote.contains_key(rel)) {
|
||||||
|
return Err(format!(
|
||||||
|
"every one of the {} synced paths is missing on Proton Drive; refusing to \
|
||||||
|
delete them here. If they were deleted on purpose, remove the folder from sync.",
|
||||||
|
baseline.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a remote walk saw but could not read: a node the SDK left out because
|
||||||
|
/// it would not decrypt, or a file whose size could not be read.
|
||||||
|
///
|
||||||
|
/// A path missing from the walk is read as deleted on Drive, so a pass must
|
||||||
|
/// not classify one it only failed to read. Since a node that failed to decrypt
|
||||||
|
/// has no name, all a walk can say is that a folder's listing came back short;
|
||||||
|
/// every path under that folder that the walk did not return is then left out,
|
||||||
|
/// as [`Unscanned`] leaves out what the local scan could not read.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct RemoteGaps {
|
||||||
|
/// Folders whose listing came back with fewer nodes than it named.
|
||||||
|
short: HashSet<String>,
|
||||||
|
/// Paths seen, but whose entry could not be read.
|
||||||
|
unread: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteGaps {
|
||||||
|
/// The listing of `folder` named nodes that did not come back.
|
||||||
|
pub(super) fn short_listing(&mut self, folder: &str) {
|
||||||
|
self.short.insert(folder.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `rel` is on Drive, but the walk could not read what it needs of it.
|
||||||
|
pub(super) fn unread(&mut self, rel: String) {
|
||||||
|
self.unread.insert(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_empty(&self) -> bool {
|
||||||
|
self.short.is_empty() && self.unread.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `rel` may be missing from `remote` only because the walk could
|
||||||
|
/// not read it. A path the walk returned is never hidden.
|
||||||
|
pub(super) fn hides<R>(
|
||||||
|
&self,
|
||||||
|
rel: &str,
|
||||||
|
remote: &HashMap<String, R>,
|
||||||
|
remote_dirs: &HashMap<String, NodeUid>,
|
||||||
|
) -> bool {
|
||||||
|
if self.is_empty() || remote.contains_key(rel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.unread.contains(rel) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// The deepest folder the walk did list decides: under a short listing,
|
||||||
|
// anything not returned may be one of the nodes it could not read.
|
||||||
|
let mut at = parent_rel(rel);
|
||||||
|
loop {
|
||||||
|
if remote_dirs.contains_key(at) {
|
||||||
|
return self.short.contains(at);
|
||||||
|
}
|
||||||
|
if at.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
at = parent_rel(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folders to name to the user, sorted.
|
||||||
|
pub(super) fn report(&self) -> Vec<String> {
|
||||||
|
let mut lines: Vec<String> = self
|
||||||
|
.short
|
||||||
|
.iter()
|
||||||
|
.map(|rel| {
|
||||||
|
let shown = if rel.is_empty() { "the folder" } else { rel };
|
||||||
|
format!("{shown} (items on Proton Drive that cannot be read)")
|
||||||
|
})
|
||||||
|
.chain(
|
||||||
|
self.unread
|
||||||
|
.iter()
|
||||||
|
.map(|rel| format!("{rel} (cannot be read on Proton Drive)")),
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
lines.sort_unstable();
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the remote folder at `rel` must not be trashed although it was deleted
|
||||||
|
/// here: something under it is new or changed on Drive since the last sync,
|
||||||
|
/// so it is another device's work that this computer has never had.
|
||||||
|
///
|
||||||
|
/// Trashing the folder would take that work with it, and the next pass would
|
||||||
|
/// then delete the copy this pass just downloaded. `None` when everything under
|
||||||
|
/// it is exactly what the baseline recorded.
|
||||||
|
pub(super) fn remote_subtree_changed(
|
||||||
|
rel: &str,
|
||||||
|
remote: &HashMap<String, RemoteItem>,
|
||||||
|
baseline: &HashMap<String, StoredSyncEntry>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let prefix = format!("{rel}/");
|
||||||
|
let mut changed: Vec<&String> = remote
|
||||||
|
.iter()
|
||||||
|
.filter(|(path, _)| path.starts_with(&prefix))
|
||||||
|
.filter(|(path, item)| match baseline.get(*path) {
|
||||||
|
None => true,
|
||||||
|
Some(_) if item.is_dir => false,
|
||||||
|
Some(base) => {
|
||||||
|
remote_sig(base) != Some((item.mtime, item.size))
|
||||||
|
|| revision_moved(Some(base), item.revision.as_deref())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|(path, _)| path)
|
||||||
|
.collect();
|
||||||
|
changed.sort_unstable();
|
||||||
|
changed
|
||||||
|
.first()
|
||||||
|
.map(|path| format!("{path} is new or changed on Proton Drive"))
|
||||||
|
}
|
||||||
|
|
||||||
/// The size the baseline recorded for `rel`, if its remote signature's mtime
|
/// The size the baseline recorded for `rel`, if its remote signature's mtime
|
||||||
/// still matches `mtime`.
|
/// still matches `mtime` and its revision is still `revision`.
|
||||||
|
///
|
||||||
|
/// A row without a revision id, while Drive reports one, cannot vouch for the
|
||||||
|
/// revision, so the size is read for real. Such a row comes from before
|
||||||
|
/// schema 44, or from an upload Drive did not list yet when it was read back.
|
||||||
|
/// Another computer's revision in the same second as that upload kept the
|
||||||
|
/// modification time, and only the size could show it (B254). The pass then
|
||||||
|
/// records the id on the row, so this costs one read per row.
|
||||||
pub(super) fn unchanged_remote_size(
|
pub(super) fn unchanged_remote_size(
|
||||||
baseline: &HashMap<String, StoredSyncEntry>,
|
baseline: &HashMap<String, StoredSyncEntry>,
|
||||||
rel: &str,
|
rel: &str,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
|
revision: Option<&str>,
|
||||||
) -> Option<i64> {
|
) -> Option<i64> {
|
||||||
match baseline.get(rel).and_then(remote_sig) {
|
let base = baseline.get(rel)?;
|
||||||
|
if revision_moved(Some(base), revision)
|
||||||
|
|| (base.remote_revision_id.is_none() && revision.is_some())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match remote_sig(base) {
|
||||||
Some((recorded, size)) if recorded == mtime => Some(size),
|
Some((recorded, size)) if recorded == mtime => Some(size),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -221,6 +367,17 @@ pub(super) fn join_rel(prefix: &str, name: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether Drive's active revision `now` is not the one the baseline row was
|
||||||
|
/// synced at. Only a revision id on both sides can say so; a row from before
|
||||||
|
/// schema 44, or a node read without one, leaves it to the modification time
|
||||||
|
/// and size.
|
||||||
|
pub(super) fn revision_moved(base: Option<&StoredSyncEntry>, now: Option<&str>) -> bool {
|
||||||
|
match (base.and_then(|b| b.remote_revision_id.as_deref()), now) {
|
||||||
|
(Some(recorded), Some(now)) => recorded != now,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The stored remote signature of a baseline row, if it has one.
|
/// The stored remote signature of a baseline row, if it has one.
|
||||||
pub(super) fn remote_sig(e: &StoredSyncEntry) -> Option<(i64, i64)> {
|
pub(super) fn remote_sig(e: &StoredSyncEntry) -> Option<(i64, i64)> {
|
||||||
match (&e.remote_rev, &e.remote_hash) {
|
match (&e.remote_rev, &e.remote_hash) {
|
||||||
@@ -251,7 +408,8 @@ pub(super) fn rel_to_path(rel: &str) -> PathBuf {
|
|||||||
rel.split('/').collect()
|
rel.split('/').collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The name for a conflict copy of `path`, e.g. `notes (sync-conflict 1700000000).txt`.
|
/// The name for a conflict copy of `path`, e.g.
|
||||||
|
/// `notes (sync-conflict laptop 1700000000).txt`.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
|
pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
|
||||||
conflict_path_with_suffix(path, stamp, 0)
|
conflict_path_with_suffix(path, stamp, 0)
|
||||||
@@ -260,16 +418,17 @@ pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
|
|||||||
/// As [`conflict_path`], with a deterministic suffix used when that name already
|
/// As [`conflict_path`], with a deterministic suffix used when that name already
|
||||||
/// exists. This keeps conflict preservation from ever replacing an older copy.
|
/// exists. This keeps conflict preservation from ever replacing an older copy.
|
||||||
pub(super) fn conflict_path_with_suffix(path: &Path, stamp: i64, suffix: u32) -> PathBuf {
|
pub(super) fn conflict_path_with_suffix(path: &Path, stamp: i64, suffix: u32) -> PathBuf {
|
||||||
|
conflict_path_as(path, crate::conflict_label(), stamp, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`conflict_path_with_suffix`] with the machine's label given.
|
||||||
|
pub(super) fn conflict_path_as(path: &Path, label: &str, stamp: i64, suffix: u32) -> PathBuf {
|
||||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||||
let ext = path.extension().and_then(|s| s.to_str());
|
let ext = path.extension().and_then(|s| s.to_str());
|
||||||
let suffix = if suffix == 0 {
|
let tag = crate::conflict_tag(label, stamp, suffix);
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("-{suffix}")
|
|
||||||
};
|
|
||||||
let name = match ext {
|
let name = match ext {
|
||||||
Some(ext) => format!("{stem} (sync-conflict {stamp}{suffix}).{ext}"),
|
Some(ext) => format!("{stem} (sync-conflict {tag}).{ext}"),
|
||||||
None => format!("{stem} (sync-conflict {stamp}{suffix})"),
|
None => format!("{stem} (sync-conflict {tag})"),
|
||||||
};
|
};
|
||||||
match path.parent() {
|
match path.parent() {
|
||||||
Some(dir) => dir.join(name),
|
Some(dir) => dir.join(name),
|
||||||
@@ -290,11 +449,14 @@ pub(super) enum FilePlan {
|
|||||||
DeleteLocal,
|
DeleteLocal,
|
||||||
DeleteRemote,
|
DeleteRemote,
|
||||||
ForgetBaseline,
|
ForgetBaseline,
|
||||||
/// First sync of a file that already exists on both sides with identical
|
/// First sync of a file that exists on both sides at the same size, with a
|
||||||
/// content: record the baseline without transferring anything. Without this,
|
/// digest on Drive: record the baseline without transferring anything if
|
||||||
|
/// the local file hashes the same, else keep a conflict copy. Without this,
|
||||||
/// a folder synced for the first time cuts a `(sync-conflict)` copy of every
|
/// a folder synced for the first time cuts a `(sync-conflict)` copy of every
|
||||||
/// pre-existing file, because a missing baseline makes both sides read as
|
/// pre-existing file, because a missing baseline makes both sides read as
|
||||||
/// "changed" (see the mass first-sync conflict storm in docs/BUGS.md).
|
/// "changed" (see the mass first-sync conflict storm in docs/BUGS.md). Size
|
||||||
|
/// alone is not enough: a second machine joining the folder would read a
|
||||||
|
/// different file of the same size as in step (B248).
|
||||||
AdoptBaseline,
|
AdoptBaseline,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,8 +475,13 @@ pub(super) fn plan_file(
|
|||||||
// left the file the same length, so it was never uploaded.
|
// left the file the same length, so it was never uploaded.
|
||||||
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
||||||
});
|
});
|
||||||
|
// Another client's new revision shows in its id even when the link's
|
||||||
|
// modification time and the size come out the same.
|
||||||
let remote_changed = remote.is_some_and(|item| {
|
let remote_changed = remote.is_some_and(|item| {
|
||||||
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
|
baseline.is_none_or(|base| {
|
||||||
|
remote_sig(base) != Some((item.mtime, item.size))
|
||||||
|
|| revision_moved(Some(base), item.revision.as_deref())
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
match (local, remote) {
|
match (local, remote) {
|
||||||
@@ -324,10 +491,13 @@ pub(super) fn plan_file(
|
|||||||
(false, true) => FilePlan::Download,
|
(false, true) => FilePlan::Download,
|
||||||
// With a baseline, both-changed is a genuine concurrent edit and must
|
// With a baseline, both-changed is a genuine concurrent edit and must
|
||||||
// be preserved as a conflict. Without one, "both changed" only means
|
// be preserved as a conflict. Without one, "both changed" only means
|
||||||
// this is the first sync and there was nothing to compare to: adopt
|
// this is the first sync and there was nothing to compare to: when the
|
||||||
// the baseline when the sizes already match rather than conflict-copy
|
// sizes match and Drive has a digest to compare against, the apply
|
||||||
// an already-identical file. Divergent sizes stay a real conflict.
|
// step hashes the local file before adopting it. Divergent sizes, or
|
||||||
(true, true) if baseline.is_none() && l.size == r.size => FilePlan::AdoptBaseline,
|
// no digest, stay a real conflict: a missing digest proves nothing.
|
||||||
|
(true, true) if baseline.is_none() && l.size == r.size && r.sha1.is_some() => {
|
||||||
|
FilePlan::AdoptBaseline
|
||||||
|
}
|
||||||
(true, true) => FilePlan::Conflict,
|
(true, true) => FilePlan::Conflict,
|
||||||
},
|
},
|
||||||
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
|
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
|
||||||
@@ -359,6 +529,8 @@ mod file_plan_tests {
|
|||||||
is_dir: false,
|
is_dir: false,
|
||||||
mtime,
|
mtime,
|
||||||
size,
|
size,
|
||||||
|
revision: None,
|
||||||
|
sha1: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +548,7 @@ mod file_plan_tests {
|
|||||||
local_size,
|
local_size,
|
||||||
remote_rev: Some(remote_mtime.to_string()),
|
remote_rev: Some(remote_mtime.to_string()),
|
||||||
remote_hash: Some(remote_size.to_string()),
|
remote_hash: Some(remote_size.to_string()),
|
||||||
|
remote_revision_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,12 +670,28 @@ mod file_plan_tests {
|
|||||||
fn first_sync_of_an_identical_file_adopts_the_baseline_not_a_conflict() {
|
fn first_sync_of_an_identical_file_adopts_the_baseline_not_a_conflict() {
|
||||||
// No baseline yet (first sync). Same size on both sides is a pre-existing
|
// No baseline yet (first sync). Same size on both sides is a pre-existing
|
||||||
// identical file, not a concurrent edit: adopt it rather than cut a
|
// identical file, not a concurrent edit: adopt it rather than cut a
|
||||||
// conflict copy of every file in the folder.
|
// conflict copy of every file in the folder. The apply step still
|
||||||
|
// hashes the local file before it records anything.
|
||||||
|
let local = local(10, 40);
|
||||||
|
let remote = RemoteItem {
|
||||||
|
sha1: Some("da39a3ee".into()),
|
||||||
|
..remote(30, 40)
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&local), Some(&remote), None),
|
||||||
|
FilePlan::AdoptBaseline
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file on Drive without a digest cannot be compared without a download,
|
||||||
|
/// and the same size is not the same content (B248).
|
||||||
|
#[test]
|
||||||
|
fn first_sync_without_a_remote_digest_is_a_conflict() {
|
||||||
let local = local(10, 40);
|
let local = local(10, 40);
|
||||||
let remote = remote(30, 40);
|
let remote = remote(30, 40);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan_file(Some(&local), Some(&remote), None),
|
plan_file(Some(&local), Some(&remote), None),
|
||||||
FilePlan::AdoptBaseline
|
FilePlan::Conflict
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,6 +707,73 @@ mod file_plan_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Another computer's new revision can leave the modification time and
|
||||||
|
/// the size as they were. The revision id still tells it apart (B249).
|
||||||
|
#[test]
|
||||||
|
fn a_new_remote_revision_is_a_change_even_at_the_same_mtime_and_size() {
|
||||||
|
let mut base = baseline(10, 20, 30, 40);
|
||||||
|
base.remote_revision_id = Some("r1".into());
|
||||||
|
let unedited = local(10, 20);
|
||||||
|
let same = RemoteItem {
|
||||||
|
revision: Some("r1".into()),
|
||||||
|
..remote(30, 40)
|
||||||
|
};
|
||||||
|
let newer = RemoteItem {
|
||||||
|
revision: Some("r2".into()),
|
||||||
|
..remote(30, 40)
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&unedited), Some(&same), Some(&base)),
|
||||||
|
FilePlan::Unchanged
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&unedited), Some(&newer), Some(&base)),
|
||||||
|
FilePlan::Download
|
||||||
|
);
|
||||||
|
let edited = local(11, 20);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&edited), Some(&newer), Some(&base)),
|
||||||
|
FilePlan::Conflict
|
||||||
|
);
|
||||||
|
|
||||||
|
// A row from before schema 44 has no id to compare, and a node read
|
||||||
|
// without one says nothing: both fall back to the signature.
|
||||||
|
let old = baseline(10, 20, 30, 40);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&unedited), Some(&newer), Some(&old)),
|
||||||
|
FilePlan::Unchanged
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&unedited), Some(&remote(30, 40)), Some(&base)),
|
||||||
|
FilePlan::Unchanged
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The walk reuses the recorded size only while the revision is the one
|
||||||
|
/// the baseline holds.
|
||||||
|
#[test]
|
||||||
|
fn a_moved_revision_makes_the_walk_read_the_size_again() {
|
||||||
|
let mut base = baseline(10, 20, 30, 40);
|
||||||
|
base.remote_revision_id = Some("r1".into());
|
||||||
|
let rows = HashMap::from([("file".to_string(), base)]);
|
||||||
|
assert_eq!(
|
||||||
|
unchanged_remote_size(&rows, "file", 30, Some("r1")),
|
||||||
|
Some(40)
|
||||||
|
);
|
||||||
|
assert_eq!(unchanged_remote_size(&rows, "file", 30, None), Some(40));
|
||||||
|
assert_eq!(unchanged_remote_size(&rows, "file", 30, Some("r2")), None);
|
||||||
|
assert_eq!(unchanged_remote_size(&rows, "file", 31, Some("r1")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row without a revision id reuses its size only while Drive gives no
|
||||||
|
/// id either (B254).
|
||||||
|
#[test]
|
||||||
|
fn a_row_without_a_revision_makes_the_walk_read_the_size_again() {
|
||||||
|
let rows = HashMap::from([("file".to_string(), baseline(10, 20, 30, 40))]);
|
||||||
|
assert_eq!(unchanged_remote_size(&rows, "file", 30, Some("r1")), None);
|
||||||
|
assert_eq!(unchanged_remote_size(&rows, "file", 30, None), Some(40));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_remote_signature_is_treated_as_changed() {
|
fn a_missing_remote_signature_is_treated_as_changed() {
|
||||||
let local = local(10, 20);
|
let local = local(10, 20);
|
||||||
@@ -530,4 +786,96 @@ mod file_plan_tests {
|
|||||||
FilePlan::Download
|
FilePlan::Download
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn uid(id: &str) -> NodeUid {
|
||||||
|
NodeUid::new(VolumeId::from("v"), LinkId::from(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder() -> RemoteItem {
|
||||||
|
RemoteItem {
|
||||||
|
is_dir: true,
|
||||||
|
mtime: 0,
|
||||||
|
size: 0,
|
||||||
|
..remote(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty or failed remote listing must not read as "Drive deleted
|
||||||
|
/// everything" and delete the local copies.
|
||||||
|
#[test]
|
||||||
|
fn the_remote_wipe_guard_blocks_only_a_total_disappearance() {
|
||||||
|
let base = HashMap::from([("a".to_string(), ()), ("b".to_string(), ())]);
|
||||||
|
let none: HashMap<String, RemoteItem> = HashMap::new();
|
||||||
|
assert!(guard_remote_wipe(&base, &none).is_err());
|
||||||
|
let one = HashMap::from([("b".to_string(), remote(1, 1))]);
|
||||||
|
assert!(guard_remote_wipe(&base, &one).is_ok());
|
||||||
|
let empty: HashMap<String, ()> = HashMap::new();
|
||||||
|
assert!(guard_remote_wipe(&empty, &none).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node that failed to decrypt has no name, so everything a short
|
||||||
|
/// listing did not return is left out, and nothing else.
|
||||||
|
#[test]
|
||||||
|
fn a_short_remote_listing_hides_what_it_did_not_return() {
|
||||||
|
let remote = HashMap::from([
|
||||||
|
("docs".to_string(), folder()),
|
||||||
|
("docs/a.txt".to_string(), remote(1, 1)),
|
||||||
|
("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();
|
||||||
|
assert!(!gaps.hides("docs/b.txt", &remote, &dirs));
|
||||||
|
gaps.short_listing("docs");
|
||||||
|
// Returned paths stand; unreturned ones under the short folder hide,
|
||||||
|
// and so does anything under a subfolder it failed to return.
|
||||||
|
assert!(!gaps.hides("docs/a.txt", &remote, &dirs));
|
||||||
|
assert!(gaps.hides("docs/b.txt", &remote, &dirs));
|
||||||
|
assert!(gaps.hides("docs/sub/deep.txt", &remote, &dirs));
|
||||||
|
// A complete sibling listing still reads deletions as deletions.
|
||||||
|
assert!(!gaps.hides("pics/gone.jpg", &remote, &dirs));
|
||||||
|
assert!(!gaps.hides("top.txt", &remote, &dirs));
|
||||||
|
gaps.short_listing("");
|
||||||
|
assert!(gaps.hides("top.txt", &remote, &dirs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_whose_size_could_not_be_read_is_hidden() {
|
||||||
|
let remote: HashMap<String, RemoteItem> = HashMap::new();
|
||||||
|
let dirs = HashMap::from([(String::new(), uid("root"))]);
|
||||||
|
let mut gaps = RemoteGaps::default();
|
||||||
|
gaps.unread("a.txt".to_string());
|
||||||
|
assert!(gaps.hides("a.txt", &remote, &dirs));
|
||||||
|
assert!(!gaps.hides("b.txt", &remote, &dirs));
|
||||||
|
assert_eq!(gaps.report(), ["a.txt (cannot be read on Proton Drive)"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder deleted here holds another device's new work on Drive, so
|
||||||
|
/// trashing it would lose that work.
|
||||||
|
#[test]
|
||||||
|
fn a_remote_folder_with_new_or_changed_files_is_not_trashed() {
|
||||||
|
let mut base = HashMap::from([
|
||||||
|
("x".to_string(), baseline(0, 0, 0, 0)),
|
||||||
|
("x/old.txt".to_string(), baseline(1, 1, 5, 6)),
|
||||||
|
]);
|
||||||
|
let mut remote_tree = HashMap::from([
|
||||||
|
("x".to_string(), folder()),
|
||||||
|
("x/old.txt".to_string(), remote(5, 6)),
|
||||||
|
("xy/new.txt".to_string(), remote(1, 1)),
|
||||||
|
]);
|
||||||
|
// Unchanged under x; a name-prefixed sibling does not count.
|
||||||
|
assert_eq!(remote_subtree_changed("x", &remote_tree, &base), None);
|
||||||
|
|
||||||
|
remote_tree.insert("x/old.txt".to_string(), remote(9, 6));
|
||||||
|
assert!(remote_subtree_changed("x", &remote_tree, &base).is_some());
|
||||||
|
|
||||||
|
remote_tree.insert("x/old.txt".to_string(), remote(5, 6));
|
||||||
|
remote_tree.insert("x/sub".to_string(), folder());
|
||||||
|
assert!(remote_subtree_changed("x", &remote_tree, &base).is_some());
|
||||||
|
base.insert("x/sub".to_string(), baseline(0, 0, 0, 0));
|
||||||
|
assert_eq!(remote_subtree_changed("x", &remote_tree, &base), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ use std::collections::{BTreeMap, HashMap};
|
|||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
|
||||||
use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection};
|
use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection};
|
||||||
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
|
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
|
||||||
@@ -36,6 +36,7 @@ use sha1::{Digest, Sha1};
|
|||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use super::Core;
|
use super::Core;
|
||||||
|
use crate::photos::can_make_thumbnail;
|
||||||
use crate::transfers::CountingReader;
|
use crate::transfers::CountingReader;
|
||||||
|
|
||||||
/// The one import this daemon may be running, and how the last one ended.
|
/// The one import this daemon may be running, and how the last one ended.
|
||||||
@@ -136,6 +137,30 @@ fn read_entry<R>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
|
/// Copy archive entry `entry` into the thumbnail staging folder and return
|
||||||
|
/// its path. The caller removes it.
|
||||||
|
fn stage_takeout_entry(
|
||||||
|
&self,
|
||||||
|
archive: &mut zip::ZipArchive<std::io::BufReader<std::fs::File>>,
|
||||||
|
entry: &str,
|
||||||
|
) -> CoreResult<PathBuf> {
|
||||||
|
static STAGED: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let path = self.cache.raw_thumbnail_staging_dir().join(format!(
|
||||||
|
"takeout-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
STAGED.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
let copied = read_entry(archive, entry, |reader| {
|
||||||
|
let mut file = std::fs::File::create(&path)?;
|
||||||
|
std::io::copy(reader, &mut file)
|
||||||
|
})?;
|
||||||
|
if let Err(e) = copied {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
return Err(CoreError::internal(format!("copy {entry}: {e}")));
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
/// Import Google Photos Takeout archives. Blocking; run on its own thread.
|
/// Import Google Photos Takeout archives. Blocking; run on its own thread.
|
||||||
///
|
///
|
||||||
/// `cancel` stops the run at the next photo boundary — an upload already on
|
/// `cancel` stops the run at the next photo boundary — an upload already on
|
||||||
@@ -321,18 +346,65 @@ impl Core {
|
|||||||
capture_time: photo.capture_time,
|
capture_time: photo.capture_time,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let uploaded = read_entry(&mut archive, &photo.entry, |reader| {
|
// A thumbnail is made from a file, so a photo that can have one
|
||||||
let counting = CountingReader::new(reader, &guard);
|
// is copied out of the archive first and uploaded from the copy.
|
||||||
self.rt.block_on(photos.upload_photo_from(
|
// Without one, Proton's apps show a blank tile until they make
|
||||||
&photo.name,
|
// their own.
|
||||||
&photo.media_type,
|
let staged = can_make_thumbnail(&photo.name, photo.size)
|
||||||
counting,
|
.then(|| self.stage_takeout_entry(&mut archive, &photo.entry))
|
||||||
photo.size as i64,
|
.and_then(|staged| {
|
||||||
Vec::new(),
|
staged
|
||||||
metadata,
|
.inspect_err(|e| {
|
||||||
false,
|
warn!(name = photo.name, error = %e,
|
||||||
))
|
"copying a takeout photo out failed; uploading it without a thumbnail")
|
||||||
});
|
})
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
let uploaded = match &staged {
|
||||||
|
Some(path) => {
|
||||||
|
let thumbnails = self.upload_thumbnails(path, &photo.name);
|
||||||
|
// No sidecar and no date in the name: the photo's own
|
||||||
|
// EXIF, before Drive files it under today.
|
||||||
|
let metadata = PhotoUploadMetadata {
|
||||||
|
capture_time: metadata
|
||||||
|
.capture_time
|
||||||
|
.or_else(|| pdfs_core::exiftime::capture_time_of(path)),
|
||||||
|
..metadata
|
||||||
|
};
|
||||||
|
std::fs::File::open(path)
|
||||||
|
.map_err(|e| {
|
||||||
|
CoreError::internal(format!("open {}: {e}", path.display()))
|
||||||
|
})
|
||||||
|
.map(|file| {
|
||||||
|
let counting =
|
||||||
|
CountingReader::new(std::io::BufReader::new(file), &guard);
|
||||||
|
self.rt.block_on(photos.upload_photo_from(
|
||||||
|
&photo.name,
|
||||||
|
&photo.media_type,
|
||||||
|
counting,
|
||||||
|
photo.size as i64,
|
||||||
|
thumbnails,
|
||||||
|
metadata,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => read_entry(&mut archive, &photo.entry, |reader| {
|
||||||
|
let counting = CountingReader::new(reader, &guard);
|
||||||
|
self.rt.block_on(photos.upload_photo_from(
|
||||||
|
&photo.name,
|
||||||
|
&photo.media_type,
|
||||||
|
counting,
|
||||||
|
photo.size as i64,
|
||||||
|
Vec::new(),
|
||||||
|
metadata,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
if let Some(path) = staged {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
drop(guard);
|
drop(guard);
|
||||||
job.step();
|
job.step();
|
||||||
|
|
||||||
@@ -480,7 +552,15 @@ impl Core {
|
|||||||
let mut state = IMPORT.lock();
|
let mut state = IMPORT.lock();
|
||||||
state.running = false;
|
state.running = false;
|
||||||
state.cancel = None;
|
state.cancel = None;
|
||||||
state.last = outcome.as_ref().ok().cloned();
|
// A failed run is reported too: without it, `--wait` read
|
||||||
|
// "no import has run" and exited as if all was well.
|
||||||
|
state.last = Some(match &outcome {
|
||||||
|
Ok(summary) => summary.clone(),
|
||||||
|
Err(e) => ImportSummary {
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
match outcome {
|
match outcome {
|
||||||
Ok(summary) => {
|
Ok(summary) => {
|
||||||
@@ -545,7 +625,7 @@ pub(crate) fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
|
|||||||
Ok(hex_lower(&hasher.finalize()))
|
Ok(hex_lower(&hasher.finalize()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hex_lower(bytes: &[u8]) -> String {
|
pub(crate) fn hex_lower(bytes: &[u8]) -> String {
|
||||||
let mut out = String::with_capacity(bytes.len() * 2);
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
for byte in bytes {
|
for byte in bytes {
|
||||||
out.push_str(&format!("{byte:02x}"));
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ impl TransferRegistry {
|
|||||||
self.download_limit.set(download);
|
self.download_limit.set(download);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The upload cap in bytes per second, `0` when there is none.
|
||||||
|
pub fn upload_cap(&self) -> u64 {
|
||||||
|
self.upload_limit.rate.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
fn limit(&self, direction: TransferDirection) -> &RateLimit {
|
fn limit(&self, direction: TransferDirection) -> &RateLimit {
|
||||||
match direction {
|
match direction {
|
||||||
TransferDirection::Upload => &self.upload_limit,
|
TransferDirection::Upload => &self.upload_limit,
|
||||||
|
|||||||
@@ -10,9 +10,14 @@
|
|||||||
//! `dead_code` allowances below are for — not code nobody uses.
|
//! `dead_code` allowances below are for — not code nobody uses.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use pdfs_core::control::{PhotoKind, SearchHit};
|
use pdfs_core::control::{PhotoKind, SearchHit};
|
||||||
|
|
||||||
|
/// How long a `stat` of the mount may take before the file counts as not
|
||||||
|
/// there. A frozen mount never answers, and every caller has a fallback.
|
||||||
|
const STAT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum DriveActivation {
|
pub(crate) enum DriveActivation {
|
||||||
Folder,
|
Folder,
|
||||||
@@ -55,18 +60,32 @@ pub(crate) fn mounted_target(mountpoint: &Path, hit: &SearchHit) -> Option<PathB
|
|||||||
present(mounted_or_relative(mountpoint, hit))
|
present(mounted_or_relative(mountpoint, hit))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`mounted_target`] for callers holding a mountpoint-relative path rather
|
|
||||||
/// than a search hit — the browser's listing entries, which are by definition
|
|
||||||
/// paths in the primary mount's tree.
|
|
||||||
#[allow(dead_code)] // browser only
|
|
||||||
pub(crate) fn mounted_target_rel(mountpoint: &Path, relative_path: &str) -> Option<PathBuf> {
|
|
||||||
present(mounted_path(mountpoint, relative_path))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn present(path: PathBuf) -> Option<PathBuf> {
|
fn present(path: PathBuf) -> Option<PathBuf> {
|
||||||
path.symlink_metadata().is_ok().then_some(path)
|
path.symlink_metadata().is_ok().then_some(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether anything is at `path`, asked on a thread of its own and given up
|
||||||
|
/// on after [`STAT_TIMEOUT`]. `None` means "not there" or "no answer".
|
||||||
|
///
|
||||||
|
/// For the GTK binaries, whose main thread must not `stat` the mount: a
|
||||||
|
/// mount that hangs hung the whole window with it. The thread of a `stat`
|
||||||
|
/// that never returns is left behind; there is no way to cancel one.
|
||||||
|
#[allow(dead_code)] // GTK binaries only
|
||||||
|
pub(crate) async fn present_within(path: PathBuf) -> Option<PathBuf> {
|
||||||
|
let (tx, rx) = async_channel::bounded(1);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = tx.send_blocking(present(path));
|
||||||
|
});
|
||||||
|
match gtk4::glib::future_with_timeout(STAT_TIMEOUT, rx.recv()).await {
|
||||||
|
Ok(Ok(target)) => target,
|
||||||
|
Ok(Err(_)) => None,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!("the mount did not answer in time");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The mount path for a hit, whether or not anything is there — for callers
|
/// The mount path for a hit, whether or not anything is there — for callers
|
||||||
/// that have no fallback (a folder cannot be materialised) or already know the
|
/// that have no fallback (a folder cannot be materialised) or already know the
|
||||||
/// entry came from a mount listing.
|
/// entry came from a mount listing.
|
||||||
|
|||||||
+133
-11
@@ -69,7 +69,7 @@ use gtk4::glib::BoxedAnyObject;
|
|||||||
|
|
||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
|
|
||||||
use pdfs_core::config::{AppDirs, FileSort, FilesView};
|
use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState};
|
||||||
|
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
||||||
@@ -130,8 +130,12 @@ struct Ui {
|
|||||||
/// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere.
|
/// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere.
|
||||||
sidebar: gtk4::ListBox,
|
sidebar: gtk4::ListBox,
|
||||||
/// The sidebar/content split. Collapsed while signed out, so the login page
|
/// The sidebar/content split. Collapsed while signed out, so the login page
|
||||||
/// owns the whole window and no destination is reachable without a session.
|
/// owns the whole window and no destination is reachable without a session,
|
||||||
|
/// and while the window is [`narrow`](Self::narrow).
|
||||||
nav: adw::NavigationSplitView,
|
nav: adw::NavigationSplitView,
|
||||||
|
/// The window is too narrow for the sidebar beside a page, so the two take
|
||||||
|
/// turns. Set by the narrow breakpoint; [`refresh`] reads it.
|
||||||
|
narrow: Cell<bool>,
|
||||||
/// Shared thumbnails for ordinary image files outside the Photos gallery.
|
/// Shared thumbnails for ordinary image files outside the Photos gallery.
|
||||||
/// One cache and request queue serves Files, search, Shared and Trash, so
|
/// One cache and request queue serves Files, search, Shared and Trash, so
|
||||||
/// the same image is downloaded and decoded only once.
|
/// the same image is downloaded and decoded only once.
|
||||||
@@ -297,7 +301,10 @@ fn present_window(app: &adw::Application) {
|
|||||||
/// Spawn the tray icon process in the background.
|
/// Spawn the tray icon process in the background.
|
||||||
pub(crate) fn spawn_tray() {
|
pub(crate) fn spawn_tray() {
|
||||||
match Command::new("pdfs-tray").spawn() {
|
match Command::new("pdfs-tray").spawn() {
|
||||||
Ok(_) => tracing::info!("spawned `pdfs-tray`"),
|
Ok(child) => {
|
||||||
|
tracing::info!("spawned `pdfs-tray`");
|
||||||
|
pdfs_core::opener::reap(child);
|
||||||
|
}
|
||||||
Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"),
|
Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +325,9 @@ fn build_window(app: &adw::Application) {
|
|||||||
tracing::warn!("cannot create the app's directories: {e}");
|
tracing::warn!("cannot create the app's directories: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let stack = adw::ViewStack::new();
|
// Not homogeneous: each page sets the window's minimum width only while it
|
||||||
|
// is shown, so the widest page does not hold every other one wide.
|
||||||
|
let stack = adw::ViewStack::builder().hhomogeneous(false).build();
|
||||||
let (login_page, login_widgets) = build_login_page();
|
let (login_page, login_widgets) = build_login_page();
|
||||||
let main_widgets = build_main_page();
|
let main_widgets = build_main_page();
|
||||||
let (browser_page, browser_widgets) = build_browser_page();
|
let (browser_page, browser_widgets) = build_browser_page();
|
||||||
@@ -336,8 +345,12 @@ fn build_window(app: &adw::Application) {
|
|||||||
login_frame.add_top_bar(&{
|
login_frame.add_top_bar(&{
|
||||||
let header = adw::HeaderBar::new();
|
let header = adw::HeaderBar::new();
|
||||||
header.set_show_title(false);
|
header.set_show_title(false);
|
||||||
|
// The split is collapsed while signed out; going "back" would only
|
||||||
|
// uncover a sidebar with nowhere to go.
|
||||||
|
header.set_show_back_button(false);
|
||||||
header
|
header
|
||||||
});
|
});
|
||||||
|
login_frame.add_top_bar(&login_widgets.keyring_banner);
|
||||||
login_frame.set_content(Some(&login_page));
|
login_frame.set_content(Some(&login_page));
|
||||||
stack.add_named(&login_frame, Some("login"));
|
stack.add_named(&login_frame, Some("login"));
|
||||||
stack.add_named(&browser_page, Some("browser"));
|
stack.add_named(&browser_page, Some("browser"));
|
||||||
@@ -365,6 +378,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
.content(&content_page)
|
.content(&content_page)
|
||||||
.min_sidebar_width(200.0)
|
.min_sidebar_width(200.0)
|
||||||
.max_sidebar_width(240.0)
|
.max_sidebar_width(240.0)
|
||||||
|
// A narrow window opens on the page, not the sidebar.
|
||||||
|
.show_content(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Toasts float over everything, so a report from a background action reaches
|
// Toasts float over everything, so a report from a background action reaches
|
||||||
@@ -372,17 +387,22 @@ fn build_window(app: &adw::Application) {
|
|||||||
let toasts = adw::ToastOverlay::new();
|
let toasts = adw::ToastOverlay::new();
|
||||||
toasts.set_child(Some(&split));
|
toasts.set_child(Some(&split));
|
||||||
|
|
||||||
|
let (startup_session, keyring_error) = match read_session() {
|
||||||
|
Ok(session) => (session, None),
|
||||||
|
Err(e) => (None, Some(e)),
|
||||||
|
};
|
||||||
let ui = Rc::new(Ui {
|
let ui = Rc::new(Ui {
|
||||||
dirs,
|
dirs,
|
||||||
stack: stack.clone(),
|
stack: stack.clone(),
|
||||||
toasts: toasts.clone(),
|
toasts: toasts.clone(),
|
||||||
busy: Cell::new(0),
|
busy: Cell::new(0),
|
||||||
opening: RefCell::new(HashSet::new()),
|
opening: RefCell::new(HashSet::new()),
|
||||||
session: RefCell::new(auth::load().ok()),
|
session: RefCell::new(startup_session),
|
||||||
unlock_offered: Cell::new(false),
|
unlock_offered: Cell::new(false),
|
||||||
mounted: RefCell::new(false),
|
mounted: RefCell::new(false),
|
||||||
sidebar: sidebar_list.clone(),
|
sidebar: sidebar_list.clone(),
|
||||||
nav: split.clone(),
|
nav: split.clone(),
|
||||||
|
narrow: Cell::new(false),
|
||||||
file_thumbs: FileThumbnailState::new(),
|
file_thumbs: FileThumbnailState::new(),
|
||||||
login: login_widgets,
|
login: login_widgets,
|
||||||
status: StatusState {
|
status: StatusState {
|
||||||
@@ -636,6 +656,9 @@ fn build_window(app: &adw::Application) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
wire_login(&ui);
|
wire_login(&ui);
|
||||||
|
if let Some(e) = keyring_error {
|
||||||
|
keyring_unreadable(&ui, &e);
|
||||||
|
}
|
||||||
let ui_status = ui.clone();
|
let ui_status = ui.clone();
|
||||||
main_widgets
|
main_widgets
|
||||||
.status_button
|
.status_button
|
||||||
@@ -713,13 +736,18 @@ fn build_window(app: &adw::Application) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let saved = ui.dirs.load_config().window.usable();
|
||||||
let window = adw::ApplicationWindow::builder()
|
let window = adw::ApplicationWindow::builder()
|
||||||
.application(app)
|
.application(app)
|
||||||
.title("Proton Drive")
|
.title("Proton Drive")
|
||||||
.default_width(980)
|
.default_width(saved.width)
|
||||||
.default_height(680)
|
.default_height(saved.height)
|
||||||
|
.maximized(saved.maximized)
|
||||||
|
.width_request(WindowState::MIN_WIDTH)
|
||||||
|
.height_request(WindowState::MIN_HEIGHT)
|
||||||
.content(&toasts)
|
.content(&toasts)
|
||||||
.build();
|
.build();
|
||||||
|
remember_window_state(&ui, app, &window);
|
||||||
// A wide window docks the details pane beside the files instead of laying it
|
// A wide window docks the details pane beside the files instead of laying it
|
||||||
// over them.
|
// over them.
|
||||||
let wide = adw::Breakpoint::new(
|
let wide = adw::Breakpoint::new(
|
||||||
@@ -727,6 +755,18 @@ fn build_window(app: &adw::Application) {
|
|||||||
);
|
);
|
||||||
wide.add_setter(&ui.browser.split, "collapsed", Some(&false.to_value()));
|
wide.add_setter(&ui.browser.split, "collapsed", Some(&false.to_value()));
|
||||||
window.add_breakpoint(wide);
|
window.add_breakpoint(wide);
|
||||||
|
// A narrow window shows the sidebar and the page in turn, so every header
|
||||||
|
// bar keeps room for the window buttons. Signals rather than a setter:
|
||||||
|
// `refresh` decides the collapse while signed out, and would undo one.
|
||||||
|
let narrow = adw::Breakpoint::new(
|
||||||
|
adw::BreakpointCondition::parse("max-width: 760sp").expect("static breakpoint"),
|
||||||
|
);
|
||||||
|
narrow.add_setter(&ui.browser.search, "width-chars", Some(&8.to_value()));
|
||||||
|
let ui_narrow = ui.clone();
|
||||||
|
narrow.connect_apply(move |_| set_narrow(&ui_narrow, true));
|
||||||
|
let ui_wide = ui.clone();
|
||||||
|
narrow.connect_unapply(move |_| set_narrow(&ui_wide, false));
|
||||||
|
window.add_breakpoint(narrow);
|
||||||
install_shortcuts(&ui, &window);
|
install_shortcuts(&ui, &window);
|
||||||
install_window_actions(&ui, &window);
|
install_window_actions(&ui, &window);
|
||||||
install_launch_actions(&ui, app, &window);
|
install_launch_actions(&ui, app, &window);
|
||||||
@@ -740,6 +780,51 @@ fn build_window(app: &adw::Application) {
|
|||||||
window.present();
|
window.present();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Follow the narrow breakpoint. While signed out the login page keeps the
|
||||||
|
/// split collapsed whatever the width.
|
||||||
|
fn set_narrow(ui: &Ui, narrow: bool) {
|
||||||
|
ui.narrow.set(narrow);
|
||||||
|
if ui.session.borrow().is_some() {
|
||||||
|
ui.nav.set_collapsed(narrow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the window's size when it closes, and when the app quits with the
|
||||||
|
/// window still open (Ctrl+Q, the tray's Quit), which never asks it to close.
|
||||||
|
fn remember_window_state(ui: &Rc<Ui>, app: &adw::Application, window: &adw::ApplicationWindow) {
|
||||||
|
let ui_close = ui.clone();
|
||||||
|
window.connect_close_request(move |window| {
|
||||||
|
save_window_state(&ui_close, window);
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
let ui_quit = ui.clone();
|
||||||
|
let window_quit = window.downgrade();
|
||||||
|
app.connect_shutdown(move |_| {
|
||||||
|
if let Some(window) = window_quit.upgrade() {
|
||||||
|
save_window_state(&ui_quit, &window);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the window's unmaximised size and maximised state to the config, if
|
||||||
|
/// either changed. Runs as the window goes away, so a failure is only logged.
|
||||||
|
fn save_window_state(ui: &Ui, window: &adw::ApplicationWindow) {
|
||||||
|
let (width, height) = window.default_size();
|
||||||
|
let state = WindowState {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maximized: window.is_maximized(),
|
||||||
|
};
|
||||||
|
let mut config = ui.dirs.load_config();
|
||||||
|
if config.window == state {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
config.window = state;
|
||||||
|
if let Err(e) = ui.dirs.save_config(&config) {
|
||||||
|
tracing::warn!("cannot save the window size: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether `window` is minimised or otherwise out of sight (another workspace,
|
/// Whether `window` is minimised or otherwise out of sight (another workspace,
|
||||||
/// fully covered), as far as the compositor tells us.
|
/// fully covered), as far as the compositor tells us.
|
||||||
fn window_hidden(window: &adw::ApplicationWindow) -> bool {
|
fn window_hidden(window: &adw::ApplicationWindow) -> bool {
|
||||||
@@ -867,6 +952,18 @@ fn set_busy_spinners(visible: bool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `row` in a frame that scrolls sideways once the window is too narrow for it,
|
||||||
|
/// so a row of labelled buttons never sets the window's minimum width.
|
||||||
|
fn scroll_sideways(row: &impl IsA<gtk4::Widget>) -> gtk4::ScrolledWindow {
|
||||||
|
gtk4::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||||
|
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.propagate_natural_width(true)
|
||||||
|
.propagate_natural_height(true)
|
||||||
|
.child(row)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
/// A destination page's frame: its own header bar over the page content, the
|
/// A destination page's frame: its own header bar over the page content, the
|
||||||
/// way GNOME apps lay a split view out. The title sits in the middle with the
|
/// way GNOME apps lay a split view out. The title sits in the middle with the
|
||||||
/// busy spinner beside it; the page packs its actions into the returned header
|
/// busy spinner beside it; the page packs its actions into the returned header
|
||||||
@@ -930,6 +1027,14 @@ fn wire_sidebar(ui: &Rc<Ui>) {
|
|||||||
ui_row.stack.set_visible_child_name(page);
|
ui_row.stack.set_visible_child_name(page);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// In a narrow window the sidebar covers the page, so picking a row, even
|
||||||
|
// the selected one, has to bring the page forward.
|
||||||
|
let ui_open = ui.clone();
|
||||||
|
ui.sidebar.connect_row_activated(move |_, _| {
|
||||||
|
if ui_open.nav.is_collapsed() {
|
||||||
|
ui_open.nav.set_show_content(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-fetch the visible page from the server, bypassing every layer of cache
|
/// Re-fetch the visible page from the server, bypassing every layer of cache
|
||||||
@@ -1065,8 +1170,17 @@ pub(crate) fn set_sidebar_badge(ui: &Ui, page: &str, count: u64) {
|
|||||||
/// Show a transient toast. Non-blocking by design: an action's outcome is
|
/// Show a transient toast. Non-blocking by design: an action's outcome is
|
||||||
/// reported without stealing focus or forcing a click, so the user can keep
|
/// reported without stealing focus or forcing a click, so the user can keep
|
||||||
/// working while a slow upload lands.
|
/// working while a slow upload lands.
|
||||||
|
///
|
||||||
|
/// A toast's title is Pango markup unless told otherwise. Messages carry file
|
||||||
|
/// names and daemon errors, and one with a `&` or `<` in it showed as an empty
|
||||||
|
/// toast, so every toast here is plain text.
|
||||||
fn toast(ui: &Rc<Ui>, message: &str) {
|
fn toast(ui: &Rc<Ui>, message: &str) {
|
||||||
ui.toasts.add_toast(adw::Toast::new(message));
|
ui.toasts.add_toast(
|
||||||
|
adw::Toast::builder()
|
||||||
|
.title(message)
|
||||||
|
.use_markup(false)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a toast that offers one follow-up action — an Undo, or a way to the page
|
/// Show a toast that offers one follow-up action — an Undo, or a way to the page
|
||||||
@@ -1077,6 +1191,7 @@ fn toast(ui: &Rc<Ui>, message: &str) {
|
|||||||
fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) {
|
fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) {
|
||||||
let toast = adw::Toast::builder()
|
let toast = adw::Toast::builder()
|
||||||
.title(message)
|
.title(message)
|
||||||
|
.use_markup(false)
|
||||||
.button_label(label)
|
.button_label(label)
|
||||||
.timeout(8)
|
.timeout(8)
|
||||||
.build();
|
.build();
|
||||||
@@ -1099,7 +1214,11 @@ fn toast_error(ui: &Rc<Ui>, what: &str, detail: &str) {
|
|||||||
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
|
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
|
||||||
};
|
};
|
||||||
tracing::warn!("{message}");
|
tracing::warn!("{message}");
|
||||||
let toast = adw::Toast::builder().title(&message).timeout(6).build();
|
let toast = adw::Toast::builder()
|
||||||
|
.title(&message)
|
||||||
|
.use_markup(false)
|
||||||
|
.timeout(6)
|
||||||
|
.build();
|
||||||
ui.toasts.add_toast(toast);
|
ui.toasts.add_toast(toast);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1699,9 +1818,12 @@ pub(crate) fn flash_copied(button: >k4::Button, icon: &'static str) {
|
|||||||
|
|
||||||
/// Open a local path with the handler the user configured — `xdg-open` unless
|
/// Open a local path with the handler the user configured — `xdg-open` unless
|
||||||
/// `open_with` in `config.json` overrides it for this kind of file.
|
/// `open_with` in `config.json` overrides it for this kind of file.
|
||||||
|
///
|
||||||
|
/// On a thread of its own: telling a folder from a file is a `stat`, and the
|
||||||
|
/// path is often in the mount, which may hang.
|
||||||
fn open_path(path: &str) {
|
fn open_path(path: &str) {
|
||||||
let path = Path::new(path);
|
let path = PathBuf::from(path);
|
||||||
pdfs_core::opener::open_default(path, path.is_dir());
|
std::thread::spawn(move || pdfs_core::opener::open_default(&path, path.is_dir()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`open_path`] for a file the daemon materialised into the content cache,
|
/// [`open_path`] for a file the daemon materialised into the content cache,
|
||||||
|
|||||||
@@ -378,7 +378,9 @@ pub(crate) fn activity_status(
|
|||||||
) {
|
) {
|
||||||
ui.activity.status.set_icon_name(Some(icon));
|
ui.activity.status.set_icon_name(Some(icon));
|
||||||
ui.activity.status.set_title(title);
|
ui.activity.status.set_title(title);
|
||||||
ui.activity.status.set_description(Some(description));
|
ui.activity
|
||||||
|
.status
|
||||||
|
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||||
ui.activity.retry.set_visible(retry);
|
ui.activity.retry.set_visible(retry);
|
||||||
ui.activity.content.set_visible_child_name("status");
|
ui.activity.content.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -645,7 +645,9 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
|
|||||||
fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
||||||
ui.gallery.albums_status.set_icon_name(Some(icon));
|
ui.gallery.albums_status.set_icon_name(Some(icon));
|
||||||
ui.gallery.albums_status.set_title(title);
|
ui.gallery.albums_status.set_title(title);
|
||||||
ui.gallery.albums_status.set_description(Some(description));
|
ui.gallery
|
||||||
|
.albums_status
|
||||||
|
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||||
ui.gallery.albums_stack.set_visible_child_name("status");
|
ui.gallery.albums_stack.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::activation::{DriveActivation, drive_activation, mounted_path, mounted_target_rel};
|
use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within};
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
pub(crate) struct BrowserState {
|
pub(crate) struct BrowserState {
|
||||||
@@ -376,7 +376,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
|||||||
bulk_box.append(&bulk_clear);
|
bulk_box.append(&bulk_clear);
|
||||||
let bulk = gtk4::Revealer::builder()
|
let bulk = gtk4::Revealer::builder()
|
||||||
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
||||||
.child(&bulk_box)
|
.child(&scroll_sideways(&bulk_box))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// The status bar: contextual text (stretch 1), then the icon-size slider.
|
// The status bar: contextual text (stretch 1), then the icon-size slider.
|
||||||
@@ -500,7 +500,9 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
|||||||
pub(crate) fn browser_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
pub(crate) fn browser_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||||
ui.browser.status.set_icon_name(Some(icon));
|
ui.browser.status.set_icon_name(Some(icon));
|
||||||
ui.browser.status.set_title(title);
|
ui.browser.status.set_title(title);
|
||||||
ui.browser.status.set_description(Some(description));
|
ui.browser
|
||||||
|
.status
|
||||||
|
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||||
ui.browser.retry.set_visible(retry);
|
ui.browser.retry.set_visible(retry);
|
||||||
// Only the empty-folder state offers a way to fill the folder; every other
|
// Only the empty-folder state offers a way to fill the folder; every other
|
||||||
// status (loading, offline, error) turns them back off.
|
// status (loading, offline, error) turns them back off.
|
||||||
@@ -981,28 +983,27 @@ pub(crate) fn background_context_menu(ui: &Rc<Ui>) -> ActionMenu {
|
|||||||
pub(crate) fn open_entry_with(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn open_entry_with(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
let rel = entry_rel(ui, entry);
|
let rel = entry_rel(ui, entry);
|
||||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||||
let Some(path) = mounted_target_rel(&mountpoint, &rel) else {
|
|
||||||
toast_error(
|
|
||||||
ui,
|
|
||||||
&gettext("Couldn't open file"),
|
|
||||||
&gettext("Open With needs the Proton Drive service to be running."),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(path)));
|
|
||||||
launcher.set_always_ask(true);
|
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
launcher.launch(
|
glib::spawn_future_local(async move {
|
||||||
ui_window(&ui).as_ref(),
|
let Some(path) = present_within(mounted_path(&mountpoint, &rel)).await else {
|
||||||
gio::Cancellable::NONE,
|
toast_error(
|
||||||
move |result| {
|
&ui,
|
||||||
|
&gettext("Couldn't open file"),
|
||||||
|
&gettext("Open With needs the Proton Drive service to be running."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(path)));
|
||||||
|
launcher.set_always_ask(true);
|
||||||
|
let window = ui_window(&ui);
|
||||||
|
launcher.launch(window.as_ref(), gio::Cancellable::NONE, move |result| {
|
||||||
if let Err(e) = result
|
if let Err(e) = result
|
||||||
&& !e.matches(gtk4::DialogError::Dismissed)
|
&& !e.matches(gtk4::DialogError::Dismissed)
|
||||||
{
|
{
|
||||||
toast_error(&ui, &gettext("Couldn't open file"), &e.to_string());
|
toast_error(&ui, &gettext("Couldn't open file"), &e.to_string());
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy the entry's public link, when it has one this client can read the
|
/// Copy the entry's public link, when it has one this client can read the
|
||||||
@@ -1110,27 +1111,29 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
/// [`mounted_target`](crate::activation::mounted_target).
|
/// [`mounted_target`](crate::activation::mounted_target).
|
||||||
pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
let rel = entry_rel(ui, entry);
|
let rel = entry_rel(ui, entry);
|
||||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
// Ignore a repeat activation of a file already opening, so an impatient
|
||||||
if let Some(path) = mounted_target_rel(&mountpoint, &rel) {
|
// double-click doesn't open it twice or kick off a second round-trip.
|
||||||
open_path(&path.to_string_lossy());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Ignore a repeat activation of a file already downloading, so an impatient
|
|
||||||
// double-click doesn't kick off a second round-trip.
|
|
||||||
if !ui.opening.borrow_mut().insert(rel.clone()) {
|
if !ui.opening.borrow_mut().insert(rel.clone()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.busy_begin();
|
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||||
let name = entry.name.clone();
|
let (name, uid) = (entry.name.clone(), entry.uid.clone());
|
||||||
let rx = spawn_request(
|
|
||||||
ui.dirs.control_socket(),
|
|
||||||
Request::OpenFile {
|
|
||||||
path: rel.clone(),
|
|
||||||
uid: Some(entry.uid.clone()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
|
// Asked off the main thread: a mount that hangs used to hang the app.
|
||||||
|
if let Some(path) = present_within(mounted_path(&mountpoint, &rel)).await {
|
||||||
|
ui.opening.borrow_mut().remove(&rel);
|
||||||
|
open_path(&path.to_string_lossy());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::OpenFile {
|
||||||
|
path: rel.clone(),
|
||||||
|
uid: Some(uid),
|
||||||
|
},
|
||||||
|
);
|
||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
ui.opening.borrow_mut().remove(&rel);
|
ui.opening.borrow_mut().remove(&rel);
|
||||||
|
|||||||
@@ -204,7 +204,9 @@ pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
|
|||||||
pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||||
ui.devices.status.set_icon_name(Some(icon));
|
ui.devices.status.set_icon_name(Some(icon));
|
||||||
ui.devices.status.set_title(title);
|
ui.devices.status.set_title(title);
|
||||||
ui.devices.status.set_description(Some(description));
|
ui.devices
|
||||||
|
.status
|
||||||
|
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||||
ui.devices.retry.set_visible(retry);
|
ui.devices.retry.set_visible(retry);
|
||||||
ui.devices.content.set_visible_child_name("status");
|
ui.devices.content.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
@@ -390,6 +392,7 @@ fn this_computer_row(
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone()))
|
.title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone()))
|
||||||
.subtitle(this_computer_subtitle(folders))
|
.subtitle(this_computer_subtitle(folders))
|
||||||
|
.use_markup(false)
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||||
@@ -426,6 +429,7 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&dev.name)
|
.title(&dev.name)
|
||||||
.subtitle(device_subtitle(dev))
|
.subtitle(device_subtitle(dev))
|
||||||
|
.use_markup(false)
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||||
@@ -440,6 +444,10 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
|
|||||||
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
|
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
|
||||||
});
|
});
|
||||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||||
|
menu.item(&gettext("Sync With This Computer…"), move || {
|
||||||
|
prompt_join_device(&ui_c, &uid_c, &name_c)
|
||||||
|
});
|
||||||
|
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||||
menu.item(&gettext("Move to This Computer…"), move || {
|
menu.item(&gettext("Move to This Computer…"), move || {
|
||||||
prompt_migrate_device(&ui_c, &uid_c, &name_c)
|
prompt_migrate_device(&ui_c, &uid_c, &name_c)
|
||||||
});
|
});
|
||||||
@@ -763,7 +771,10 @@ fn show_restore_picker(
|
|||||||
.active(true)
|
.active(true)
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
let row = adw::EntryRow::builder().title(&f.name).build();
|
let row = adw::EntryRow::builder()
|
||||||
|
.title(&f.name)
|
||||||
|
.use_markup(false)
|
||||||
|
.build();
|
||||||
row.set_text(&f.local_path);
|
row.set_text(&f.local_path);
|
||||||
row.add_prefix(&check);
|
row.add_prefix(&check);
|
||||||
group.add(&row);
|
group.add(&row);
|
||||||
@@ -1093,6 +1104,69 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
|||||||
dialog.present(win.as_ref());
|
dialog.present(win.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask before syncing into another computer's device alongside it, then offer
|
||||||
|
/// its folders for syncing here.
|
||||||
|
///
|
||||||
|
/// The same adoption as "Continue This Backup Here", but the other computer
|
||||||
|
/// goes on syncing, so the dialog says what that means: the two keep the same
|
||||||
|
/// folders in step, deletions included.
|
||||||
|
pub(crate) fn prompt_join_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||||
|
let win = ui_window(ui);
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Sync With This Computer?"))
|
||||||
|
// Translators: {name} is the name of the other computer this one syncs with.
|
||||||
|
.body(gettext_f(
|
||||||
|
"This computer and “{name}” keep the same folders in step. A change or a deletion on one reaches the other.\n\nNew synced folders on this computer go under “{name}” in Proton Drive. Folders already synced here stay where they are. Next you choose which of its folders to sync here.",
|
||||||
|
&[("name", name)],
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
|
dialog.add_response("join", &gettext("Sync Here"));
|
||||||
|
dialog.set_response_appearance("join", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let ui = ui.clone();
|
||||||
|
let uid = uid.to_string();
|
||||||
|
// Translators: {name} is the name of the other computer this one now syncs with.
|
||||||
|
let done = gettext_f("Now syncing with “{name}”", &[("name", name)]);
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
if resp != "join" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::AdoptDevice {
|
||||||
|
uid: Some(uid.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (ui, done) = (ui.clone(), done.clone());
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
let failed = gettext("Couldn't sync with that computer");
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
reload_sync_pages(&ui);
|
||||||
|
toast(&ui, &done);
|
||||||
|
// The device is this computer's now, so its own restore
|
||||||
|
// picker offers the folders.
|
||||||
|
prompt_restore_folders(&ui, None);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, &failed, &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&failed,
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
dialog.present(win.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask before moving another computer's folders into this computer's backup,
|
/// Ask before moving another computer's folders into this computer's backup,
|
||||||
/// then offer them for syncing here.
|
/// then offer them for syncing here.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -209,7 +209,8 @@ fn ask(view: &Rc<FinderView>, finder: Finder, generation: u32) {
|
|||||||
Ok(Ok(Response::Error { message, .. })) => {
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
view.status.set_icon_name(Some("dialog-warning-symbolic"));
|
view.status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||||
view.status.set_title(&failed);
|
view.status.set_title(&failed);
|
||||||
view.status.set_description(Some(&message));
|
view.status
|
||||||
|
.set_description(Some(>k4::glib::markup_escape_text(&message)));
|
||||||
view.progress.set_visible(false);
|
view.progress.set_visible(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1145,7 +1145,9 @@ pub(crate) fn locations_status(
|
|||||||
) {
|
) {
|
||||||
ui.locations.status.set_icon_name(Some(icon));
|
ui.locations.status.set_icon_name(Some(icon));
|
||||||
ui.locations.status.set_title(title);
|
ui.locations.status.set_title(title);
|
||||||
ui.locations.status.set_description(Some(description));
|
ui.locations
|
||||||
|
.status
|
||||||
|
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||||
ui.locations.retry.set_visible(retry);
|
ui.locations.retry.set_visible(retry);
|
||||||
ui.locations.content.set_visible_child_name("status");
|
ui.locations.content.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ pub(crate) struct LoginState {
|
|||||||
/// Spins inside the Sign in button while a sign-in is running.
|
/// Spins inside the Sign in button while a sign-in is running.
|
||||||
pub(crate) login_spinner: Spinner,
|
pub(crate) login_spinner: Spinner,
|
||||||
pub(crate) login_status: gtk4::Label,
|
pub(crate) login_status: gtk4::Label,
|
||||||
|
/// Shown over the form while a saved sign-in exists but the keyring would
|
||||||
|
/// not hand it over; see [`keyring_unreadable`].
|
||||||
|
pub(crate) keyring_banner: adw::Banner,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the login page's account links go.
|
/// Where the login page's account links go.
|
||||||
@@ -87,6 +90,12 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
|||||||
.child(&inner)
|
.child(&inner)
|
||||||
.build();
|
.build();
|
||||||
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
|
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
|
||||||
|
let keyring_banner = adw::Banner::builder()
|
||||||
|
.title(gettext(
|
||||||
|
"Couldn't read your saved sign-in. Unlock the system keyring, then try again.",
|
||||||
|
))
|
||||||
|
.button_label(gettext("Retry"))
|
||||||
|
.build();
|
||||||
|
|
||||||
(
|
(
|
||||||
scroll.upcast(),
|
scroll.upcast(),
|
||||||
@@ -96,10 +105,80 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
|||||||
login_button,
|
login_button,
|
||||||
login_spinner,
|
login_spinner,
|
||||||
login_status,
|
login_status,
|
||||||
|
keyring_banner,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The stored session, `None` when there is none, or the keyring's error when
|
||||||
|
/// it could not be read. A locked or absent keyring is not a sign-out: the
|
||||||
|
/// session is still saved, and signing in again would only replace it.
|
||||||
|
pub(crate) fn read_session() -> pdfs_core::Result<Option<auth::StoredSession>> {
|
||||||
|
match auth::load() {
|
||||||
|
Ok(session) => Ok(Some(session)),
|
||||||
|
Err(pdfs_core::Error::NotLoggedIn) => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long to wait before each automatic retry of an unreadable keyring. A
|
||||||
|
/// keyring daemon started with the session is usually up within seconds.
|
||||||
|
const KEYRING_RETRIES: [u32; 4] = [2, 5, 10, 30];
|
||||||
|
|
||||||
|
/// The keyring refused to hand over the saved session. Keep the login page up,
|
||||||
|
/// but say why, and pick the session up as soon as the keyring answers: on its
|
||||||
|
/// own a few times, and whenever the user presses Retry.
|
||||||
|
pub(crate) fn keyring_unreadable(ui: &Rc<Ui>, error: &pdfs_core::Error) {
|
||||||
|
tracing::warn!("cannot read the saved session: {error}");
|
||||||
|
let banner = &ui.login.keyring_banner;
|
||||||
|
if banner.is_revealed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
banner.set_revealed(true);
|
||||||
|
let ui_retry = ui.clone();
|
||||||
|
let handler = banner.connect_button_clicked(move |_| retry_session(&ui_retry));
|
||||||
|
// Disconnected once the banner goes, so a later failure wires it afresh.
|
||||||
|
let handler = RefCell::new(Some(handler));
|
||||||
|
banner.connect_revealed_notify(move |banner| {
|
||||||
|
if !banner.is_revealed()
|
||||||
|
&& let Some(handler) = handler.borrow_mut().take()
|
||||||
|
{
|
||||||
|
banner.disconnect(handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
for seconds in KEYRING_RETRIES {
|
||||||
|
glib::timeout_future_seconds(seconds).await;
|
||||||
|
if !ui.login.keyring_banner.is_revealed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
retry_session(&ui);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the saved session again after [`keyring_unreadable`]. Found: show the
|
||||||
|
/// signed-in window. Gone: the plain login page. Still unreadable: keep waiting.
|
||||||
|
fn retry_session(ui: &Rc<Ui>) {
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let Ok(result) = gio::spawn_blocking(read_session).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(session) => {
|
||||||
|
ui.login.keyring_banner.set_revealed(false);
|
||||||
|
if session.is_some() && ui.session.borrow().is_none() {
|
||||||
|
*ui.session.borrow_mut() = session;
|
||||||
|
refresh(&ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::debug!("the keyring still cannot be read: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker
|
/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker
|
||||||
/// thread, and report the outcome back on the main loop.
|
/// thread, and report the outcome back on the main loop.
|
||||||
pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||||
@@ -199,7 +278,14 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
ui.login.login_status.set_text("");
|
ui.login.login_status.set_text("");
|
||||||
ui.login.password.set_text("");
|
ui.login.password.set_text("");
|
||||||
// Cache the new identity so `refresh` never hits the keyring.
|
// Cache the new identity so `refresh` never hits the keyring.
|
||||||
*ui.session.borrow_mut() = auth::load().ok();
|
// Read on a worker: a keyring that has to be unlocked first
|
||||||
|
// answers only once the user has, and the window must not
|
||||||
|
// freeze until then.
|
||||||
|
match gio::spawn_blocking(read_session).await {
|
||||||
|
Ok(Ok(session)) => *ui.session.borrow_mut() = session,
|
||||||
|
Ok(Err(e)) => keyring_unreadable(&ui, &e),
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
// Enable+start the mount service now that we have a session.
|
// Enable+start the mount service now that we have a session.
|
||||||
let _ = gio::spawn_blocking(service::enable_start).await;
|
let _ = gio::spawn_blocking(service::enable_start).await;
|
||||||
refresh(&ui);
|
refresh(&ui);
|
||||||
@@ -602,7 +688,7 @@ pub(crate) fn sign_out(ui: &Rc<Ui>) {
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = gio::spawn_blocking(|| {
|
let result = gio::spawn_blocking(|| {
|
||||||
service::disable_stop();
|
service::disable_stop();
|
||||||
auth::logout().map_err(|e| e.to_string())
|
auth::logout().map(|_| ()).map_err(|e| e.to_string())
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
if let Ok(Err(e)) = result {
|
if let Ok(Err(e)) = result {
|
||||||
|
|||||||
@@ -275,14 +275,42 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
viewer.status.set_label(message);
|
viewer.status.set_label(message);
|
||||||
viewer.status.set_visible(true);
|
viewer.status.set_visible(true);
|
||||||
};
|
};
|
||||||
match result {
|
let path = match result {
|
||||||
Ok(Ok(Response::FilePath { path })) if is_video => {
|
Ok(Ok(Response::FilePath { path })) => path,
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => return fail(&message),
|
||||||
|
Ok(Ok(_)) => return fail(&gettext("Unexpected reply from the Proton Drive service.")),
|
||||||
|
Ok(Err(_)) | Err(_) => return fail(&gettext("Couldn't reach Proton Drive.")),
|
||||||
|
};
|
||||||
|
// Decoding a full-size photo and reading its EXIF take a good part of
|
||||||
|
// a second for a large one, which froze the whole app on the main
|
||||||
|
// thread. Done on a worker; a reply for a photo the user has since
|
||||||
|
// left is dropped like the request's own.
|
||||||
|
let loaded = {
|
||||||
|
let path = path.clone();
|
||||||
|
gio::spawn_blocking(move || {
|
||||||
|
let size = std::fs::metadata(&path).ok().map(|meta| meta.len());
|
||||||
|
if is_video {
|
||||||
|
return (size, None, ExifInfo::default());
|
||||||
|
}
|
||||||
|
let texture = gtk4::gdk::Texture::from_filename(&path);
|
||||||
|
(size, Some(texture), read_exif(&path))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
if *viewer.uid.borrow() != uid {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok((size, texture, info)) = loaded else {
|
||||||
|
return fail(&gettext("Couldn't render this photo."));
|
||||||
|
};
|
||||||
|
match texture {
|
||||||
|
None => {
|
||||||
viewer.video.set_filename(Some(&path));
|
viewer.video.set_filename(Some(&path));
|
||||||
viewer.media.set_visible_child_name("video");
|
viewer.media.set_visible_child_name("video");
|
||||||
*viewer.path.borrow_mut() = Some(path.clone());
|
*viewer.path.borrow_mut() = Some(path.clone());
|
||||||
show_info(&viewer, &path, ExifInfo::default());
|
show_info(&viewer, size, ExifInfo::default());
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) {
|
Some(texture) => match texture {
|
||||||
Ok(texture) => {
|
Ok(texture) => {
|
||||||
// Zoomed in on the thumbnail: keep the photo the size it is
|
// Zoomed in on the thumbnail: keep the photo the size it is
|
||||||
// on screen, now drawn from the full-size pixels.
|
// on screen, now drawn from the full-size pixels.
|
||||||
@@ -297,16 +325,13 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
zoom_to(&viewer, Some(zoom), None);
|
zoom_to(&viewer, Some(zoom), None);
|
||||||
}
|
}
|
||||||
*viewer.path.borrow_mut() = Some(path.clone());
|
*viewer.path.borrow_mut() = Some(path.clone());
|
||||||
show_info(&viewer, &path, read_exif(&path));
|
show_info(&viewer, size, info);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to load texture for {path}: {e}");
|
tracing::error!("Failed to load texture for {path}: {e}");
|
||||||
fail(&gettext("Couldn't render this photo."));
|
fail(&gettext("Couldn't render this photo."));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Ok(Ok(Response::Error { message, .. })) => fail(&message),
|
|
||||||
Ok(Ok(_)) => fail(&gettext("Unexpected reply from the Proton Drive service.")),
|
|
||||||
Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")),
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -452,16 +477,16 @@ pub(crate) fn clear_info(viewer: &Rc<Viewer>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fill the details drawer for the photo now on screen: its own file facts
|
/// Fill the details drawer for the photo now on screen: its own file facts
|
||||||
/// (size, dimensions) plus whatever EXIF it carries.
|
/// (`size`, dimensions) plus whatever EXIF it carries.
|
||||||
pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
|
pub(crate) fn show_info(viewer: &Rc<Viewer>, size: Option<u64>, info: ExifInfo) {
|
||||||
while let Some(row) = viewer.info_rows.first_child() {
|
while let Some(row) = viewer.info_rows.first_child() {
|
||||||
viewer.info_rows.remove(&row);
|
viewer.info_rows.remove(&row);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut fields: Vec<(String, String)> = Vec::new();
|
let mut fields: Vec<(String, String)> = Vec::new();
|
||||||
if let Ok(meta) = std::fs::metadata(path) {
|
if let Some(size) = size {
|
||||||
// Translators: label of a photo's file size in the details panel.
|
// Translators: label of a photo's file size in the details panel.
|
||||||
fields.push((pgettext("property", "Size"), human_bytes(meta.len())));
|
fields.push((pgettext("property", "Size"), human_bytes(size)));
|
||||||
}
|
}
|
||||||
fields.extend(info.fields);
|
fields.extend(info.fields);
|
||||||
|
|
||||||
@@ -481,6 +506,7 @@ pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(label.as_str())
|
.title(label.as_str())
|
||||||
.subtitle(value)
|
.subtitle(value)
|
||||||
|
.use_markup(false)
|
||||||
.subtitle_selectable(true)
|
.subtitle_selectable(true)
|
||||||
.build();
|
.build();
|
||||||
row.add_css_class("property");
|
row.add_css_class("property");
|
||||||
@@ -1259,8 +1285,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
// Any real pointer movement brings the controls back; resting over one of
|
// Any real pointer movement brings the controls back; resting over one of
|
||||||
// them keeps them up.
|
// them keeps them up.
|
||||||
let motion = gtk4::EventControllerMotion::new();
|
let motion = gtk4::EventControllerMotion::new();
|
||||||
let viewer_motion = viewer.clone();
|
let viewer_motion = Rc::downgrade(&viewer);
|
||||||
motion.connect_motion(move |_, x, y| {
|
motion.connect_motion(move |_, x, y| {
|
||||||
|
let Some(viewer_motion) = viewer_motion.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let (last_x, last_y) = viewer_motion.pointer.get();
|
let (last_x, last_y) = viewer_motion.pointer.get();
|
||||||
viewer_motion.pointer.set((x, y));
|
viewer_motion.pointer.set((x, y));
|
||||||
if (x - last_x).abs() > 1.0 || (y - last_y).abs() > 1.0 {
|
if (x - last_x).abs() > 1.0 || (y - last_y).abs() > 1.0 {
|
||||||
@@ -1270,10 +1299,17 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
body.add_controller(motion);
|
body.add_controller(motion);
|
||||||
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
|
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
|
||||||
let hover = gtk4::EventControllerMotion::new();
|
let hover = gtk4::EventControllerMotion::new();
|
||||||
let viewer_enter = viewer.clone();
|
let viewer_enter = Rc::downgrade(&viewer);
|
||||||
hover.connect_enter(move |_, _, _| viewer_enter.chrome_hover.set(true));
|
hover.connect_enter(move |_, _, _| {
|
||||||
let viewer_leave = viewer.clone();
|
if let Some(viewer) = viewer_enter.upgrade() {
|
||||||
|
viewer.chrome_hover.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let viewer_leave = Rc::downgrade(&viewer);
|
||||||
hover.connect_leave(move |_| {
|
hover.connect_leave(move |_| {
|
||||||
|
let Some(viewer_leave) = viewer_leave.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
viewer_leave.chrome_hover.set(false);
|
viewer_leave.chrome_hover.set(false);
|
||||||
show_chrome(&viewer_leave);
|
show_chrome(&viewer_leave);
|
||||||
});
|
});
|
||||||
@@ -1283,8 +1319,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
// Ctrl+scroll zooms around the pointer; a plain scroll pans a zoomed photo.
|
// Ctrl+scroll zooms around the pointer; a plain scroll pans a zoomed photo.
|
||||||
let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
|
let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
|
||||||
wheel.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
wheel.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||||
let viewer_wheel = viewer.clone();
|
let viewer_wheel = Rc::downgrade(&viewer);
|
||||||
wheel.connect_scroll(move |controller, _, dy| {
|
wheel.connect_scroll(move |controller, _, dy| {
|
||||||
|
let Some(viewer_wheel) = viewer_wheel.upgrade() else {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
};
|
||||||
if !controller
|
if !controller
|
||||||
.current_event_state()
|
.current_event_state()
|
||||||
.contains(gtk4::gdk::ModifierType::CONTROL_MASK)
|
.contains(gtk4::gdk::ModifierType::CONTROL_MASK)
|
||||||
@@ -1304,14 +1343,20 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
scroller.add_controller(wheel);
|
scroller.add_controller(wheel);
|
||||||
|
|
||||||
let pinch = gtk4::GestureZoom::new();
|
let pinch = gtk4::GestureZoom::new();
|
||||||
let viewer_pinch = viewer.clone();
|
let viewer_pinch = Rc::downgrade(&viewer);
|
||||||
pinch.connect_begin(move |_, _| {
|
pinch.connect_begin(move |_, _| {
|
||||||
|
let Some(viewer_pinch) = viewer_pinch.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
viewer_pinch
|
viewer_pinch
|
||||||
.pinch_start
|
.pinch_start
|
||||||
.set(current_scale(&viewer_pinch).unwrap_or(1.0));
|
.set(current_scale(&viewer_pinch).unwrap_or(1.0));
|
||||||
});
|
});
|
||||||
let viewer_pinch = viewer.clone();
|
let viewer_pinch = Rc::downgrade(&viewer);
|
||||||
pinch.connect_scale_changed(move |gesture, delta| {
|
pinch.connect_scale_changed(move |gesture, delta| {
|
||||||
|
let Some(viewer_pinch) = viewer_pinch.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let start = viewer_pinch.pinch_start.get();
|
let start = viewer_pinch.pinch_start.get();
|
||||||
zoom_to(
|
zoom_to(
|
||||||
&viewer_pinch,
|
&viewer_pinch,
|
||||||
@@ -1323,8 +1368,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
|
|
||||||
// Dragging a zoomed photo pans it.
|
// Dragging a zoomed photo pans it.
|
||||||
let drag = gtk4::GestureDrag::new();
|
let drag = gtk4::GestureDrag::new();
|
||||||
let viewer_drag = viewer.clone();
|
let viewer_drag = Rc::downgrade(&viewer);
|
||||||
drag.connect_drag_begin(move |_, _, _| {
|
drag.connect_drag_begin(move |_, _, _| {
|
||||||
|
let Some(viewer_drag) = viewer_drag.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let scroller = &viewer_drag.scroller;
|
let scroller = &viewer_drag.scroller;
|
||||||
viewer_drag.drag_start.set((
|
viewer_drag.drag_start.set((
|
||||||
scroller.hadjustment().value(),
|
scroller.hadjustment().value(),
|
||||||
@@ -1334,8 +1382,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
viewer_drag.picture.set_cursor_from_name(Some("grabbing"));
|
viewer_drag.picture.set_cursor_from_name(Some("grabbing"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let viewer_drag = viewer.clone();
|
let viewer_drag = Rc::downgrade(&viewer);
|
||||||
drag.connect_drag_update(move |_, dx, dy| {
|
drag.connect_drag_update(move |_, dx, dy| {
|
||||||
|
let Some(viewer_drag) = viewer_drag.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if viewer_drag.zoom.get().is_none() {
|
if viewer_drag.zoom.get().is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1343,39 +1394,58 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
viewer_drag.scroller.hadjustment().set_value(x - dx);
|
viewer_drag.scroller.hadjustment().set_value(x - dx);
|
||||||
viewer_drag.scroller.vadjustment().set_value(y - dy);
|
viewer_drag.scroller.vadjustment().set_value(y - dy);
|
||||||
});
|
});
|
||||||
let viewer_drag = viewer.clone();
|
let viewer_drag = Rc::downgrade(&viewer);
|
||||||
drag.connect_drag_end(move |_, _, _| {
|
drag.connect_drag_end(move |_, _, _| {
|
||||||
|
let Some(viewer_drag) = viewer_drag.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if viewer_drag.zoom.get().is_some() {
|
if viewer_drag.zoom.get().is_some() {
|
||||||
viewer_drag.picture.set_cursor_from_name(Some("grab"));
|
viewer_drag.picture.set_cursor_from_name(Some("grab"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
scroller.add_controller(drag);
|
scroller.add_controller(drag);
|
||||||
|
|
||||||
let w_close = window.clone();
|
let w_close = window.downgrade();
|
||||||
close_btn.connect_clicked(move |_| {
|
close_btn.connect_clicked(move |_| {
|
||||||
|
let Some(w_close) = w_close.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
w_close.close();
|
w_close.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
let viewer_closing = viewer.clone();
|
// The one strong handle on the viewer. Every handler holds a weak one: a
|
||||||
|
// strong one in a handler of a widget the viewer holds is a cycle, which
|
||||||
|
// kept every closed lightbox, its window and its full-size photo in memory
|
||||||
|
// for the life of the app.
|
||||||
|
let keep = RefCell::new(Some(viewer.clone()));
|
||||||
window.connect_close_request(move |_| {
|
window.connect_close_request(move |_| {
|
||||||
stop_video(&viewer_closing);
|
if let Some(viewer) = keep.borrow_mut().take() {
|
||||||
disarm_slide(&viewer_closing);
|
stop_video(&viewer);
|
||||||
if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() {
|
disarm_slide(&viewer);
|
||||||
source.remove();
|
if let Some(source) = viewer.chrome_source.borrow_mut().take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
||||||
}
|
}
|
||||||
glib::Propagation::Proceed
|
glib::Propagation::Proceed
|
||||||
});
|
});
|
||||||
|
|
||||||
let viewer_info = viewer.clone();
|
let viewer_info = Rc::downgrade(&viewer);
|
||||||
info_toggle.connect_toggled(move |toggle| {
|
info_toggle.connect_toggled(move |toggle| {
|
||||||
|
let Some(viewer_info) = viewer_info.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
viewer_info
|
viewer_info
|
||||||
.info_revealer
|
.info_revealer
|
||||||
.set_reveal_child(toggle.is_active());
|
.set_reveal_child(toggle.is_active());
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_slides = ui.clone();
|
let ui_slides = ui.clone();
|
||||||
let viewer_slides = viewer.clone();
|
let viewer_slides = Rc::downgrade(&viewer);
|
||||||
slideshow_btn.connect_toggled(move |toggle| {
|
slideshow_btn.connect_toggled(move |toggle| {
|
||||||
|
let Some(viewer_slides) = viewer_slides.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if toggle.is_active() {
|
if toggle.is_active() {
|
||||||
start_slideshow(&ui_slides, &viewer_slides);
|
start_slideshow(&ui_slides, &viewer_slides);
|
||||||
} else {
|
} else {
|
||||||
@@ -1387,9 +1457,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||||
|
|
||||||
let ui_map = ui.clone();
|
let ui_map = ui.clone();
|
||||||
let viewer_map = viewer.clone();
|
let viewer_map = Rc::downgrade(&viewer);
|
||||||
let w_map = window.clone();
|
let w_map = window.downgrade();
|
||||||
info_map.connect_clicked(move |_| {
|
info_map.connect_clicked(move |_| {
|
||||||
|
let Some(w_map) = w_map.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(viewer_map) = viewer_map.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let coords = *viewer_map.coords.borrow();
|
let coords = *viewer_map.coords.borrow();
|
||||||
if let Some((lat, lon)) = coords {
|
if let Some((lat, lon)) = coords {
|
||||||
w_map.close();
|
w_map.close();
|
||||||
@@ -1398,8 +1474,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let ui_fav = ui.clone();
|
let ui_fav = ui.clone();
|
||||||
let viewer_fav = viewer.clone();
|
let viewer_fav = Rc::downgrade(&viewer);
|
||||||
favorite_btn.connect_toggled(move |btn| {
|
favorite_btn.connect_toggled(move |btn| {
|
||||||
|
let Some(viewer_fav) = viewer_fav.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if viewer_fav.favorite_suppress.get() {
|
if viewer_fav.favorite_suppress.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1443,17 +1522,26 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
// gone, and leaving it open on a trashed photo invites a second Delete on
|
// gone, and leaving it open on a trashed photo invites a second Delete on
|
||||||
// something the user cannot see.
|
// something the user cannot see.
|
||||||
let ui_delete = ui.clone();
|
let ui_delete = ui.clone();
|
||||||
let viewer_delete = viewer.clone();
|
let viewer_delete = Rc::downgrade(&viewer);
|
||||||
let w_delete = window.clone();
|
let w_delete = window.downgrade();
|
||||||
delete_btn.connect_clicked(move |_| {
|
delete_btn.connect_clicked(move |_| {
|
||||||
|
let Some(w_delete) = w_delete.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(viewer_delete) = viewer_delete.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let uid = viewer_delete.uid.borrow().clone();
|
let uid = viewer_delete.uid.borrow().clone();
|
||||||
w_delete.close();
|
w_delete.close();
|
||||||
trash_photos(&ui_delete, vec![uid], false);
|
trash_photos(&ui_delete, vec![uid], false);
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_group = ui.clone();
|
let ui_group = ui.clone();
|
||||||
let viewer_group = viewer.clone();
|
let viewer_group = Rc::downgrade(&viewer);
|
||||||
group_btn.connect_clicked(move |_| {
|
group_btn.connect_clicked(move |_| {
|
||||||
|
let Some(viewer_group) = viewer_group.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let uid = viewer_group.uid.borrow().clone();
|
let uid = viewer_group.uid.borrow().clone();
|
||||||
let next =
|
let next =
|
||||||
next_group_member(&viewer_group.group.borrow(), &uid).map(|item| item.uid.clone());
|
next_group_member(&viewer_group.group.borrow(), &uid).map(|item| item.uid.clone());
|
||||||
@@ -1464,9 +1552,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let ui_download = ui.clone();
|
let ui_download = ui.clone();
|
||||||
let w_download = window.clone();
|
let w_download = window.downgrade();
|
||||||
let viewer_download = viewer.clone();
|
let viewer_download = Rc::downgrade(&viewer);
|
||||||
download_btn.connect_clicked(move |_| {
|
download_btn.connect_clicked(move |_| {
|
||||||
|
let Some(w_download) = w_download.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(viewer_download) = viewer_download.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if let Some(path) = viewer_download.path.borrow().as_deref() {
|
if let Some(path) = viewer_download.path.borrow().as_deref() {
|
||||||
let name = viewer_download.name.borrow();
|
let name = viewer_download.name.borrow();
|
||||||
save_photo_to_disk(
|
save_photo_to_disk(
|
||||||
@@ -1478,23 +1572,35 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let w_ext = window.clone();
|
let w_ext = window.downgrade();
|
||||||
let viewer_ext = viewer.clone();
|
let viewer_ext = Rc::downgrade(&viewer);
|
||||||
open_ext_btn.connect_clicked(move |_| {
|
open_ext_btn.connect_clicked(move |_| {
|
||||||
|
let Some(w_ext) = w_ext.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(viewer_ext) = viewer_ext.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if let Some(path) = viewer_ext.path.borrow().as_deref() {
|
if let Some(path) = viewer_ext.path.borrow().as_deref() {
|
||||||
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
|
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_prev = ui.clone();
|
let ui_prev = ui.clone();
|
||||||
let viewer_prev = viewer.clone();
|
let viewer_prev = Rc::downgrade(&viewer);
|
||||||
prev_btn.connect_clicked(move |_| {
|
prev_btn.connect_clicked(move |_| {
|
||||||
|
let Some(viewer_prev) = viewer_prev.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
navigate_photo(&ui_prev, &viewer_prev, -1);
|
navigate_photo(&ui_prev, &viewer_prev, -1);
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_next = ui.clone();
|
let ui_next = ui.clone();
|
||||||
let viewer_next = viewer.clone();
|
let viewer_next = Rc::downgrade(&viewer);
|
||||||
next_btn.connect_clicked(move |_| {
|
next_btn.connect_clicked(move |_| {
|
||||||
|
let Some(viewer_next) = viewer_next.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
navigate_photo(&ui_next, &viewer_next, 1);
|
navigate_photo(&ui_next, &viewer_next, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1503,8 +1609,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
// reaching for the image doesn't fling the window shut; a zoomed photo has
|
// reaching for the image doesn't fling the window shut; a zoomed photo has
|
||||||
// no backdrop to speak of. Double-click toggles between fitted and 100 %.
|
// no backdrop to speak of. Double-click toggles between fitted and 100 %.
|
||||||
let backdrop = gtk4::GestureClick::new();
|
let backdrop = gtk4::GestureClick::new();
|
||||||
let viewer_click = viewer.clone();
|
let viewer_click = Rc::downgrade(&viewer);
|
||||||
backdrop.connect_pressed(move |_, n_press, x, y| {
|
backdrop.connect_pressed(move |_, n_press, x, y| {
|
||||||
|
let Some(viewer_click) = viewer_click.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if n_press != 2 {
|
if n_press != 2 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1521,9 +1630,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
zoom_to(&viewer_click, Some((fit * 2.0).max(1.0)), Some(anchor));
|
zoom_to(&viewer_click, Some((fit * 2.0).max(1.0)), Some(anchor));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let viewer_click = viewer.clone();
|
let viewer_click = Rc::downgrade(&viewer);
|
||||||
let w_click = window.clone();
|
let w_click = window.downgrade();
|
||||||
backdrop.connect_released(move |_, n_press, x, y| {
|
backdrop.connect_released(move |_, n_press, x, y| {
|
||||||
|
let Some(w_click) = w_click.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(viewer_click) = viewer_click.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if n_press == 1
|
if n_press == 1
|
||||||
&& viewer_click.zoom.get().is_none()
|
&& viewer_click.zoom.get().is_none()
|
||||||
&& !over_photo(&viewer_click.picture, x, y)
|
&& !over_photo(&viewer_click.picture, x, y)
|
||||||
@@ -1535,9 +1650,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
|
|
||||||
let key_controller = gtk4::EventControllerKey::new();
|
let key_controller = gtk4::EventControllerKey::new();
|
||||||
let ui_key = ui.clone();
|
let ui_key = ui.clone();
|
||||||
let viewer_key = viewer.clone();
|
let viewer_key = Rc::downgrade(&viewer);
|
||||||
let w_key = window.clone();
|
let w_key = window.downgrade();
|
||||||
key_controller.connect_key_pressed(move |_, key, _keycode, state| {
|
key_controller.connect_key_pressed(move |_, key, _keycode, state| {
|
||||||
|
let Some(w_key) = w_key.upgrade() else {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
};
|
||||||
|
let Some(viewer_key) = viewer_key.upgrade() else {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
};
|
||||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||||
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
||||||
match key.name().as_deref() {
|
match key.name().as_deref() {
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
select_box.append(&select_done);
|
select_box.append(&select_done);
|
||||||
let select_bar = gtk4::Revealer::builder()
|
let select_bar = gtk4::Revealer::builder()
|
||||||
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
||||||
.child(&select_box)
|
.child(&scroll_sideways(&select_box))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// All / Photos / Videos / Raw filter. Linked toggles acting as one segmented
|
// All / Photos / Videos / Raw filter. Linked toggles acting as one segmented
|
||||||
@@ -1379,11 +1379,9 @@ pub(crate) fn wire_gallery(
|
|||||||
"tiff" | "tif" => "image/tiff",
|
"tiff" | "tif" => "image/tiff",
|
||||||
_ => "image/jpeg",
|
_ => "image/jpeg",
|
||||||
};
|
};
|
||||||
let capture_time = std::fs::metadata(&path)
|
// The daemon dates the photo: by its EXIF, or by the file's
|
||||||
.and_then(|m| m.modified())
|
// modification time when it has none.
|
||||||
.ok()
|
let capture_time = None;
|
||||||
.and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
|
|
||||||
.map(|d| d.as_secs() as i64);
|
|
||||||
|
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
@@ -3459,7 +3457,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
pub(crate) fn gallery_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
pub(crate) fn gallery_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||||
ui.gallery.status.set_icon_name(Some(icon));
|
ui.gallery.status.set_icon_name(Some(icon));
|
||||||
ui.gallery.status.set_title(title);
|
ui.gallery.status.set_title(title);
|
||||||
ui.gallery.status.set_description(Some(description));
|
ui.gallery
|
||||||
|
.status
|
||||||
|
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||||
ui.gallery.retry.set_visible(retry);
|
ui.gallery.retry.set_visible(retry);
|
||||||
// Only the empty timeline offers a way to fill it; every other status turns
|
// Only the empty timeline offers a way to fill it; every other status turns
|
||||||
// those buttons back off.
|
// those buttons back off.
|
||||||
@@ -3479,10 +3479,10 @@ pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
|
|||||||
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
|
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
|
||||||
/// user's default handler when mpv isn't installed.
|
/// user's default handler when mpv isn't installed.
|
||||||
pub(crate) fn play_external(path: &str) {
|
pub(crate) fn play_external(path: &str) {
|
||||||
if Command::new("mpv").arg(path).spawn().is_ok() {
|
match Command::new("mpv").arg(path).spawn() {
|
||||||
return;
|
Ok(child) => pdfs_core::opener::reap(child),
|
||||||
|
Err(_) => open_path(path),
|
||||||
}
|
}
|
||||||
open_path(path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -448,7 +448,9 @@ pub(crate) fn close_place(ui: &Rc<Ui>) {
|
|||||||
fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
||||||
ui.gallery.places_status.set_icon_name(Some(icon));
|
ui.gallery.places_status.set_icon_name(Some(icon));
|
||||||
ui.gallery.places_status.set_title(title);
|
ui.gallery.places_status.set_title(title);
|
||||||
ui.gallery.places_status.set_description(Some(description));
|
ui.gallery
|
||||||
|
.places_status
|
||||||
|
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||||
ui.gallery.places_stack.set_visible_child_name("status");
|
ui.gallery.places_stack.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -311,7 +311,9 @@ pub(crate) fn wire_shared(ui: &Rc<Ui>, widgets: &SharedWidgets) {
|
|||||||
pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||||
ui.shared.status.set_icon_name(Some(icon));
|
ui.shared.status.set_icon_name(Some(icon));
|
||||||
ui.shared.status.set_title(title);
|
ui.shared.status.set_title(title);
|
||||||
ui.shared.status.set_description(Some(description));
|
ui.shared
|
||||||
|
.status
|
||||||
|
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||||
ui.shared.retry.set_visible(retry);
|
ui.shared.retry.set_visible(retry);
|
||||||
ui.shared.content.set_visible_child_name("status");
|
ui.shared.content.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
@@ -818,6 +820,7 @@ fn invitation_row(inv: &InvitationInfo) -> adw::ActionRow {
|
|||||||
.title(&item)
|
.title(&item)
|
||||||
// Translators: {email} is the address of the person who sent the invitation.
|
// Translators: {email} is the address of the person who sent the invitation.
|
||||||
.subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)]))
|
.subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)]))
|
||||||
|
.use_markup(false)
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir {
|
row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir {
|
||||||
"folder-symbolic"
|
"folder-symbolic"
|
||||||
@@ -862,6 +865,7 @@ fn repaint_links(ui: &Rc<Ui>, bookmarks: &[BookmarkInfo]) {
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&title)
|
.title(&title)
|
||||||
.subtitle(&bm.url)
|
.subtitle(&bm.url)
|
||||||
|
.use_markup(false)
|
||||||
.subtitle_lines(1)
|
.subtitle_lines(1)
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
.tooltip_text(gettext("Open in browser"))
|
.tooltip_text(gettext("Open in browser"))
|
||||||
|
|||||||
@@ -329,7 +329,9 @@ pub(crate) fn shared_by_me_status(
|
|||||||
) {
|
) {
|
||||||
ui.shared_by_me.status.set_icon_name(Some(icon));
|
ui.shared_by_me.status.set_icon_name(Some(icon));
|
||||||
ui.shared_by_me.status.set_title(title);
|
ui.shared_by_me.status.set_title(title);
|
||||||
ui.shared_by_me.status.set_description(Some(description));
|
ui.shared_by_me
|
||||||
|
.status
|
||||||
|
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||||
ui.shared_by_me.retry.set_visible(retry);
|
ui.shared_by_me.retry.set_visible(retry);
|
||||||
ui.shared_by_me.content.set_visible_child_name("status");
|
ui.shared_by_me.content.set_visible_child_name("status");
|
||||||
ui.shared_by_me.subtitle.set_subtitle("");
|
ui.shared_by_me.subtitle.set_subtitle("");
|
||||||
|
|||||||
@@ -860,7 +860,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
|||||||
if ui.stack.visible_child_name().as_deref() == Some("login") {
|
if ui.stack.visible_child_name().as_deref() == Some("login") {
|
||||||
ui.stack.set_visible_child_name("browser");
|
ui.stack.set_visible_child_name("browser");
|
||||||
}
|
}
|
||||||
ui.nav.set_collapsed(false);
|
ui.nav.set_collapsed(ui.narrow.get());
|
||||||
if ui.status.account_name.label() != s.username {
|
if ui.status.account_name.label() != s.username {
|
||||||
ui.status.account_name.set_label(&s.username);
|
ui.status.account_name.set_label(&s.username);
|
||||||
ui.status.account_name.set_tooltip_text(Some(&s.username));
|
ui.status.account_name.set_tooltip_text(Some(&s.username));
|
||||||
@@ -1566,6 +1566,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&name)
|
.title(&name)
|
||||||
.subtitle(&pin.path)
|
.subtitle(&pin.path)
|
||||||
|
.use_markup(false)
|
||||||
.build();
|
.build();
|
||||||
let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic");
|
let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic");
|
||||||
row.add_prefix(&icon);
|
row.add_prefix(&icon);
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
|||||||
.unwrap_or_else(|| path.display().to_string()),
|
.unwrap_or_else(|| path.display().to_string()),
|
||||||
)
|
)
|
||||||
.subtitle(human_bytes(size))
|
.subtitle(human_bytes(size))
|
||||||
|
.use_markup(false)
|
||||||
.build();
|
.build();
|
||||||
let remove = gtk4::Button::builder()
|
let remove = gtk4::Button::builder()
|
||||||
.icon_name("edit-delete-symbolic")
|
.icon_name("edit-delete-symbolic")
|
||||||
@@ -645,7 +646,12 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
|||||||
// An import runs for hours, so its ending is announced rather than
|
// An import runs for hours, so its ending is announced rather than
|
||||||
// left to be discovered: a toast for whoever is looking at the
|
// left to be discovered: a toast for whoever is looking at the
|
||||||
// window, and a desktop notification for whoever is not.
|
// window, and a desktop notification for whoever is not.
|
||||||
if was_running {
|
let failed = summary.as_ref().and_then(|s| s.error.clone());
|
||||||
|
if was_running && let Some(error) = &failed {
|
||||||
|
let headline = gettext("Couldn't import from Google Photos");
|
||||||
|
toast_error(&ui, &headline, error);
|
||||||
|
notify("takeout-import", &headline, error);
|
||||||
|
} else if was_running {
|
||||||
let dry = ui.takeout.dry_run.get();
|
let dry = ui.takeout.dry_run.get();
|
||||||
let headline = if dry {
|
let headline = if dry {
|
||||||
gettext("Scan finished")
|
gettext("Scan finished")
|
||||||
@@ -701,7 +707,18 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
|||||||
} else {
|
} else {
|
||||||
gettext("Last import")
|
gettext("Last import")
|
||||||
});
|
});
|
||||||
ui.takeout.summary_group.set_description(Some(&if dry {
|
ui.takeout.summary_group.set_description(Some(&if let Some(error) = &summary.error {
|
||||||
|
// The reason is the service's own text, which is not translated. A
|
||||||
|
// group's description is markup, and the reason may name a path.
|
||||||
|
glib::markup_escape_text(&gettext_f(
|
||||||
|
"{what}: {detail}",
|
||||||
|
&[
|
||||||
|
("what", &gettext("Couldn't import from Google Photos")),
|
||||||
|
("detail", error),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.to_string()
|
||||||
|
} else if dry {
|
||||||
gettext("Nothing was uploaded — this is what an import would do.")
|
gettext("Nothing was uploaded — this is what an import would do.")
|
||||||
} else if summary.cancelled {
|
} else if summary.cancelled {
|
||||||
gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
|
gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
|
||||||
|
|||||||
@@ -330,7 +330,9 @@ pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
|
|||||||
pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||||
ui.trash.status.set_icon_name(Some(icon));
|
ui.trash.status.set_icon_name(Some(icon));
|
||||||
ui.trash.status.set_title(title);
|
ui.trash.status.set_title(title);
|
||||||
ui.trash.status.set_description(Some(description));
|
ui.trash
|
||||||
|
.status
|
||||||
|
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||||
ui.trash.retry.set_visible(retry);
|
ui.trash.retry.set_visible(retry);
|
||||||
ui.trash.content.set_visible_child_name("status");
|
ui.trash.content.set_visible_child_name("status");
|
||||||
ui.trash.subtitle.set_subtitle("");
|
ui.trash.subtitle.set_subtitle("");
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
|||||||
.title(gettext("Location"))
|
.title(gettext("Location"))
|
||||||
.subtitle("—")
|
.subtitle("—")
|
||||||
.subtitle_lines(3)
|
.subtitle_lines(3)
|
||||||
|
// A folder path, which may hold `&` or `<`.
|
||||||
|
.use_markup(false)
|
||||||
.build();
|
.build();
|
||||||
path_row.add_css_class("property");
|
path_row.add_css_class("property");
|
||||||
props.add(&size_row);
|
props.add(&size_row);
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&entry.email)
|
.title(&entry.email)
|
||||||
.subtitle(&subtitle)
|
.subtitle(&subtitle)
|
||||||
|
.use_markup(false)
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
|
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
|
||||||
|
|
||||||
@@ -506,10 +507,16 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
.selected(role_wire_to_index(&entry.role))
|
.selected(role_wire_to_index(&entry.role))
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
let state_role = state.clone();
|
// Weak, here and in every row below: the rows belong to groups the
|
||||||
|
// dialog state holds, so a strong handle in a row's handler is a
|
||||||
|
// cycle that kept each closed dialog in memory.
|
||||||
|
let state_role = Rc::downgrade(state);
|
||||||
let id = entry.id.clone();
|
let id = entry.id.clone();
|
||||||
let kind = entry.kind;
|
let kind = entry.kind;
|
||||||
drop.connect_selected_notify(move |d| {
|
drop.connect_selected_notify(move |d| {
|
||||||
|
let Some(state_role) = state_role.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_role,
|
&state_role,
|
||||||
state_role.target.update_role(
|
state_role.target.update_role(
|
||||||
@@ -539,12 +546,14 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
remove.add_css_class("flat");
|
remove.add_css_class("flat");
|
||||||
let state_rm = state.clone();
|
let state_rm = Rc::downgrade(state);
|
||||||
let id = entry.id.clone();
|
let id = entry.id.clone();
|
||||||
let kind = entry.kind;
|
let kind = entry.kind;
|
||||||
let who = entry.email.clone();
|
let who = entry.email.clone();
|
||||||
remove.connect_clicked(move |btn| {
|
remove.connect_clicked(move |btn| {
|
||||||
let state = state_rm.clone();
|
let Some(state) = state_rm.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let id = id.clone();
|
let id = id.clone();
|
||||||
let button = btn.clone();
|
let button = btn.clone();
|
||||||
confirm_destructive(
|
confirm_destructive(
|
||||||
@@ -605,6 +614,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
url.clone()
|
url.clone()
|
||||||
})
|
})
|
||||||
.subtitle(&subtitle)
|
.subtitle(&subtitle)
|
||||||
|
.use_markup(false)
|
||||||
.build();
|
.build();
|
||||||
row.add_css_class("property");
|
row.add_css_class("property");
|
||||||
|
|
||||||
@@ -615,12 +625,12 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
copy.add_css_class("flat");
|
copy.add_css_class("flat");
|
||||||
let state_copy = state.clone();
|
let ui_copy = state.ui.clone();
|
||||||
let url_copy = url.clone();
|
let url_copy = url.clone();
|
||||||
copy.connect_clicked(move |btn| {
|
copy.connect_clicked(move |btn| {
|
||||||
btn.clipboard().set_text(&url_copy);
|
btn.clipboard().set_text(&url_copy);
|
||||||
flash_copied(btn, "edit-copy-symbolic");
|
flash_copied(btn, "edit-copy-symbolic");
|
||||||
toast(&state_copy.ui, &gettext("Link copied"));
|
toast(&ui_copy, &gettext("Link copied"));
|
||||||
});
|
});
|
||||||
row.add_suffix(©);
|
row.add_suffix(©);
|
||||||
}
|
}
|
||||||
@@ -631,10 +641,12 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
remove.add_css_class("flat");
|
remove.add_css_class("flat");
|
||||||
let state_rm = state.clone();
|
let state_rm = Rc::downgrade(state);
|
||||||
let id = link.id.clone();
|
let id = link.id.clone();
|
||||||
remove.connect_clicked(move |btn| {
|
remove.connect_clicked(move |btn| {
|
||||||
let state = state_rm.clone();
|
let Some(state) = state_rm.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let id = id.clone();
|
let id = id.clone();
|
||||||
let button = btn.clone();
|
let button = btn.clone();
|
||||||
confirm_destructive(
|
confirm_destructive(
|
||||||
@@ -688,9 +700,12 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
let btn = create.clone();
|
let btn = create.clone();
|
||||||
pw_row.connect_entry_activated(move |_| btn.emit_clicked());
|
pw_row.connect_entry_activated(move |_| btn.emit_clicked());
|
||||||
|
|
||||||
let state_c = state.clone();
|
let state_c = Rc::downgrade(state);
|
||||||
let pw_for = pw_row.clone();
|
let pw_for = pw_row.clone();
|
||||||
create.connect_clicked(move |btn| {
|
create.connect_clicked(move |btn| {
|
||||||
|
let Some(state_c) = state_c.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if !btn.is_sensitive() {
|
if !btn.is_sensitive() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(title)
|
.title(title)
|
||||||
.subtitle(subtitle)
|
.subtitle(subtitle)
|
||||||
|
.use_markup(false)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let save = gtk4::Button::builder()
|
let save = gtk4::Button::builder()
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ mod thumbs;
|
|||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk4::{gdk, gio, glib};
|
use gtk4::{gdk, gio, glib};
|
||||||
@@ -24,7 +23,7 @@ use pdfs_core::config::AppDirs;
|
|||||||
use pdfs_core::control::{Request, Response, SearchFilters, SearchHit, SearchSource};
|
use pdfs_core::control::{Request, Response, SearchFilters, SearchHit, SearchSource};
|
||||||
use pdfs_core::opener::{self, OpenWith};
|
use pdfs_core::opener::{self, OpenWith};
|
||||||
|
|
||||||
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, present_within};
|
||||||
use crate::compat::{Spinner, spinner};
|
use crate::compat::{Spinner, spinner};
|
||||||
use crate::theme::set_proton_theme;
|
use crate::theme::set_proton_theme;
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -38,10 +37,6 @@ use thumbs::Thumbs;
|
|||||||
/// Recent files shown above the pinned ones while the query is empty.
|
/// Recent files shown above the pinned ones while the query is empty.
|
||||||
const RECENT_LIMIT: usize = 8;
|
const RECENT_LIMIT: usize = 8;
|
||||||
|
|
||||||
/// How long opening waits for the mount to answer whether it has a file. A
|
|
||||||
/// frozen mount must not freeze the launcher; the daemon is asked instead.
|
|
||||||
const STAT_TIMEOUT: Duration = Duration::from_secs(2);
|
|
||||||
|
|
||||||
/// The titled groups of the result list.
|
/// The titled groups of the result list.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum Section {
|
enum Section {
|
||||||
@@ -516,22 +511,11 @@ pub(crate) fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
|||||||
Some(ui)
|
Some(ui)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the mount has `hit`, asked on a worker thread and given up on
|
/// Whether the mount has `hit`, without blocking the window
|
||||||
/// after [`STAT_TIMEOUT`]. `None` means "not there" or "no answer"; either
|
/// ([`present_within`]). `None` means "not there" or "no answer"; either way
|
||||||
/// way the daemon is the fallback.
|
/// the daemon is the fallback.
|
||||||
async fn mounted_target_within(mountpoint: PathBuf, hit: SearchHit) -> Option<PathBuf> {
|
async fn mounted_target_within(mountpoint: PathBuf, hit: SearchHit) -> Option<PathBuf> {
|
||||||
let (tx, rx) = async_channel::bounded(1);
|
present_within(mounted_or_relative(&mountpoint, &hit)).await
|
||||||
std::thread::spawn(move || {
|
|
||||||
let _ = tx.send_blocking(mounted_target(&mountpoint, &hit));
|
|
||||||
});
|
|
||||||
match glib::future_with_timeout(STAT_TIMEOUT, rx.recv()).await {
|
|
||||||
Ok(Ok(target)) => target,
|
|
||||||
Ok(Err(_)) => None,
|
|
||||||
Err(_) => {
|
|
||||||
tracing::warn!("the mount did not answer in time; opening through the daemon");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ui {
|
impl Ui {
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
|||||||
&[("title", &j.title), ("done", &done), ("total", &total)],
|
&[("title", &j.title), ("done", &done), ("total", &total)],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Some(j) => format!("{}…", j.title),
|
// Translators: {title} names a running job whose size is not known yet.
|
||||||
|
Some(j) => gettext_f("{title}…", &[("title", &j.title)]),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
},
|
},
|
||||||
[t] => {
|
[t] => {
|
||||||
@@ -118,10 +119,19 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
|||||||
TransferDirection::Upload => "↑",
|
TransferDirection::Upload => "↑",
|
||||||
};
|
};
|
||||||
if t.bytes_total == 0 {
|
if t.bytes_total == 0 {
|
||||||
format!("{arrow} {}…", t.name)
|
// Translators: one transfer of unknown size; {arrow} is ↑ or ↓, {name} a file name.
|
||||||
|
gettext_f("{arrow} {name}…", &[("arrow", arrow), ("name", &t.name)])
|
||||||
} else {
|
} else {
|
||||||
let pct = (t.bytes_completed * 100 / t.bytes_total.max(1)).min(100);
|
let pct = (t.bytes_completed * 100 / t.bytes_total.max(1)).min(100);
|
||||||
format!("{arrow} {} ({pct}%)", t.name)
|
// Translators: one transfer; {arrow} is ↑ or ↓, {name} a file name, {percent} how much is done.
|
||||||
|
gettext_f(
|
||||||
|
"{arrow} {name} ({percent}%)",
|
||||||
|
&[
|
||||||
|
("arrow", arrow),
|
||||||
|
("name", &t.name),
|
||||||
|
("percent", &pct.to_string()),
|
||||||
|
],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -361,8 +371,9 @@ fn open_manager(args: &[&str]) {
|
|||||||
.and_then(|p| p.parent().map(|d| d.join("pdfs-app")))
|
.and_then(|p| p.parent().map(|d| d.join("pdfs-app")))
|
||||||
.filter(|p| p.exists())
|
.filter(|p| p.exists())
|
||||||
.unwrap_or_else(|| PathBuf::from("pdfs-app"));
|
.unwrap_or_else(|| PathBuf::from("pdfs-app"));
|
||||||
if let Err(e) = Command::new(&exe).args(args).spawn() {
|
match Command::new(&exe).args(args).spawn() {
|
||||||
tracing::error!("failed to launch {}: {e}", exe.display());
|
Ok(child) => pdfs_core::opener::reap(child),
|
||||||
|
Err(e) => tracing::error!("failed to launch {}: {e}", exe.display()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-2
@@ -255,7 +255,9 @@ not one this daemon sealed itself (`own_sealed_rev` table), the queued bytes are
|
|||||||
conflict copy (`Core::revision_conflict`) next to the remote version. A name taken on Drive by a
|
conflict copy (`Core::revision_conflict`) next to the remote version. A name taken on Drive by a
|
||||||
node this machine has not seen lands under a conflict name too. Both go to the activity log.
|
node this machine has not seen lands under a conflict name too. Both go to the activity log.
|
||||||
|
|
||||||
Conflict copies are named `{stem} (sync-conflict {unix_secs}).{ext}`.
|
Conflict copies are named `{stem} (sync-conflict {host} {unix_secs}).{ext}`, where `{host}` is the
|
||||||
|
hostname cut down to one token (`conflict_label`). `conflict_base_name` also reads the older form
|
||||||
|
without a host.
|
||||||
|
|
||||||
### Failures and sync issues
|
### Failures and sync issues
|
||||||
|
|
||||||
@@ -342,6 +344,22 @@ Safety rules:
|
|||||||
- **Ignore rules.** `.pdfsignore` (or `.protonignore`) at the folder root plus `ignore_patterns`
|
- **Ignore rules.** `.pdfsignore` (or `.protonignore`) at the folder root plus `ignore_patterns`
|
||||||
from `config.json`, in gitignore syntax. Ignoring never deletes.
|
from `config.json`, in gitignore syntax. Ignoring never deletes.
|
||||||
|
|
||||||
|
Another client may write the same folder, such as a second computer syncing into the device. The
|
||||||
|
baseline therefore also records the active revision id each file was synced at (schema 44):
|
||||||
|
|
||||||
|
- **A new revision is a remote change** even when the link's modification time and the size come
|
||||||
|
out the same.
|
||||||
|
- **An upload checks first.** `upload_revision` reads the node and leaves a file whose revision
|
||||||
|
moved since the walk for the next pass, which classifies it as a conflict. A revision committed
|
||||||
|
between that check and the SDK's own revision draft is not caught.
|
||||||
|
- **The baseline records what was transferred.** A download records the revision the pass
|
||||||
|
planned to fetch, not a read-back that may already show a newer one. An upload records the
|
||||||
|
read-back only when its SHA-1 is the one sent.
|
||||||
|
- **A first sync compares content.** A path present on both sides without a baseline is adopted
|
||||||
|
only when the local SHA-1 matches Drive's digest; otherwise it is a conflict.
|
||||||
|
|
||||||
|
Remote changes still reach a mirror only through the 120 s poll.
|
||||||
|
|
||||||
### Moves between locations
|
### Moves between locations
|
||||||
|
|
||||||
My files and each online-only folder are separate FUSE sessions, and a mirrored folder has none,
|
My files and each online-only folder are separate FUSE sessions, and a mirrored folder has none,
|
||||||
@@ -369,12 +387,18 @@ named like the hostname, else a new one. An adopted uid that no longer exists is
|
|||||||
replaced silently.
|
replaced silently.
|
||||||
|
|
||||||
The local database does not survive a lost machine, so the daemon backs up its arrangement to
|
The local database does not survive a lost machine, so the daemon backs up its arrangement to
|
||||||
`<device root>/.proton-drive-linux/profile.json` (`pdfs_core::profile`): each synced folder's
|
`<device root>/.proton-drive-linux/profile-<machine id>.json` (`pdfs_core::profile`): each synced folder's
|
||||||
remote uid, local path and mode, the pins, and the ignore patterns. It is uploaded like any other
|
remote uid, local path and mode, the pins, and the ignore patterns. It is uploaded like any other
|
||||||
file, so it is end-to-end encrypted. `Core::touch_profile` coalesces bursts of changes into one
|
file, so it is end-to-end encrypted. `Core::touch_profile` coalesces bursts of changes into one
|
||||||
upload. `PROFILE_VERSION` guards the format: a profile from a newer client is refused, not applied
|
upload. `PROFILE_VERSION` guards the format: a profile from a newer client is refused, not applied
|
||||||
in part.
|
in part.
|
||||||
|
|
||||||
|
The machine id is random and lives in `machine-id` next to `config.json`. Each machine has its
|
||||||
|
own file because several computers may sync into one device: `pdfs devices join` and **Sync With
|
||||||
|
This Computer…** adopt another computer's device without taking it over. Restore reads every
|
||||||
|
profile there, the legacy shared `profile.json` included, and prefers the one with this hostname,
|
||||||
|
then the newest (`profile::saved_folder`).
|
||||||
|
|
||||||
Restore lists the device's folders, proposes a local path from the profile, and on confirmation
|
Restore lists the device's folders, proposes a local path from the profile, and on confirmation
|
||||||
adds a synced folder bound to the remote uid. The download is an ordinary pass against an empty
|
adds a synced folder bound to the remote uid. The download is an ordinary pass against an empty
|
||||||
baseline. The user side is in [RECOVERY.md](RECOVERY.md).
|
baseline. The user side is in [RECOVERY.md](RECOVERY.md).
|
||||||
|
|||||||
+1145
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,126 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.5.0] — 2026-10-16
|
||||||
|
|
||||||
|
Schema: **44**. Queued changes get an index by kind and name, and each synced file remembers the
|
||||||
|
version on Proton Drive it was synced at. Nothing is rewritten, and the upgrade takes a moment.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Several computers can sync the same folders.** On the Computers page, **Sync With This
|
||||||
|
Computer…** on another computer, or `pdfs devices join`, makes this computer sync into that
|
||||||
|
computer's backup alongside it, then offers its folders for syncing here. Both computers keep
|
||||||
|
them in step, deletions included. Each computer now keeps its own settings backup in the
|
||||||
|
shared backup, so they no longer overwrite each other (B252).
|
||||||
|
- **The app remembers its window size**, and whether it was maximized, between runs.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The app's window could not shrink to fit a small or scaled screen**, so the minimize,
|
||||||
|
maximize and close buttons ended up off-screen. Below about 760 pixels the sidebar and the
|
||||||
|
page now take turns, and every page fits in a window 480 pixels wide.
|
||||||
|
- **The app showed the sign-in page when the system keyring was locked or not running yet**, as
|
||||||
|
if the sign-in had been lost. It now says that the keyring could not be read, and opens on its
|
||||||
|
own once the keyring answers (B253).
|
||||||
|
- **A new version saved on another computer in the same second as an upload from here could
|
||||||
|
be missed for good** when Drive had not listed the uploaded file yet. The first sync pass
|
||||||
|
after upgrading reads each file's size from Drive once more to close this (B254).
|
||||||
|
- **An edit in a synced folder could replace one another computer had just saved.** The newer
|
||||||
|
version from the other computer was left only in the version history. The edit is now kept as
|
||||||
|
a conflict copy (B249).
|
||||||
|
- **A version saved on another computer while this one downloaded the previous version never
|
||||||
|
arrived.** (B250)
|
||||||
|
- **Syncing a folder into one that already held a file of the same name and size took the two
|
||||||
|
for the same file**, even when their content differed. The content is now compared, and a
|
||||||
|
different file is kept as a conflict copy (B248).
|
||||||
|
- **Conflict copies now name the computer that made them**, for example
|
||||||
|
`notes (sync-conflict laptop 1700000000).txt`, so two computers no longer make the same name
|
||||||
|
(B251).
|
||||||
|
- **A folder deleted on another device took files with it that had never reached Drive.** A new
|
||||||
|
file, an edit or an ignored `.git` in a synced folder was deleted along with the folder. Such a
|
||||||
|
folder is now kept and created on Drive again (B211).
|
||||||
|
- **Files on Drive that could not be decrypted were deleted from the synced folder.** They read
|
||||||
|
as deleted on Drive. They are now left alone and named in the activity feed, and a pass in which
|
||||||
|
nothing synced is left on Drive is refused (B212).
|
||||||
|
- **An edit made while a synced folder was being checked could be deleted** when another device
|
||||||
|
had deleted the file. It is now kept and uploaded (B213).
|
||||||
|
- **Deleting a synced folder sent another device's new files in it to the trash.** The folder now
|
||||||
|
stays on Drive with them (B214).
|
||||||
|
- **A file name on Drive containing `/` or `..` could write outside the synced folder**, and made
|
||||||
|
its folder in the mount fail to list. Such names are left out (B215).
|
||||||
|
- **A damaged database lost the changes still waiting to upload.** It is still moved aside and
|
||||||
|
rebuilt, but queued changes, synced folders' state and files kept offline are copied out of it
|
||||||
|
first (B216).
|
||||||
|
- **Search slowed down over time, and the database grew far larger than needed.** The search
|
||||||
|
indexes are now compacted once a day; on one machine that took the local index from 317 MiB to
|
||||||
|
20 MiB (B217).
|
||||||
|
- **`pdfs diagnose` and `pdfs cache inspect` froze the drive while they checked the database**,
|
||||||
|
for half a minute on a large one. They no longer hold up anything else (B218).
|
||||||
|
- **Listing a folder shared with you could freeze the whole drive on a slow connection**, and so
|
||||||
|
could folders of photos asking for thumbnails while the connection stalled (B219, B220).
|
||||||
|
- **Opening a large folder for the first time held up every other file operation on the drive.**
|
||||||
|
(B221)
|
||||||
|
- **A full disk while saving a file was not reported.** The program saving it now gets the error
|
||||||
|
when it closes the file. Copying a large file into the drive no longer stalls everything else at
|
||||||
|
the end, `fsync` on a file opened for reading no longer fails, and `fallocate` modes the drive
|
||||||
|
cannot honour are refused instead of corrupting the file (B222, B224, B227).
|
||||||
|
- **A damaged settings file was replaced with defaults at the next settings change**, losing the
|
||||||
|
mount location and the ignore rules. Settings are no longer saved over a file that cannot be
|
||||||
|
read (B223).
|
||||||
|
- **Google Photos import gave some photos another photo's name and date.** A photo without its
|
||||||
|
own metadata file took a similarly named one's, and an edited copy took its original's name. An
|
||||||
|
edited copy now takes its original's date and keeps its own name (B225).
|
||||||
|
- **Thumbnails of portrait photos lay on their side**, here and in Proton's own apps (B226).
|
||||||
|
- **A write could be lost to a power cut after the program saving it had closed the file.** Saving
|
||||||
|
a change into the upload queue is now written through to disk (B229).
|
||||||
|
- **A partly written file whose upload answer was lost came back as a conflict copy of itself.**
|
||||||
|
The retry now recognises its own upload (B228).
|
||||||
|
- **A file or folder created offline could merge with another device's of the same name** made
|
||||||
|
while this computer was offline (B230).
|
||||||
|
- **A large upload under a low upload limit never finished.** It ran out of time on every try,
|
||||||
|
took the drive offline and started over. Its time limit now allows for its share of the limit
|
||||||
|
(B231).
|
||||||
|
- **Every reconnect sent all failed uploads again at once,** including those Proton had refused
|
||||||
|
for a full quota or missing access, and failed uploads were retried no later than every two
|
||||||
|
minutes. Refused uploads now keep their wait, which grows to five minutes, and retries are
|
||||||
|
spread out (B237).
|
||||||
|
- **Signing out left the session valid on Proton.** It is now ended there too; `pdfs logout` says
|
||||||
|
so when Proton cannot be reached (B233).
|
||||||
|
- **Any folder could be synced, the home folder included,** which uploaded the app's own cache
|
||||||
|
and walked into the drive itself. The root of the file system, the home folder and the folders
|
||||||
|
above it, the Proton Drive folder, the app's own data and folders inside or around another
|
||||||
|
synced folder are now refused, also when restoring another computer's folders (B234).
|
||||||
|
- **Names with `&` or `<` showed as empty rows in the app**, and some names were drawn as
|
||||||
|
formatting, for example shared items, computer names and link addresses (B232).
|
||||||
|
- **Photos imported from Google Photos, re-dated, or uploaded from the app had no thumbnail** in
|
||||||
|
Proton's apps (B236).
|
||||||
|
- **Photos with no other date were filed under the day they were uploaded.** Their own EXIF date
|
||||||
|
is now used, for Google Photos imports and for uploads from the app (B244).
|
||||||
|
- **Scanning a Google Photos export with a large folder took up to half a minute.** 10,000 photos
|
||||||
|
now take a tenth of a second (B235).
|
||||||
|
- **Every photo opened in the viewer stayed in memory**, with its full-size image, until the app
|
||||||
|
quit. The Share dialog leaked the same way (B241).
|
||||||
|
- **Opening a large photo froze the app** while it was decoded (B242).
|
||||||
|
- **The app froze when opening a file while the drive did not respond.** It now falls back to
|
||||||
|
downloading the file after two seconds. Applications the app started also no longer linger as
|
||||||
|
zombie processes (B243).
|
||||||
|
- **`pdfs import-google-photos --wait` and `pdfs redate-photos --wait` succeeded when the run
|
||||||
|
failed.** They now exit with an error when the run failed, was cancelled or left photos failed,
|
||||||
|
and the app shows why an import failed (B239).
|
||||||
|
- **`pdfs mount`, `login`, `unlock` and `logout` could be core-dumped with the account's keys in
|
||||||
|
memory.** Only the background service was protected. A file named like an option, such as
|
||||||
|
`--json`, also broke the file manager's "Keep offline" script (B240).
|
||||||
|
- **Signing in while the system keyring was locked froze the app window** until the keyring was
|
||||||
|
unlocked (B246).
|
||||||
|
- **A program the background service started could keep it from being restarted when it hung**
|
||||||
|
(B245).
|
||||||
|
- **The tray's transfer line was not translated** (B247).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Draining a large batch of offline changes no longer slows down with its size.** The queue is
|
||||||
|
indexed by name (schema 43, B238).
|
||||||
|
- **Release binaries are stripped of their symbols**: `pdfs` goes from 43 to 35 MB, `pdfs-app`
|
||||||
|
from 24 to 19 MB and `pdfs-tray` from 12 to 8 MB.
|
||||||
|
|
||||||
## [3.4.1] — 2026-10-16
|
## [3.4.1] — 2026-10-16
|
||||||
|
|
||||||
Schema: **42**, unchanged.
|
Schema: **42**, unchanged.
|
||||||
@@ -1804,6 +1924,7 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
|||||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||||
by a new POSIX compliance suite for the filesystem.
|
by a new POSIX compliance suite for the filesystem.
|
||||||
|
|
||||||
|
[3.5.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.5.0
|
||||||
[3.4.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.1
|
[3.4.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.1
|
||||||
[3.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.0
|
[3.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.0
|
||||||
[3.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.3.0
|
[3.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.3.0
|
||||||
|
|||||||
+2
-1
@@ -36,7 +36,7 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pdfs login [-u EMAIL]` | Sign in (password, then a 2FA code if the account has one) and store the session in the system keyring. Restarts the service if it is running. |
|
| `pdfs login [-u EMAIL]` | Sign in (password, then a 2FA code if the account has one) and store the session in the system keyring. Restarts the service if it is running. |
|
||||||
| `pdfs logout` | Remove the stored session. |
|
| `pdfs logout` | End the session on Proton and remove the stored session. |
|
||||||
| `pdfs status` | Account, mount, queue and cache state. |
|
| `pdfs status` | Account, mount, queue and cache state. |
|
||||||
| `pdfs quota` | Storage used across all Proton products. |
|
| `pdfs quota` | Storage used across all Proton products. |
|
||||||
| `pdfs locations` | Every local place the service occupies: the Proton Drive folder and each synced folder, with mode and state. |
|
| `pdfs locations` | Every local place the service occupies: the Proton Drive folder and each synced folder, with mode and state. |
|
||||||
@@ -102,6 +102,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| `pdfs devices list` | List computers registered to the account. |
|
| `pdfs devices list` | List computers registered to the account. |
|
||||||
| `pdfs devices rename UID NAME` | Rename a computer. |
|
| `pdfs devices rename UID NAME` | Rename a computer. |
|
||||||
| `pdfs devices rm UID` | Remove a computer and its backup from the account. |
|
| `pdfs devices rm UID` | Remove a computer and its backup from the account. |
|
||||||
|
| `pdfs devices join UID [--yes]` | Sync the same folders as another computer. This machine syncs into that computer's backup alongside it, then offers its folders like `pdfs sync restore`. Both computers keep them in step. `--yes` skips the confirmation and accepts every proposed path. |
|
||||||
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
|
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
|
||||||
| `pdfs devices migrate UID [--yes]` | Move another computer's folders into this computer's backup, then restore them here like `pdfs sync restore`. The other computer stays registered, without those folders. `--yes` skips the confirmation and accepts every proposed path. See [RECOVERY.md](RECOVERY.md). |
|
| `pdfs devices migrate UID [--yes]` | Move another computer's folders into this computer's backup, then restore them here like `pdfs sync restore`. The other computer stays registered, without those folders. `--yes` skips the confirmation and accepts every proposed path. See [RECOVERY.md](RECOVERY.md). |
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ and where the client keeps its data.
|
|||||||
| Path | Contents |
|
| Path | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `~/.config/proton-drive-linux/config.json` | Settings (this page) |
|
| `~/.config/proton-drive-linux/config.json` | Settings (this page) |
|
||||||
|
| `~/.config/proton-drive-linux/machine-id` | A random id for this machine. It names this machine's profile in a computer's backup that several computers may share |
|
||||||
| `~/.local/state/proton-drive-linux/cache.db` | Metadata database: names, folder tree, sync state, upload queue, photo index |
|
| `~/.local/state/proton-drive-linux/cache.db` | Metadata database: names, folder tree, sync state, upload queue, photo index |
|
||||||
| `~/.local/state/proton-drive-linux/sdk_cache.db` | Encrypted cache of Proton Drive metadata. Safe to delete while the service is stopped |
|
| `~/.local/state/proton-drive-linux/sdk_cache.db` | Encrypted cache of Proton Drive metadata. Safe to delete while the service is stopped |
|
||||||
| `~/.local/state/proton-drive-linux/control.sock` | Socket the app, tray, prompt and CLI use to reach the service |
|
| `~/.local/state/proton-drive-linux/control.sock` | Socket the app, tray, prompt and CLI use to reach the service |
|
||||||
@@ -61,6 +62,7 @@ fix it.
|
|||||||
"online_map": false,
|
"online_map": false,
|
||||||
"tray_hidden": false,
|
"tray_hidden": false,
|
||||||
"files_view": { "list": true, "sort": "modified", "descending": true, "folders_first": true },
|
"files_view": { "list": true, "sort": "modified", "descending": true, "folders_first": true },
|
||||||
|
"window": { "width": 980, "height": 680, "maximized": false },
|
||||||
"open_with": {
|
"open_with": {
|
||||||
"terminal": ["alacritty", "-e"],
|
"terminal": ["alacritty", "-e"],
|
||||||
"rules": [{ "match": ["@text"], "command": ["nvim"], "terminal": true }]
|
"rules": [{ "match": ["@text"], "command": ["nvim"], "terminal": true }]
|
||||||
@@ -78,7 +80,7 @@ fix 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 |
|
||||||
| `ignore_patterns` | list | see below | — | Global ignore rules for synced folders |
|
| `ignore_patterns` | list | see below | — | Global ignore rules for synced folders |
|
||||||
| `device_uid` | string | none | Computers → Continue This Backup Here… | The computer this machine continues. Unset means "match by hostname" |
|
| `device_uid` | string | none | Computers → Continue This Backup Here… or Sync With This Computer… | The computer this machine syncs into. Unset means "match by hostname" |
|
||||||
| `conflict_sweep` | `off` / `report` / `enforce` | `report` | — | See [`conflict_sweep`](#conflict_sweep) |
|
| `conflict_sweep` | `off` / `report` / `enforce` | `report` | — | See [`conflict_sweep`](#conflict_sweep) |
|
||||||
| `local_first` | bool | `true` | — | See [`local_first`](#local_first) |
|
| `local_first` | bool | `true` | — | See [`local_first`](#local_first) |
|
||||||
| `language` | gettext code | system | General → Appearance | Language of the app, tray and prompt, such as `de` or `pt_BR` |
|
| `language` | gettext code | system | General → Appearance | Language of the app, tray and prompt, such as `de` or `pt_BR` |
|
||||||
@@ -86,6 +88,7 @@ fix it.
|
|||||||
| `online_map` | bool | `false` | General → Appearance | Street map for Places from OpenFreeMap |
|
| `online_map` | bool | `false` | General → Appearance | Street map for Places from OpenFreeMap |
|
||||||
| `tray_hidden` | bool | `false` | General → Appearance | Hide the tray icon |
|
| `tray_hidden` | bool | `false` | General → Appearance | Hide the tray icon |
|
||||||
| `files_view` | object | grid, by name | My files view menu | `list`, `sort` (`name`, `size`, `modified`), `descending`, `folders_first` |
|
| `files_view` | object | grid, by name | My files view menu | `list`, `sort` (`name`, `size`, `modified`), `descending`, `folders_first` |
|
||||||
|
| `window` | object | 980 × 680 | Resizing the app window | `width`, `height` (the unmaximized size), `maximized` |
|
||||||
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
|
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
|
||||||
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
|
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -17,7 +17,7 @@ your synced folders back on the replacement machine.
|
|||||||
| Every file that finished uploading | Proton Drive |
|
| Every file that finished uploading | Proton Drive |
|
||||||
| The computer's registration and its backup | **Computers** in the app and the web |
|
| The computer's registration and its backup | **Computers** in the app and the web |
|
||||||
| One folder per synced folder, under that computer | Proton Drive |
|
| One folder per synced folder, under that computer | Proton Drive |
|
||||||
| The machine profile: which folders were synced, to which local path, in which mode; pins; ignore patterns | `.proton-drive-linux/profile.json` under the computer's backup, end-to-end encrypted like any other file |
|
| The machine profile: which folders were synced, to which local path, in which mode; pins; ignore patterns | `.proton-drive-linux/profile-<machine id>.json` under the computer's backup, one per computer that syncs into it, end-to-end encrypted like any other file. Clients before 3.5.0 wrote `profile.json` |
|
||||||
| Your account, keys and shares | Proton |
|
| Your account, keys and shares | Proton |
|
||||||
|
|
||||||
The service rewrites the profile a short while after you add, remove or switch a synced folder,
|
The service rewrites the profile a short while after you add, remove or switch a synced folder,
|
||||||
@@ -166,18 +166,21 @@ This section is for the curious and for anyone debugging a restore.
|
|||||||
in the account is reported in the log instead of being replaced silently.
|
in the account is reported in the log instead of being replaced silently.
|
||||||
- **What can be restored.** `pdfs sync restore` lists the folders directly under the computer's
|
- **What can be restored.** `pdfs sync restore` lists the folders directly under the computer's
|
||||||
backup, except `.proton-drive-linux`. Folders already synced on this machine are skipped.
|
backup, except `.proton-drive-linux`. Folders already synced on this machine are skipped.
|
||||||
- **Proposed paths.** When the profile names a local path whose parent directory exists on this
|
- **Proposed paths.** Several computers may sync into one backup, each with its own profile. The
|
||||||
|
profile written by a computer with this machine's hostname is used first, then the newest one
|
||||||
|
that has the folder. When it names a local path whose parent directory exists on this
|
||||||
machine, that path is proposed. Otherwise the proposal is `~/<folder name>`. The service never
|
machine, that path is proposed. Otherwise the proposal is `~/<folder name>`. The service never
|
||||||
writes to a path you have not confirmed.
|
writes to a path you have not confirmed.
|
||||||
- **Attaching by UID.** The restore binds each local path to the remote folder's UID, not to its
|
- **Attaching by UID.** The restore binds each local path to the remote folder's UID, not to its
|
||||||
name. Restoring `Documents` into `~/docs` works.
|
name. Restoring `Documents` into `~/docs` works.
|
||||||
- **Downloading.** A restored folder starts with an empty sync baseline. Against an empty local
|
- **Downloading.** A restored folder starts with an empty sync baseline. Against an empty local
|
||||||
directory, every remote file counts as new and is downloaded. Files present on both sides with
|
directory, every remote file counts as new and is downloaded. Files present on both sides with
|
||||||
no baseline become conflict copies, as described above.
|
no baseline are compared by content: identical files are taken as they are, and different
|
||||||
|
ones become conflict copies, as described above.
|
||||||
- **The wipe guard.** A sync pass in which every previously synced file has vanished locally is
|
- **The wipe guard.** A sync pass in which every previously synced file has vanished locally is
|
||||||
refused instead of being mirrored to Proton Drive as a mass deletion. A folder that was emptied
|
refused instead of being mirrored to Proton Drive as a mass deletion. A folder that was emptied
|
||||||
by accident therefore does not empty the backup.
|
by accident therefore does not empty the backup.
|
||||||
- **The profile format.** `profile.json` carries a `version` field. A client refuses a profile
|
- **The profile format.** Each profile carries a `version` field. A client refuses a profile
|
||||||
written by a newer, incompatible client instead of applying part of it.
|
written by a newer, incompatible client instead of applying part of it.
|
||||||
|
|
||||||
## 4. Known gaps
|
## 4. Known gaps
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ Three layers check the client, from fast and offline to slow and against a real
|
|||||||
| Layer | Needs | Runs in CI |
|
| Layer | Needs | Runs in CI |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [Unit tests](#unit-tests) | Nothing | Every push and pull request |
|
| [Unit tests](#unit-tests) | Nothing | Every push and pull request |
|
||||||
| [Simulation runs](#simulation-runs) | `/dev/fuse` | Every push to `main` |
|
| [Simulation runs](#simulation-runs) | `/dev/fuse` | Every push to `main` and every pull request |
|
||||||
| [Acceptance suite](#acceptance-suite) | Nothing for the reference run; a signed-in account for the rest | The reference run only |
|
| [Acceptance suite](#acceptance-suite) | Nothing for the reference run; a signed-in account for the rest | The reference run only |
|
||||||
|
|
||||||
## Unit tests
|
## Unit tests
|
||||||
|
|||||||
@@ -97,7 +97,15 @@ this block, 5003 is a release Proton no longer accepts.
|
|||||||
|
|
||||||
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
|
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
|
||||||
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
|
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
|
||||||
enabled). `pdfs diagnose` shows `keyring session` as failing in that case.
|
enabled). `pdfs diagnose` shows `keyring session` as failing in that case. When the app finds
|
||||||
|
a saved sign-in it cannot read, it says so above the sign-in form and opens on its own once the
|
||||||
|
keyring is unlocked. You don't have to sign in again.
|
||||||
|
|
||||||
|
**Asked for the mailbox password after every reboot** — the account has a separate mailbox
|
||||||
|
password (two-password mode). Such accounts are locked by default: the unlock lasts until you log
|
||||||
|
out or restart. To stay unlocked, turn on **Keep unlocked on this computer** in the unlock dialog,
|
||||||
|
or run `pdfs unlock --remember`. Either one stores the unlock in the system keyring.
|
||||||
|
[SECURITY_MODEL.md](SECURITY_MODEL.md) explains what that trades away.
|
||||||
|
|
||||||
## The Proton Drive folder
|
## The Proton Drive folder
|
||||||
|
|
||||||
|
|||||||
+23
-3
@@ -188,15 +188,16 @@ Before you shut down a computer for good, check that nothing is waiting: see
|
|||||||
|
|
||||||
When the same file changed on this computer and somewhere else since the last sync, neither side
|
When the same file changed on this computer and somewhere else since the last sync, neither side
|
||||||
is thrown away. The local version is kept as a copy named like
|
is thrown away. The local version is kept as a copy named like
|
||||||
`report (sync-conflict 1759068180).odt`, where the number is the time of the conflict in seconds
|
`report (sync-conflict laptop 1759068180).odt`, where `laptop` is the name of the computer that
|
||||||
since 1970, and the remote version takes the original name.
|
made the copy and the number is the time of the conflict in seconds since 1970. The remote
|
||||||
|
version takes the original name. Copies made before 3.5.0 have no computer name.
|
||||||
|
|
||||||
Conflicts appear under **Sync → Overview → Needs attention** and in `pdfs conflicts`. For each
|
Conflicts appear under **Sync → Overview → Needs attention** and in `pdfs conflicts`. For each
|
||||||
one, keep the original, keep the copy, or keep both under a new name:
|
one, keep the original, keep the copy, or keep both under a new name:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pdfs conflicts
|
pdfs conflicts
|
||||||
pdfs conflicts resolve "Documents/report (sync-conflict 1759068180).odt" --keep copy
|
pdfs conflicts resolve "Documents/report (sync-conflict laptop 1759068180).odt" --keep copy
|
||||||
```
|
```
|
||||||
|
|
||||||
The version you do not keep goes to the Trash, so a wrong choice can be undone.
|
The version you do not keep goes to the Trash, so a wrong choice can be undone.
|
||||||
@@ -284,12 +285,31 @@ Proton Drive keeps earlier versions of every file. **Details → Versions…** i
|
|||||||
back folders that are backed up under it but not synced here yet.
|
back folders that are backed up under it but not synced here yet.
|
||||||
- **Another computer** can be browsed read-only in the same list or grid as My files, and
|
- **Another computer** can be browsed read-only in the same list or grid as My files, and
|
||||||
**Restore to This Computer…** downloads its folders here.
|
**Restore to This Computer…** downloads its folders here.
|
||||||
|
- **Sync With This Computer…** makes this computer sync the same folders as another one, for
|
||||||
|
example a desktop and a laptop. Both keep the folders in step: a change or a deletion on one
|
||||||
|
reaches the other. See [Syncing several computers](#syncing-several-computers).
|
||||||
- **Continue This Backup Here…** makes this computer take over another computer's backup, for
|
- **Continue This Backup Here…** makes this computer take over another computer's backup, for
|
||||||
example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md).
|
example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md).
|
||||||
- **Move to This Computer…** moves another computer's folders into this computer's backup, for
|
- **Move to This Computer…** moves another computer's folders into this computer's backup, for
|
||||||
example when you switch from Windows to Linux, and then offers them for syncing here. The other
|
example when you switch from Windows to Linux, and then offers them for syncing here. The other
|
||||||
computer stays in the list, without those folders.
|
computer stays in the list, without those folders.
|
||||||
|
|
||||||
|
### Syncing several computers
|
||||||
|
|
||||||
|
Two or more computers can sync the same folders through one backup in Proton Drive.
|
||||||
|
|
||||||
|
1. Sync the folders on the first computer as usual.
|
||||||
|
2. On the second computer, open **Computers**, open the menu of the first computer and choose
|
||||||
|
**Sync With This Computer…**. From a terminal: `pdfs devices join UID`, with the UID from
|
||||||
|
`pdfs devices list`.
|
||||||
|
3. Choose which of its folders to sync here and where, as in a restore.
|
||||||
|
|
||||||
|
From then on, both computers sync into that backup. A folder you add later on either computer
|
||||||
|
lands there too, and a folder with the same name joins the one already there. An edit on one
|
||||||
|
computer reaches the other within about two minutes. When both computers change the same file in
|
||||||
|
that time, each keeps the other's version under the original name and its own as a conflict copy
|
||||||
|
named after it. Folders that were synced here before joining stay in this computer's own backup.
|
||||||
|
|
||||||
## Search launcher
|
## Search launcher
|
||||||
|
|
||||||
`pdfs-prompt` searches Proton Drive and your home folder together, in one ranked list. It
|
`pdfs-prompt` searches Proton Drive and your home folder together, in one ranked list. It
|
||||||
|
|||||||
+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.4.1
|
pkgver=3.5.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')
|
||||||
|
|||||||
@@ -8,10 +8,17 @@ PartOf=graphical-session.target
|
|||||||
[Service]
|
[Service]
|
||||||
# Deliberately not Type=notify: the daemon waits for a stored login before it
|
# Deliberately not Type=notify: the daemon waits for a stored login before it
|
||||||
# mounts anything, and a machine that boots before the user logs in would then
|
# mounts anything, and a machine that boots before the user logs in would then
|
||||||
# fail the unit on the start timeout. NotifyAccess=all gets the daemon a
|
# fail the unit on the start timeout. NotifyAccess=main gets the daemon a
|
||||||
# NOTIFY_SOCKET all the same, which is all the watchdog below needs.
|
# NOTIFY_SOCKET all the same, which is all the watchdog below needs, and keeps
|
||||||
|
# its children (fusermount3, ffmpeg, exiftool) from answering the watchdog for
|
||||||
|
# it. "all" let any of them do so.
|
||||||
|
#
|
||||||
|
# Most sandboxing options are left out on purpose: in a user unit, every
|
||||||
|
# seccomp-based one (SystemCallFilter, LockPersonality, RestrictAddressFamilies
|
||||||
|
# and the like) implies NoNewPrivileges, which stops the setuid fusermount3
|
||||||
|
# from mounting anything.
|
||||||
Type=simple
|
Type=simple
|
||||||
NotifyAccess=all
|
NotifyAccess=main
|
||||||
# The daemon waits for a stored login, mounts ~/ProtonDrive, and keeps it
|
# The daemon waits for a stored login, mounts ~/ProtonDrive, and keeps it
|
||||||
# mounted. SIGTERM (from `systemctl --user stop`) triggers a clean lazy unmount.
|
# mounted. SIGTERM (from `systemctl --user stop`) triggers a clean lazy unmount.
|
||||||
ExecStart=/usr/bin/pdfs daemon
|
ExecStart=/usr/bin/pdfs daemon
|
||||||
|
|||||||
+966
-899
File diff suppressed because it is too large.
Load diff
+933
-876
File diff suppressed because it is too large.
Load diff
+939
-872
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user