mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 01:27:03 +01:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b49631e18f | ||
|
|
f25c2459a0 | ||
|
|
ce35a83a04 | ||
|
|
762d06765e | ||
|
|
a0ab243be6 | ||
|
|
2930b457d4 | ||
|
|
d9da0e2263 |
No files matched your search
Generated
+10
-8
@@ -3541,7 +3541,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "3.8.0"
|
version = "3.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3558,10 +3558,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "3.8.0"
|
version = "3.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
|
"chrono",
|
||||||
"dbus-secret-service-keyring-store",
|
"dbus-secret-service-keyring-store",
|
||||||
"directories",
|
"directories",
|
||||||
"ignore",
|
"ignore",
|
||||||
@@ -3585,7 +3586,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "3.8.0"
|
version = "3.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3607,7 +3608,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "3.8.0"
|
version = "3.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3847,13 +3848,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.10.1"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a8fa2a88130883010b32035e208aaa36fb275f6d7d78ed44a1069b1d33d24481"
|
checksum = "14a22e621abbc1c61b4640728a7de969c16b66d5d983b59c638d501fda25a7b9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -3874,9 +3876,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.10.1"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "af0bf8d50fa829d1a65b09c6855b606578d02734d4089874c971d1063c23c353"
|
checksum = "bd55a5e8b6600f3aa282ebbdf13ff366431ff9aae84d4eb20b0ef06d598919fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+4
-3
@@ -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.8.0"
|
version = "3.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -15,8 +15,9 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
|||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.10.1" }
|
proton-sdk = { version = "0.11.0" }
|
||||||
proton-drive-rs = { version = "0.10.1" }
|
proton-drive-rs = { version = "0.11.0" }
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ description = "Core auth/session/config for the Proton Drive Linux client"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
proton-sdk.workspace = true
|
proton-sdk.workspace = true
|
||||||
proton-drive-rs.workspace = true
|
proton-drive-rs.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub mod similar;
|
|||||||
pub mod syncignore;
|
pub mod syncignore;
|
||||||
pub mod syncroot;
|
pub mod syncroot;
|
||||||
pub mod takeout;
|
pub mod takeout;
|
||||||
|
pub mod timestamp;
|
||||||
pub mod tray;
|
pub mod tray;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//! Convert the client's stored epoch seconds at the SDK boundary.
|
||||||
|
|
||||||
|
use proton_drive_rs::ModificationTime;
|
||||||
|
|
||||||
|
/// A UTC modification time, or `None` when outside the SDK's supported range.
|
||||||
|
pub fn modification_time(seconds: i64) -> Option<ModificationTime> {
|
||||||
|
chrono::DateTime::from_timestamp(seconds, 0).map(|time| time.fixed_offset())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn epoch_seconds_preserve_the_instant_including_before_1970() {
|
||||||
|
for seconds in [-1, 0, 1_709_208_000, 1_782_482_460] {
|
||||||
|
let time = modification_time(seconds).unwrap();
|
||||||
|
assert_eq!(time.timestamp(), seconds);
|
||||||
|
assert_eq!(time.offset().local_minus_utc(), 0);
|
||||||
|
assert_eq!(time.timestamp_subsec_nanos(), 0);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
modification_time(-1).unwrap().to_rfc3339(),
|
||||||
|
"1969-12-31T23:59:59+00:00"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_range_seconds_omit_the_timestamp() {
|
||||||
|
assert!(modification_time(i64::MIN).is_none());
|
||||||
|
assert!(modification_time(i64::MAX).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,17 +157,6 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
if ours {
|
if ours {
|
||||||
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
||||||
}
|
}
|
||||||
// A file kept offline keeps its copy through a change, and the
|
|
||||||
// keeper replaces it once this batch's listings are read again.
|
|
||||||
// The copy is checked against the revision's `(mtime, size)`, so
|
|
||||||
// no read takes it for a newer revision. Evicted, it was gone until
|
|
||||||
// something read the file, which then downloaded it (B282). A
|
|
||||||
// node new in, or moved into, a kept folder is fetched by checking
|
|
||||||
// that folder.
|
|
||||||
let kept = match !*is_trashed && !ours {
|
|
||||||
true => kept_by_pin(core, node_uid, parent_node_uid.as_ref()),
|
|
||||||
false => None,
|
|
||||||
};
|
|
||||||
let mut had_node = false;
|
let mut had_node = false;
|
||||||
core.for_each_mount(|st, notify| {
|
core.for_each_mount(|st, notify| {
|
||||||
if *is_trashed {
|
if *is_trashed {
|
||||||
@@ -183,12 +172,9 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Once, not once per mount: the content cache is per-daemon.
|
// Once, not once per mount: the content cache is per-daemon.
|
||||||
if had_node && kept.is_none() {
|
if had_node {
|
||||||
core.cache.evict(node_uid);
|
core.cache.evict(node_uid);
|
||||||
}
|
}
|
||||||
if let Some(kept) = kept {
|
|
||||||
dirty.kept.insert(kept);
|
|
||||||
}
|
|
||||||
// A create, rename or move-in shows up as a change to the parent
|
// A create, rename or move-in shows up as a change to the parent
|
||||||
// listing too, which is recorded for the end of the batch rather
|
// listing too, which is recorded for the end of the batch rather
|
||||||
// than acted on here — see [`DirtyParents`].
|
// than acted on here — see [`DirtyParents`].
|
||||||
@@ -219,8 +205,6 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
| DriveEvent::ScopeAccessLost { .. }
|
| DriveEvent::ScopeAccessLost { .. }
|
||||||
| DriveEvent::SharedWithMeUpdated { .. } => {
|
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||||
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
||||||
// Changes went by unseen, kept files among them (B282).
|
|
||||||
core.recheck_all_pins();
|
|
||||||
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
||||||
core.for_each_mount(|st, notify| {
|
core.for_each_mount(|st, notify| {
|
||||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||||
@@ -312,21 +296,6 @@ struct DirtyParents {
|
|||||||
/// Checked per mount — a mount that already lists every one of those
|
/// Checked per mount — a mount that already lists every one of those
|
||||||
/// children under that parent keeps its listing.
|
/// children under that parent keeps its listing.
|
||||||
ours: HashMap<NodeUid, HashSet<NodeUid>>,
|
ours: HashMap<NodeUid, HashSet<NodeUid>>,
|
||||||
/// Pinned files and folders the batch changed, for the pin keeper to
|
|
||||||
/// check once the listings above are dropped (`keeper`).
|
|
||||||
kept: HashSet<NodeUid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the pin keeper has to check after a remote change to `node_uid`,
|
|
||||||
/// whose parent is now `parent_uid`: the node itself when a pin keeps it, or
|
|
||||||
/// else the parent, when a pinned folder holds it and the node is new there.
|
|
||||||
fn kept_by_pin(core: &Core, node_uid: &NodeUid, parent_uid: Option<&NodeUid>) -> Option<NodeUid> {
|
|
||||||
if core.cache.is_pinned(node_uid) {
|
|
||||||
return Some(node_uid.clone());
|
|
||||||
}
|
|
||||||
parent_uid
|
|
||||||
.filter(|parent| core.cache.is_pinned(parent))
|
|
||||||
.cloned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this mount's cached listing of `parent_uid` already accounts for
|
/// Whether this mount's cached listing of `parent_uid` already accounts for
|
||||||
@@ -507,9 +476,6 @@ pub(super) async fn run_event_sync(
|
|||||||
cursor = Some(applied);
|
cursor = Some(applied);
|
||||||
}
|
}
|
||||||
flush_dirty_parents(&core, &dirty);
|
flush_dirty_parents(&core, &dirty);
|
||||||
for uid in &dirty.kept {
|
|
||||||
core.recheck_pinned(uid);
|
|
||||||
}
|
|
||||||
// Another device changed the tree: whatever a front-end lists may have
|
// Another device changed the tree: whatever a front-end lists may have
|
||||||
// moved, including what is shared and what is in the trash.
|
// moved, including what is shared and what is in the trash.
|
||||||
core.events
|
core.events
|
||||||
|
|||||||
@@ -204,42 +204,18 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
|||||||
/// B99), so resolving one needs this too. A mirror folder has no mount to run
|
/// B99), so resolving one needs this too. A mirror folder has no mount to run
|
||||||
/// against: a file there is an ordinary local file.
|
/// against: a file there is an ordinary local file.
|
||||||
fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||||
route_within(core, mountpoint, path, |path| {
|
|
||||||
format!("{path} is in a mirrored folder; change the local file there instead")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`route_to_mount`] for keeping a node offline or releasing it. A file in an
|
|
||||||
/// on-demand synced folder is a different Drive node from any file of the
|
|
||||||
/// same name under the primary mount, and keeping one offline did nothing for
|
|
||||||
/// the other: a program reading the synced folder's copy downloaded it
|
|
||||||
/// (`docs/BUGS.md` B281). A mirror folder already holds every file on disk.
|
|
||||||
fn route_keep(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
|
||||||
route_within(core, mountpoint, path, |path| {
|
|
||||||
format!(
|
|
||||||
"{path} is in a mirrored folder, which keeps all its files on this computer already"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`route_to_mount`], refusing a path that leaves the mount it routed to and
|
|
||||||
/// answering a path in a mirror folder, which has no mount, with `mirrored`.
|
|
||||||
fn route_within(
|
|
||||||
core: &Core,
|
|
||||||
mountpoint: &Path,
|
|
||||||
path: &str,
|
|
||||||
mirrored: impl FnOnce(&str) -> String,
|
|
||||||
) -> CoreResult<(Core, PathBuf)> {
|
|
||||||
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
|
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
|
||||||
let p = Path::new(path);
|
let p = Path::new(path);
|
||||||
let in_mirror = core
|
let mirrored = core
|
||||||
.db
|
.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
||||||
if in_mirror {
|
if mirrored {
|
||||||
CoreError::invalid(mirrored(path))
|
CoreError::invalid(format!(
|
||||||
|
"{path} is in a mirrored folder; change the local file there instead"
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
e
|
e
|
||||||
}
|
}
|
||||||
@@ -255,18 +231,6 @@ fn route_within(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The path the pin registry keeps for a pin made through `routed`: the
|
|
||||||
/// mount-relative path on the primary mount, as it always was, and the
|
|
||||||
/// absolute path the request named on an on-demand folder's mount, since
|
|
||||||
/// that node is not under the primary mountpoint (`docs/BUGS.md` B281).
|
|
||||||
fn pin_record(routed: &Core, rel: &Path, path: &str) -> PathBuf {
|
|
||||||
if routed.primary {
|
|
||||||
rel.to_path_buf()
|
|
||||||
} else {
|
|
||||||
Path::new(path).components().collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`route_change`] for a request that trashes or renames the node it names.
|
/// [`route_change`] for a request that trashes or renames the node it names.
|
||||||
/// A mount's root is My files or a whole synced folder. An on-demand folder's
|
/// A mount's root is My files or a whole synced folder. An on-demand folder's
|
||||||
/// root has a parent on Drive, so nothing further down would stop `pdfs rm
|
/// root has a parent on Drive, so nothing further down would stop `pdfs rm
|
||||||
@@ -557,8 +521,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(CtlRequest::Subscribe) => {
|
Ok(CtlRequest::Subscribe) => {
|
||||||
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
|
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::Pin { path }) => match route_keep(core, mountpoint, &path) {
|
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok((routed, rel)) => match routed.pin(&rel, &pin_record(&routed, &rel, &path)) {
|
Ok(rel) => match core.pin(&rel) {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
core.touch_profile();
|
core.touch_profile();
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -569,8 +533,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::Unpin { path }) => match route_keep(core, mountpoint, &path) {
|
Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok((routed, rel)) => match routed.unpin(&rel) {
|
Ok(rel) => match core.unpin(&rel) {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
core.touch_profile();
|
core.touch_profile();
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -975,6 +939,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
});
|
});
|
||||||
let metadata = proton_drive_rs::PhotoUploadMetadata {
|
let metadata = proton_drive_rs::PhotoUploadMetadata {
|
||||||
capture_time,
|
capture_time,
|
||||||
|
last_modification_time: capture_time
|
||||||
|
.and_then(pdfs_core::timestamp::modification_time),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name);
|
let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name);
|
||||||
|
|||||||
@@ -352,7 +352,13 @@ impl DriveApi for ProtonDriveClient {
|
|||||||
name: &str,
|
name: &str,
|
||||||
last_modification_time: Option<i64>,
|
last_modification_time: Option<i64>,
|
||||||
) -> Result<NodeUid> {
|
) -> Result<NodeUid> {
|
||||||
ProtonDriveClient::create_folder(self, parent_uid, name, last_modification_time).await
|
ProtonDriveClient::create_folder(
|
||||||
|
self,
|
||||||
|
parent_uid,
|
||||||
|
name,
|
||||||
|
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_file(
|
async fn upload_file(
|
||||||
@@ -384,7 +390,7 @@ impl DriveApi for ProtonDriveClient {
|
|||||||
reader,
|
reader,
|
||||||
intended_size,
|
intended_size,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
last_modification_time,
|
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
|
||||||
aead,
|
aead,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -409,7 +415,7 @@ impl DriveApi for ProtonDriveClient {
|
|||||||
reader,
|
reader,
|
||||||
intended_size,
|
intended_size,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
last_modification_time,
|
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
|
||||||
aead,
|
aead,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -429,7 +435,7 @@ impl DriveApi for ProtonDriveClient {
|
|||||||
reader,
|
reader,
|
||||||
intended_size,
|
intended_size,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
last_modification_time,
|
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
//! Keeping pinned files on this computer after they were pinned.
|
|
||||||
//!
|
|
||||||
//! A pin downloads a file once. Nothing downloaded it again afterwards: a
|
|
||||||
//! change from another device deleted the cached copy, a file added to a
|
|
||||||
//! pinned folder elsewhere was never fetched, and a download that failed while
|
|
||||||
//! the link was down stayed failed. Each of those left a file listed as kept
|
|
||||||
//! offline that the next read downloaded, and that could not be read at all
|
|
||||||
//! without a network (`docs/BUGS.md` B282).
|
|
||||||
//!
|
|
||||||
//! The keeper is one thread that checks pins again and downloads what is
|
|
||||||
//! missing or stale: every pin when the daemon starts and when the link comes
|
|
||||||
//! back, and the pins a batch of remote events touched once the batch is
|
|
||||||
//! applied. A remote change no longer deletes a pinned file's copy. The copy
|
|
||||||
//! is stored under the revision's `(mtime, size)`, so a read of a newer
|
|
||||||
//! revision does not take it, and the keeper replaces it once the new one is
|
|
||||||
//! downloaded.
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use proton_drive_rs::NodeKind;
|
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
|
|
||||||
use super::{Core, is_local_uid, node_size, parse_uid};
|
|
||||||
|
|
||||||
/// How often the keeper looks for work.
|
|
||||||
const KEEPER_TICK: Duration = Duration::from_secs(1);
|
|
||||||
/// The first wait before a check that could not download everything is tried
|
|
||||||
/// again. It doubles on every failure, up to [`RETRY_MAX`].
|
|
||||||
const RETRY_FIRST: Duration = Duration::from_secs(30);
|
|
||||||
const RETRY_MAX: Duration = Duration::from_secs(30 * 60);
|
|
||||||
|
|
||||||
/// What the keeper owes a check.
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub(crate) struct PinWork {
|
|
||||||
/// Every pin.
|
|
||||||
all: bool,
|
|
||||||
/// The pinned files and folders named here. A folder is checked whole.
|
|
||||||
nodes: HashSet<NodeUid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PinWork {
|
|
||||||
fn is_empty(&self) -> bool {
|
|
||||||
!self.all && self.nodes.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merge(&mut self, other: PinWork) {
|
|
||||||
self.all |= other.all;
|
|
||||||
self.nodes.extend(other.nodes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A file a pin keeps, as the tree knows it now.
|
|
||||||
pub(crate) struct KeptFile {
|
|
||||||
pub(crate) uid: NodeUid,
|
|
||||||
pub(crate) name: String,
|
|
||||||
pub(crate) mtime: i64,
|
|
||||||
pub(crate) size: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Core {
|
|
||||||
/// Have the keeper check every pin.
|
|
||||||
pub(crate) fn recheck_all_pins(&self) {
|
|
||||||
self.pin_work.lock().all = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Have the keeper check the pinned file or folder `uid`.
|
|
||||||
pub(crate) fn recheck_pinned(&self, uid: &NodeUid) {
|
|
||||||
self.pin_work.lock().nodes.insert(uid.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The keeper's loop, until the daemon stops. Starts with a check of every
|
|
||||||
/// pin, which catches up on whatever changed while the daemon was down.
|
|
||||||
///
|
|
||||||
/// Work that failed waits out a backoff of its own. New work does not wait
|
|
||||||
/// for it, so a file that keeps failing does not hold up the next change.
|
|
||||||
pub(crate) fn run_pin_keeper(&self) {
|
|
||||||
self.recheck_all_pins();
|
|
||||||
let mut backlog = PinWork::default();
|
|
||||||
let mut backlog_due = Instant::now();
|
|
||||||
let mut retry = RETRY_FIRST;
|
|
||||||
while self.shutdown.sleep(KEEPER_TICK) {
|
|
||||||
if !self.is_online() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut work = std::mem::take(&mut *self.pin_work.lock());
|
|
||||||
let retrying = !backlog.is_empty() && Instant::now() >= backlog_due;
|
|
||||||
if retrying {
|
|
||||||
work.merge(std::mem::take(&mut backlog));
|
|
||||||
}
|
|
||||||
if work.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let failed = self.keep_pins(&work);
|
|
||||||
if failed.is_empty() {
|
|
||||||
if retrying {
|
|
||||||
retry = RETRY_FIRST;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
backlog.merge(failed);
|
|
||||||
backlog_due = Instant::now() + retry;
|
|
||||||
retry = (retry * 2).min(RETRY_MAX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download every file `work` covers whose cached copy is missing or
|
|
||||||
/// stale. Returns what could not be checked or downloaded.
|
|
||||||
fn keep_pins(&self, work: &PinWork) -> PinWork {
|
|
||||||
let mut failed = PinWork::default();
|
|
||||||
let roots: Vec<NodeUid> = if work.all {
|
|
||||||
self.cache
|
|
||||||
.list_pins()
|
|
||||||
.iter()
|
|
||||||
.filter_map(|pin| parse_uid(&pin.uid))
|
|
||||||
.chain(work.nodes.iter().cloned())
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
work.nodes.iter().cloned().collect()
|
|
||||||
};
|
|
||||||
let mut seen: HashSet<u64> = HashSet::new();
|
|
||||||
let mut files: Vec<u64> = Vec::new();
|
|
||||||
for uid in roots {
|
|
||||||
if self.shutdown.is_stopping() {
|
|
||||||
return failed;
|
|
||||||
}
|
|
||||||
if !self.cache.is_pinned(&uid) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let found = {
|
|
||||||
let st = self.state();
|
|
||||||
st.by_uid.get(&uid).and_then(|&ino| {
|
|
||||||
let e = st.entries.get(&ino)?;
|
|
||||||
Some((ino, e.parent, e.node.is_folder()))
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let Some((ino, parent, is_folder)) = found else {
|
|
||||||
debug!(%uid, "pin keeper: pinned node is not in the tree");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if is_folder {
|
|
||||||
match self.pin_subtree_files(ino) {
|
|
||||||
Ok(inos) => files.extend(inos.into_iter().filter(|i| seen.insert(*i))),
|
|
||||||
Err(e) => {
|
|
||||||
warn!(%uid, error = %e, "pin keeper: listing a kept folder failed");
|
|
||||||
failed.nodes.insert(uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// An event about this file dropped its parent's listing. Listing
|
|
||||||
// it again brings the file's entry up to the revision Drive has
|
|
||||||
// now, which is what the cached copy is checked against.
|
|
||||||
if !work.all
|
|
||||||
&& parent != ino
|
|
||||||
&& let Err(e) = self.ensure_children(parent)
|
|
||||||
{
|
|
||||||
warn!(%uid, error = ?e, "pin keeper: listing a kept file's folder failed");
|
|
||||||
failed.nodes.insert(uid);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if seen.insert(ino) {
|
|
||||||
files.push(ino);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let stale: Vec<KeptFile> = files
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|ino| self.kept_file(ino))
|
|
||||||
.filter(|f| !self.cache.is_cached(&f.uid, f.mtime, f.size))
|
|
||||||
.collect();
|
|
||||||
if stale.is_empty() {
|
|
||||||
return failed;
|
|
||||||
}
|
|
||||||
let job = self.transfers.begin_job("Updating files kept offline");
|
|
||||||
job.set_total(stale.len() as u64);
|
|
||||||
let (mut fetched, mut failures) = (0usize, 0usize);
|
|
||||||
for file in stale {
|
|
||||||
if self.shutdown.is_stopping() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Unpinned while the check ran.
|
|
||||||
if !self.cache.is_pinned(&file.uid) {
|
|
||||||
job.step();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
job.detail(file.name.clone());
|
|
||||||
match self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size) {
|
|
||||||
Ok(()) => fetched += 1,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(uid = %file.uid, error = %e, "pin keeper: download failed");
|
|
||||||
failed.nodes.insert(file.uid);
|
|
||||||
failures += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
job.step();
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
fetched,
|
|
||||||
failed = failures,
|
|
||||||
"pin keeper: brought files kept offline up to date"
|
|
||||||
);
|
|
||||||
failed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The file at `ino`, as a pin keeps it. `None` for a folder, a file this
|
|
||||||
/// daemon still owes an upload for, or a file whose real size cannot be
|
|
||||||
/// read.
|
|
||||||
///
|
|
||||||
/// A queued write is ahead of Drive. Downloading Drive's revision would
|
|
||||||
/// cache what the write replaces, and the drain keeps the uploaded bytes
|
|
||||||
/// of a pinned file once they land.
|
|
||||||
///
|
|
||||||
/// A listing carries the ciphertext size until its size upgrade lands
|
|
||||||
/// (B12). Stored under that size, the copy never matched the plaintext it
|
|
||||||
/// holds, so every read downloaded the file again and every check
|
|
||||||
/// downloaded it once more.
|
|
||||||
pub(crate) fn kept_file(&self, ino: u64) -> Option<KeptFile> {
|
|
||||||
let (uid, name, mtime, size, provisional) = {
|
|
||||||
let st = self.state();
|
|
||||||
let e = st.entries.get(&ino)?;
|
|
||||||
let NodeKind::File { claimed_size, .. } = &e.node.kind else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
(
|
|
||||||
e.uid.clone(),
|
|
||||||
e.node.name.clone(),
|
|
||||||
e.node.modification_time,
|
|
||||||
node_size(&e.node),
|
|
||||||
claimed_size.is_none(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if is_local_uid(&uid) || self.pending.lock().contains_key(&uid) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let size = match provisional {
|
|
||||||
true => self.resolve_provisional_size(ino, &uid)?,
|
|
||||||
false => size,
|
|
||||||
};
|
|
||||||
Some(KeptFile {
|
|
||||||
uid,
|
|
||||||
name,
|
|
||||||
mtime,
|
|
||||||
size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn work_that_failed_keeps_every_node_and_the_full_check() {
|
|
||||||
let mut backlog = PinWork::default();
|
|
||||||
assert!(backlog.is_empty());
|
|
||||||
let uid = parse_uid("vol~a").unwrap();
|
|
||||||
backlog.merge(PinWork {
|
|
||||||
all: false,
|
|
||||||
nodes: HashSet::from([uid.clone()]),
|
|
||||||
});
|
|
||||||
backlog.merge(PinWork {
|
|
||||||
all: true,
|
|
||||||
nodes: HashSet::from([uid.clone()]),
|
|
||||||
});
|
|
||||||
assert!(backlog.all);
|
|
||||||
assert_eq!(backlog.nodes.len(), 1);
|
|
||||||
assert!(!backlog.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+16
-31
@@ -91,7 +91,6 @@ mod drive;
|
|||||||
use drive::{DriveApi, RevisionRead};
|
use drive::{DriveApi, RevisionRead};
|
||||||
mod events;
|
mod events;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
mod keeper;
|
|
||||||
mod link;
|
mod link;
|
||||||
pub use filesystem::ProtonFs;
|
pub use filesystem::ProtonFs;
|
||||||
mod mount;
|
mod mount;
|
||||||
@@ -590,8 +589,6 @@ struct Core {
|
|||||||
/// Nudges the drain workers: set true and notify to have them re-examine
|
/// Nudges the drain workers: set true and notify to have them re-examine
|
||||||
/// the queue instead of waiting out their backoff.
|
/// the queue instead of waiting out their backoff.
|
||||||
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
||||||
/// The pins the keeper owes a check (`keeper`).
|
|
||||||
pin_work: Arc<Mutex<keeper::PinWork>>,
|
|
||||||
/// The daemon's stop signal, shared with every background loop this `Core`
|
/// The daemon's stop signal, shared with every background loop this `Core`
|
||||||
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
|
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
|
||||||
shutdown: Arc<shutdown::Shutdown>,
|
shutdown: Arc<shutdown::Shutdown>,
|
||||||
@@ -4273,12 +4270,7 @@ impl Core {
|
|||||||
/// recursive pin and downloads every descendant file on a background thread,
|
/// recursive pin and downloads every descendant file on a background thread,
|
||||||
/// reported as a job: a big folder far outlasts the socket read timeout.
|
/// reported as a job: a big folder far outlasts the socket read timeout.
|
||||||
/// Returns a human message.
|
/// Returns a human message.
|
||||||
///
|
fn pin(&self, rel: &Path) -> CoreResult<String> {
|
||||||
/// `recorded` is the path the pin registry keeps for the front ends to
|
|
||||||
/// open the pin by: `rel` itself on the primary mount, which they join to
|
|
||||||
/// the mountpoint, and the absolute local path on an on-demand folder's
|
|
||||||
/// mount, whose node is not reachable under the primary mountpoint at all.
|
|
||||||
fn pin(&self, rel: &Path, recorded: &Path) -> CoreResult<String> {
|
|
||||||
let (ino, uid) = self.resolve(rel)?;
|
let (ino, uid) = self.resolve(rel)?;
|
||||||
let (name, is_folder, mtime, size) = {
|
let (name, is_folder, mtime, size) = {
|
||||||
let st = self.state();
|
let st = self.state();
|
||||||
@@ -4302,7 +4294,7 @@ impl Core {
|
|||||||
// Record the recursive pin first so every descendant is eviction-
|
// Record the recursive pin first so every descendant is eviction-
|
||||||
// exempt before we start filling the cache with the subtree.
|
// exempt before we start filling the cache with the subtree.
|
||||||
self.cache
|
self.cache
|
||||||
.add_pin(&uid, recorded, true)
|
.add_pin(&uid, rel, true)
|
||||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
let folder = name.clone();
|
let folder = name.clone();
|
||||||
@@ -4312,15 +4304,9 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
|
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
|
||||||
return Ok(format!("{name}: downloading its files in the background"));
|
return Ok(format!("{name}: downloading its files in the background"));
|
||||||
}
|
}
|
||||||
// Stored under a provisional size, the copy would never match the
|
|
||||||
// plaintext it holds (`docs/BUGS.md` B282).
|
|
||||||
let size = match self.kept_file(ino) {
|
|
||||||
Some(file) if file.uid == uid => file.size,
|
|
||||||
_ => size,
|
|
||||||
};
|
|
||||||
self.fetch_content(&uid, &name, mtime, size)?;
|
self.fetch_content(&uid, &name, mtime, size)?;
|
||||||
self.cache
|
self.cache
|
||||||
.add_pin(&uid, recorded, false)
|
.add_pin(&uid, rel, false)
|
||||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||||
Ok(name)
|
Ok(name)
|
||||||
}
|
}
|
||||||
@@ -4338,21 +4324,15 @@ impl Core {
|
|||||||
Ok(files) => {
|
Ok(files) => {
|
||||||
job.set_total(files.len() as u64);
|
job.set_total(files.len() as u64);
|
||||||
let mut failed = 0usize;
|
let mut failed = 0usize;
|
||||||
for ino in files {
|
for (uid, file, mtime, size) in files {
|
||||||
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A file with a queued write, or no readable size, is
|
job.detail(file.clone());
|
||||||
// left to the drain and the keeper (`keeper::kept_file`).
|
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
|
||||||
if let Some(file) = self.kept_file(ino) {
|
warn!(%uid, error = %e, "pin folder: download failed");
|
||||||
job.detail(file.name.clone());
|
|
||||||
if let Err(e) =
|
|
||||||
self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size)
|
|
||||||
{
|
|
||||||
warn!(uid = %file.uid, error = %e, "pin folder: download failed");
|
|
||||||
failed += 1;
|
failed += 1;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
job.step();
|
job.step();
|
||||||
}
|
}
|
||||||
info!(folder = %name, failed, "pin folder: done");
|
info!(folder = %name, failed, "pin folder: done");
|
||||||
@@ -4385,9 +4365,9 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The inode of every file in the subtree rooted at folder `ino`.
|
/// Every file in the subtree rooted at folder `ino`.
|
||||||
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<u64>> {
|
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
|
||||||
let mut files: Vec<u64> = Vec::new();
|
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
|
||||||
let mut stack = vec![ino];
|
let mut stack = vec![ino];
|
||||||
while let Some(dir) = stack.pop() {
|
while let Some(dir) = stack.pop() {
|
||||||
if self.shutdown.is_stopping() {
|
if self.shutdown.is_stopping() {
|
||||||
@@ -4402,7 +4382,12 @@ impl Core {
|
|||||||
if e.node.is_folder() {
|
if e.node.is_folder() {
|
||||||
stack.push(k);
|
stack.push(k);
|
||||||
} else {
|
} else {
|
||||||
files.push(k);
|
files.push((
|
||||||
|
e.uid.clone(),
|
||||||
|
e.node.name.clone(),
|
||||||
|
e.node.modification_time,
|
||||||
|
node_size(&e.node),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,9 +286,6 @@ impl Core {
|
|||||||
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();
|
||||||
// Downloads for pins failed while the link was down, and the events
|
|
||||||
// that would have named the files are behind us (`docs/BUGS.md` B282).
|
|
||||||
self.recheck_all_pins();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait up to `budget` for the link to come back. Returns whether it is up.
|
/// Wait up to `budget` for the link to come back. Returns whether it is up.
|
||||||
|
|||||||
@@ -511,7 +511,6 @@ pub(crate) fn mount_with(
|
|||||||
landed: Arc::new(Mutex::new(HashMap::new())),
|
landed: Arc::new(Mutex::new(HashMap::new())),
|
||||||
unadopted: Arc::new(Mutex::new(HashMap::new())),
|
unadopted: Arc::new(Mutex::new(HashMap::new())),
|
||||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
pin_work: Arc::new(Mutex::new(Default::default())),
|
|
||||||
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
||||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||||
@@ -622,17 +621,6 @@ pub(crate) fn mount_with(
|
|||||||
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep pinned files on this computer through changes made elsewhere, and
|
|
||||||
// fetch the ones a failed or missed download left out (`docs/BUGS.md`
|
|
||||||
// B282). Its own thread: a check lists folders and downloads files.
|
|
||||||
{
|
|
||||||
let keeper_core = core.clone();
|
|
||||||
let spawned = std::thread::Builder::new()
|
|
||||||
.name("pdfs-pin-keeper".into())
|
|
||||||
.spawn(move || keeper_core.run_pin_keeper());
|
|
||||||
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||||
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
||||||
if matches!(host, Host::Daemon) {
|
if matches!(host, Host::Daemon) {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ impl Core {
|
|||||||
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
|
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
|
||||||
let metadata = PhotoUploadMetadata {
|
let metadata = PhotoUploadMetadata {
|
||||||
capture_time: Some(candidate.wanted),
|
capture_time: Some(candidate.wanted),
|
||||||
|
last_modification_time: pdfs_core::timestamp::modification_time(candidate.wanted),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let uploaded = self.rt.block_on(photos.upload_photo_from(
|
let uploaded = self.rt.block_on(photos.upload_photo_from(
|
||||||
|
|||||||
@@ -111,11 +111,6 @@ impl Core {
|
|||||||
// stale from here on, and the server may not have applied the swap yet.
|
// stale from here on, and the server may not have applied the swap yet.
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
// A file kept offline gets the restored content back on disk, and
|
|
||||||
// again when the event of the swap arrives (`docs/BUGS.md` B282).
|
|
||||||
if self.cache.is_pinned(uid) {
|
|
||||||
self.recheck_pinned(uid);
|
|
||||||
}
|
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Restore,
|
ActivityKind::Restore,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -3447,118 +3447,6 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
|
||||||
fn pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy() {
|
|
||||||
// Keeping a file offline took only paths under the primary mount, so
|
|
||||||
// the copy in an on-demand synced folder could not be kept, and a
|
|
||||||
// program reading it there downloaded it every time (B281).
|
|
||||||
let drive = FakeDrive::new();
|
|
||||||
let dir = scratch("pin-in-ondemand");
|
|
||||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
|
||||||
let ondemand = dir.join("ondemand");
|
|
||||||
sync_folder(&daemon, &ondemand, "ondemand");
|
|
||||||
|
|
||||||
let content = b"not a real png, but kept all the same".repeat(64);
|
|
||||||
let file = ondemand.join("photo.png");
|
|
||||||
std::fs::write(&file, &content).unwrap();
|
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
|
||||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
|
||||||
}));
|
|
||||||
daemon.request(&Request::PurgeCache).unwrap();
|
|
||||||
|
|
||||||
let path = file.canonicalize().unwrap().to_string_lossy().into_owned();
|
|
||||||
let pinned = daemon
|
|
||||||
.request(&Request::Pin { path: path.clone() })
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
|
|
||||||
let pins = || match daemon.request(&Request::ListPins) {
|
|
||||||
Ok(Response::Pins { pins }) => pins,
|
|
||||||
other => panic!("pins: {other:?}"),
|
|
||||||
};
|
|
||||||
let listed = pins();
|
|
||||||
assert!(
|
|
||||||
listed.iter().any(|pin| pin.path == path && pin.cached),
|
|
||||||
"the pin opens by the path it was made through: {listed:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// With the link gone, only a kept copy can answer the read.
|
|
||||||
daemon.client.set_online(false);
|
|
||||||
assert_eq!(std::fs::read(&file).unwrap(), content);
|
|
||||||
daemon.client.set_online(true);
|
|
||||||
|
|
||||||
let unpinned = daemon
|
|
||||||
.request(&Request::Unpin { path: path.clone() })
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(unpinned, Response::Ok { .. }), "{unpinned:?}");
|
|
||||||
assert!(pins().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_file_kept_offline_stays_on_disk_through_changes_made_elsewhere() {
|
|
||||||
// A change from another device deleted a pinned file's copy and
|
|
||||||
// nothing downloaded it again, and a file added to a pinned folder
|
|
||||||
// elsewhere was never downloaded: both were listed as kept offline
|
|
||||||
// and read only with a network (B282).
|
|
||||||
let drive = FakeDrive::new();
|
|
||||||
let dir = scratch("pin-keeper");
|
|
||||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
|
||||||
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
|
|
||||||
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"first").unwrap();
|
|
||||||
std::fs::write(daemon.mountpoint.join("solo.txt"), b"alone").unwrap();
|
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
|
||||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
|
||||||
&& drive.lookup("Docs/a.txt").is_some()
|
|
||||||
&& drive.lookup("solo.txt").is_some()
|
|
||||||
}));
|
|
||||||
daemon.request(&Request::PurgeCache).unwrap();
|
|
||||||
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
|
|
||||||
path: folder.into(),
|
|
||||||
}) {
|
|
||||||
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
for path in ["Docs", "solo.txt"] {
|
|
||||||
let pinned = daemon.request(&Request::Pin { path: path.into() }).unwrap();
|
|
||||||
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
|
|
||||||
}
|
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
|
||||||
entry("Docs", "a.txt").is_some_and(|e| e.cached)
|
|
||||||
&& entry("", "solo.txt").is_some_and(|e| e.cached)
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Another device edits both files and adds one to the kept folder.
|
|
||||||
drive.device().write("Docs/a.txt", b"second, and longer");
|
|
||||||
drive.device().write("solo.txt", b"alone, edited");
|
|
||||||
drive.device().write("Docs/new.txt", b"made elsewhere");
|
|
||||||
assert!(
|
|
||||||
wait_until(Duration::from_secs(60), || {
|
|
||||||
entry("Docs", "a.txt").is_some_and(|e| e.cached && e.size == 18)
|
|
||||||
&& entry("", "solo.txt").is_some_and(|e| e.cached && e.size == 13)
|
|
||||||
&& entry("Docs", "new.txt").is_some_and(|e| e.cached)
|
|
||||||
}),
|
|
||||||
"the keeper downloaded the new content: {:?} {:?} {:?}",
|
|
||||||
entry("Docs", "a.txt"),
|
|
||||||
entry("", "solo.txt"),
|
|
||||||
entry("Docs", "new.txt"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// With the link gone, only the kept copies can answer.
|
|
||||||
daemon.client.set_online(false);
|
|
||||||
let read = |rel: &str| std::fs::read(daemon.mountpoint.join(rel)).unwrap();
|
|
||||||
assert_eq!(read("Docs/a.txt"), b"second, and longer");
|
|
||||||
assert_eq!(read("solo.txt"), b"alone, edited");
|
|
||||||
assert_eq!(read("Docs/new.txt"), b"made elsewhere");
|
|
||||||
daemon.client.set_online(true);
|
|
||||||
|
|
||||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The synced folder `id` as the daemon lists it.
|
/// The synced folder `id` as the daemon lists it.
|
||||||
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
|
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
|
||||||
match daemon.request(&Request::ListSyncFolders) {
|
match daemon.request(&Request::ListSyncFolders) {
|
||||||
|
|||||||
@@ -344,6 +344,9 @@ impl Core {
|
|||||||
);
|
);
|
||||||
let metadata = PhotoUploadMetadata {
|
let metadata = PhotoUploadMetadata {
|
||||||
capture_time: photo.capture_time,
|
capture_time: photo.capture_time,
|
||||||
|
last_modification_time: photo
|
||||||
|
.capture_time
|
||||||
|
.and_then(pdfs_core::timestamp::modification_time),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// A thumbnail is made from a file, so a photo that can have one
|
// A thumbnail is made from a file, so a photo that can have one
|
||||||
@@ -365,10 +368,13 @@ impl Core {
|
|||||||
let thumbnails = self.upload_thumbnails(path, &photo.name);
|
let thumbnails = self.upload_thumbnails(path, &photo.name);
|
||||||
// No sidecar and no date in the name: the photo's own
|
// No sidecar and no date in the name: the photo's own
|
||||||
// EXIF, before Drive files it under today.
|
// EXIF, before Drive files it under today.
|
||||||
let metadata = PhotoUploadMetadata {
|
let capture_time = metadata
|
||||||
capture_time: metadata
|
|
||||||
.capture_time
|
.capture_time
|
||||||
.or_else(|| pdfs_core::exiftime::capture_time_of(path)),
|
.or_else(|| pdfs_core::exiftime::capture_time_of(path));
|
||||||
|
let metadata = PhotoUploadMetadata {
|
||||||
|
capture_time,
|
||||||
|
last_modification_time: capture_time
|
||||||
|
.and_then(pdfs_core::timestamp::modification_time),
|
||||||
..metadata
|
..metadata
|
||||||
};
|
};
|
||||||
std::fs::File::open(path)
|
std::fs::File::open(path)
|
||||||
|
|||||||
@@ -467,7 +467,6 @@ both with `pdfs_core::search::relevance_score`, so the prompt can merge them int
|
|||||||
| `pdfs-sync`, `pdfs-sync-poll` | Mirror passes and the remote poll (§7) |
|
| `pdfs-sync`, `pdfs-sync-poll` | Mirror passes and the remote poll (§7) |
|
||||||
| Tokio runtime | Remote calls, and the Drive and photos event streams (§6) |
|
| Tokio runtime | Remote calls, and the Drive and photos event streams (§6) |
|
||||||
| `pdfs-online-probe` | Probes Drive while offline |
|
| `pdfs-online-probe` | Probes Drive while offline |
|
||||||
| `pdfs-pin-keeper` | Checks pins once a second for work: every pin at startup, when the link returns and after the event feed loses continuity, and the pins a batch of remote events changed. Downloads files kept offline that are missing or stale; failures retry with a backoff of 30 s to 30 min (`docs/BUGS.md` B282) |
|
|
||||||
| `pdfs-supervisor` | Logs workers that hold one job for minutes, a queue that stops draining, and control requests that never return; samples memory; pings the systemd watchdog only after a round trip over the control socket succeeds (`WatchdogSec=120`) |
|
| `pdfs-supervisor` | Logs workers that hold one job for minutes, a queue that stops draining, and control requests that never return; samples memory; pings the systemd watchdog only after a round trip over the control socket succeeds (`WatchdogSec=120`) |
|
||||||
| `pdfs-conflict-sweep` | Optional. After 30 s, then every 5 minutes, finds `(sync-conflict …)` copies identical (size and SHA-1) to their original. `conflict_sweep` decides: `off`, `report` (default) or `enforce`, which trashes them after re-checking each one |
|
| `pdfs-conflict-sweep` | Optional. After 30 s, then every 5 minutes, finds `(sync-conflict …)` copies identical (size and SHA-1) to their original. `conflict_sweep` decides: `off`, `report` (default) or `enforce`, which trashes them after re-checking each one |
|
||||||
| Smaller helpers | `pdfs-localindex` (home index for search), `pdfs-pause` (ends a timed pause), `pdfs-similar` (similar photos, on request), `pdfs-diagnostics` |
|
| Smaller helpers | `pdfs-localindex` (home index for search), `pdfs-pause` (ends a timed pause), `pdfs-similar` (similar photos, on request), `pdfs-diagnostics` |
|
||||||
|
|||||||
@@ -109,8 +109,6 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
|
|
||||||
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
|
|
||||||
| [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed |
|
| [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed |
|
||||||
| [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back |
|
| [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back |
|
||||||
| [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds |
|
| [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds |
|
||||||
@@ -385,78 +383,6 @@ a live account since.
|
|||||||
|
|
||||||
Newest first.
|
Newest first.
|
||||||
|
|
||||||
### B282 — A file kept offline is downloaded again after a change made elsewhere
|
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
|
||||||
**Found:** 2026-11-13, while looking into B281. Nothing downloaded a pinned file again after the
|
|
||||||
pin had downloaded it once.
|
|
||||||
**Where:** `apply_event` in `crates/pdfs-fuse/src/background.rs`; the new pin keeper in
|
|
||||||
`crates/pdfs-fuse/src/keeper.rs`
|
|
||||||
|
|
||||||
**Repro:** Pin a file, then change it from another device. The event deletes the cached copy.
|
|
||||||
The file stays listed as kept offline, `cached` is false, and the next read downloads it, or
|
|
||||||
fails without a network. Rename or move it elsewhere and the same happens, though its content
|
|
||||||
did not change. Add a file to a pinned folder from another device and it is never downloaded.
|
|
||||||
A folder pin whose downloads failed while the link was down never retries them.
|
|
||||||
|
|
||||||
**Cause:** A remote change to a known node called `ContentCache::evict`, which does not check
|
|
||||||
pins, and only the pin request itself downloaded anything. The copy was also stored under the
|
|
||||||
size the listing held at pin time. That is the ciphertext size until the size upgrade lands
|
|
||||||
(B12), and a copy stored under it never matches the plaintext it holds.
|
|
||||||
|
|
||||||
**Fix:** A remote change no longer evicts a pinned file's copy. The copy is checked against the
|
|
||||||
revision's `(mtime, size)`, so a read of a newer revision does not take it. A rename keeps the
|
|
||||||
revision's mtime (B123), so the copy stays valid. A pin keeper thread checks pins again and
|
|
||||||
downloads files that are missing or stale:
|
|
||||||
|
|
||||||
- the pins a batch of remote events changed, after the batch's listings are dropped;
|
|
||||||
- a pinned folder that gets a new file;
|
|
||||||
- every pin at startup, when the link comes back, and after the event feed loses continuity;
|
|
||||||
- a file whose version is restored.
|
|
||||||
|
|
||||||
Failed downloads are tried again with a backoff from 30 s to 30 min. Pinning settles a
|
|
||||||
provisional size before it stores the copy.
|
|
||||||
|
|
||||||
**Test:** `a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere` in
|
|
||||||
`crates/pdfs-fuse/src/sim/daemon.rs`. Another device edits a pinned file and a file in a pinned
|
|
||||||
folder, and adds a file to that folder. The test waits until all three are cached, then reads
|
|
||||||
them with the link down. With the keeper disabled, it fails with all three uncached.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B281 — A file in a synced folder cannot be kept offline
|
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
|
||||||
**Found:** 2026-11-13. An image was made available offline in the app, and it opened from the app
|
|
||||||
with no download. `feh` opened it from a terminal and downloaded it each time. The two were not
|
|
||||||
the same file. The app had kept `~/ProtonDrive/Pictures/photo.png`, in My files. The
|
|
||||||
terminal opened `~/Pictures/photo.png`, in the on-demand synced folder `~/Pictures`. That
|
|
||||||
copy is a separate node under Computers, uploaded on its own, with the same name and size but a
|
|
||||||
different mtime. The daemon treats every program the same way: it does not look at the process
|
|
||||||
that reads.
|
|
||||||
**Where:** the `Pin` and `Unpin` handlers in `crates/pdfs-fuse/src/control.rs`
|
|
||||||
|
|
||||||
**Repro:** Add a synced folder in on-demand mode and put a file in it. `pdfs pin` on its absolute
|
|
||||||
path fails with "is not under the mountpoint". A relative path is resolved against My files, so
|
|
||||||
it keeps a file of the same name there, if one exists, or fails. Reading the file in the synced
|
|
||||||
folder then downloads it.
|
|
||||||
|
|
||||||
**Cause:** Both handlers turned the path into a mount-relative one with `rel_to_mount`, which only
|
|
||||||
knows the primary mount. B86 moved `ls`, `refresh`, `rm` and `mv` to `route_to_mount`, which finds
|
|
||||||
the mount that serves an absolute path. Pin and unpin were left out.
|
|
||||||
|
|
||||||
**Fix:** Pin and unpin route through `route_to_mount` and run on the mount that owns the path. A
|
|
||||||
path in a mirror folder gets an error that says its files are already on this computer. The pin
|
|
||||||
registry records the absolute local path for a pin made through an on-demand folder, so
|
|
||||||
`pdfs pins`, the prompt and the HUD open the file through the folder it was kept from. Pins
|
|
||||||
under the primary mount still record the path relative to it.
|
|
||||||
|
|
||||||
**Test:** `pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy` in
|
|
||||||
`crates/pdfs-fuse/src/sim/daemon.rs`. It pins a file through an on-demand folder's absolute path,
|
|
||||||
takes the link down, and reads the file through that folder.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
|
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
|
|||||||
+17
-50
@@ -11,60 +11,27 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [3.8.0] — 2026-11-12
|
### Changed
|
||||||
|
|
||||||
Schema: **45**. Synced folders now tell device backups apart from folders attached to My files.
|
- Update `proton-sdk` and `proton-drive-rs` to 0.11.0.
|
||||||
Existing folders stay device backups. Older builds cannot open the upgraded database.
|
Convert epoch timestamps at the SDK boundary and explicitly preserve photo modification
|
||||||
|
times now that they are independent of capture times. See [the SDK review](SDK-0.11.0.md)
|
||||||
|
for features and breaking changes.
|
||||||
|
Schema: **45**. Synced folders now distinguish device backups from My files attachments.
|
||||||
|
Existing folders remain device backups; older builds cannot open the upgraded database.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Sync a local folder with a folder you already have in My files** (#67, #77).
|
- Shell completion for `pdfs` in bash, zsh and fish, installed by every package, plus elvish
|
||||||
`pdfs sync attach ~/Work --to Work` keeps `~/Work` an ordinary local folder and syncs it both
|
and PowerShell through `pdfs completions SHELL`. Besides commands and options it completes
|
||||||
ways with My files/Work. The first sync uploads files that are only on this computer, downloads
|
paths in My files and the ids and uids the running service knows (synced folders, the queue,
|
||||||
files that are only on Proton Drive, and keeps both versions of a file that differs: the one on
|
computers, trash, albums, shares, invitations, bookmarks, conflicts and versions), each with
|
||||||
Proton Drive keeps its name, and the local one becomes a conflict copy next to it and is
|
a description. Share roles are now checked by the CLI (`viewer`, `editor`, `admin`) before
|
||||||
uploaded too. Attached folders are always kept on this computer, never online only. They are
|
the request.
|
||||||
listed apart from device backups and are not part of the device profile used to restore another
|
- `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing
|
||||||
computer. Stopping one keeps both copies, and `--delete-remote` is refused for it. If the folder
|
My files folder. The first sync preserves one-sided files and differing versions (#67).
|
||||||
on Proton Drive is trashed or moved out of My files, syncing stops with an error and the local
|
Attachments stay in mirror mode, are excluded from device profiles, and cannot delete
|
||||||
files stay. Attaching a folder is done from a terminal for now.
|
the remote root when syncing is stopped.
|
||||||
|
|
||||||
- **Tab completion for `pdfs`** in bash, zsh and fish, installed by every package. Besides
|
|
||||||
commands and options it completes paths in My files (`pdfs pin Doc<Tab>`) and what the running
|
|
||||||
service knows, each with a description: synced folders, queued changes, computers, trash, albums,
|
|
||||||
items shared with you, invitations, bookmarks and conflict copies, and once a path is typed, the
|
|
||||||
file's versions or the share's members and link. Without the service it still completes commands
|
|
||||||
and options. Elvish and PowerShell load it from `pdfs completions SHELL`, and so does a source
|
|
||||||
build; the [command-line reference](CLI.md#shell-completion) has the line for each shell.
|
|
||||||
`pdfs share` and `pdfs share-role` now check the role (`viewer`, `editor` or `admin`) before
|
|
||||||
sending the request, and `pdfs public-link` does the same for `viewer` and `editor`.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **My files comes back after the service was killed for hanging** (B279). A killed service left
|
|
||||||
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
|
|
||||||
forever without opening anything, until the mounts were cleared by hand. The service now clears
|
|
||||||
a dead mount before it opens the folder. On a large account a restart that did open My files was
|
|
||||||
killed again, because loading the account took longer than the 2 minutes the service has to
|
|
||||||
report in. Startup now gets 10 minutes, and the 2 minutes apply again once the service answers.
|
|
||||||
|
|
||||||
- **Uploading many large photos no longer runs the computer out of memory** (B280). Every image
|
|
||||||
upload decodes the whole picture to make its thumbnail, and up to 16 did so at once. A batch of
|
|
||||||
100-megapixel photos took about 9 GiB, filled the swap, and the service was killed on every
|
|
||||||
restart. Thumbnails now share 512 MiB between them: phone photos are still made side by side,
|
|
||||||
and a very large photo waits and is made on its own.
|
|
||||||
|
|
||||||
- **Keep a file offline in an on-demand synced folder** (B281). `pdfs pin` and `pdfs unpin` now
|
|
||||||
accept a path in a synced folder such as `~/Pictures`. Before, they only took paths under the
|
|
||||||
Proton Drive mount, so the copy in the synced folder was downloaded again by every program that
|
|
||||||
read it.
|
|
||||||
|
|
||||||
- **Files kept offline stay on this computer when they change elsewhere** (B282). A change from
|
|
||||||
another device, even a rename, deleted the copy of a file kept offline, and nothing downloaded
|
|
||||||
it again, so the next read downloaded it or failed without a network. Files added to a folder
|
|
||||||
kept offline from another device were never downloaded. The copy now stays until the new
|
|
||||||
version is downloaded, which happens on its own. Files kept offline are also checked at
|
|
||||||
startup and when the connection returns, and failed downloads are tried again.
|
|
||||||
|
|
||||||
## [3.7.0] — 2026-11-02
|
## [3.7.0] — 2026-11-02
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). PATH can be under the Proton Drive mount or in an on-demand synced folder. A file in a mirror folder is already on this computer. |
|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
|
||||||
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
|
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
|
||||||
| `pdfs pins` | List files and folders kept available offline. |
|
| `pdfs pins` | List files and folders kept available offline. |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ That URL follows `main`. To stay on releases, pin the input to a release tag ins
|
|||||||
the tag to update:
|
the tag to update:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.8.0";
|
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.7.0";
|
||||||
```
|
```
|
||||||
|
|
||||||
Each release tag is built with Nix before the release is published.
|
Each release tag is built with Nix before the release is published.
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# SDK 0.11.0 review
|
||||||
|
|
||||||
|
Reviewed the sibling `../proton-sdk-rs` checkout at `84039a1` against 0.10.1
|
||||||
|
(`48dd410`), including its changelog and source diff.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Folder creation, streaming uploads, draft replacement, and revision uploads
|
||||||
|
preserve the supplied timestamp's UTC offset and fractional seconds.
|
||||||
|
- `ModificationTime` is exported as an alias for
|
||||||
|
`chrono::DateTime<chrono::FixedOffset>`.
|
||||||
|
- Photo modification time can be set independently of capture time. Downloaded
|
||||||
|
claimed modification timestamps remain verbatim strings.
|
||||||
|
- Neither SDK crate defines Cargo feature flags. No feature opt-in is needed.
|
||||||
|
The Drive crate now depends directly on `chrono` 0.4; the SDK already used it.
|
||||||
|
The minimum Rust version remains 1.96, matching this workspace.
|
||||||
|
|
||||||
|
## Breaking changes and adaptation
|
||||||
|
|
||||||
|
The modification-time argument changes from `Option<i64>` to
|
||||||
|
`Option<ModificationTime>` on `create_folder`, `upload_file_from`,
|
||||||
|
`upload_file_replacing_draft_from`, `upload_new_revision`, and
|
||||||
|
`upload_new_revision_from`. `None` remains valid.
|
||||||
|
|
||||||
|
The real `DriveApi` adapter converts the client's stored epoch seconds to UTC
|
||||||
|
through `pdfs_core::timestamp::modification_time`. Internal storage, the trait,
|
||||||
|
and the simulated Drive continue using epoch seconds. Out-of-range timestamps
|
||||||
|
are omitted rather than panicking. Existing upload paths still supply whole
|
||||||
|
seconds; this bump alone does not add subsecond storage or offset tracking.
|
||||||
|
|
||||||
|
`PhotoUploadMetadata` gains `last_modification_time`, and the SDK no longer
|
||||||
|
copies `capture_time` into it. Our literals already use `Default`, so this would
|
||||||
|
compile while silently changing uploaded metadata. Control uploads, Takeout
|
||||||
|
imports (including the EXIF fallback), and redating now set both explicitly to
|
||||||
|
preserve the previous behavior when a capture time is known. Without a known
|
||||||
|
capture time, modification time stays omitted as before.
|
||||||
|
|
||||||
|
No other public API changes were found in the reviewed diff. No database or
|
||||||
|
recovery migration is required by this SDK bump.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
The workspace requires the published 0.11.0 versions of both crates from
|
||||||
|
crates.io. No local patches or sibling checkout are needed to build it.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The published crates' source files match the reviewed sibling checkout at
|
||||||
|
`84039a1`. On the final branch based on `main`, these checks passed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test --workspace --locked
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
The test suite reported 1,135 passed and 67 ignored; ignored tests were not run.
|
||||||
|
Tests ran outside the sandbox because control-socket tests require socket
|
||||||
|
permissions. No live account or mounted-filesystem acceptance testing was
|
||||||
|
performed.
|
||||||
+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.8.0
|
pkgver=3.7.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
||||||
%global _lto_cflags %{nil}
|
%global _lto_cflags %{nil}
|
||||||
|
|
||||||
%{!?version: %global version 3.8.0}
|
%{!?version: %global version 3.7.0}
|
||||||
%global forge https://github.com/narrrl/proton-drive-linux
|
%global forge https://github.com/narrrl/proton-drive-linux
|
||||||
# Files come from the checkout, or from the unpacked tag archive.
|
# Files come from the checkout, or from the unpacked tag archive.
|
||||||
%global src %{?git_dir}%{!?git_dir:.}
|
%global src %{?git_dir}%{!?git_dir:.}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: proton-drive-linux 3.8.0\n"
|
"Project-Id-Version: proton-drive-linux 3.7.0\n"
|
||||||
"Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n"
|
"Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n"
|
||||||
"POT-Creation-Date: 2026-11-01 17:51+0100\n"
|
"POT-Creation-Date: 2026-11-01 17:51+0100\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ SECTIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Too long or too internal for the wiki; links to them go to GitHub.
|
# Too long or too internal for the wiki; links to them go to GitHub.
|
||||||
EXCLUDED = {"BUGS.md", "CHANGELOG.md"}
|
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "SDK-0.11.0.md"}
|
||||||
|
|
||||||
# Files outside docs/ whose links are checked but not published.
|
# Files outside docs/ whose links are checked but not published.
|
||||||
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]
|
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]
|
||||||
|
|||||||
Reference in new issue
Block a user