mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Merge remote-tracking branch 'origin/main' into pr32-merge
# Conflicts: # docs/CHANGELOG.md # po/de.po # po/en_GB.po # po/es.po # po/fr.po # po/it.po # po/nl.po # po/pdfs.pot # po/pl.po # po/pt_BR.po
This commit is contained in:
commit
7fd3439b60
55 files changed
+6213
-4610
No files matched your search
Generated
+4
-4
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "3.1.2"
|
version = "3.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3490,7 +3490,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "3.1.2"
|
version = "3.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3516,7 +3516,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "3.1.2"
|
version = "3.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "3.1.2"
|
version = "3.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|||||||
+1
-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.1.2"
|
version = "3.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
|
|||||||
@@ -628,6 +628,15 @@ enum DeviceCmd {
|
|||||||
#[arg(long, conflicts_with = "uid")]
|
#[arg(long, conflicts_with = "uid")]
|
||||||
clear: bool,
|
clear: bool,
|
||||||
},
|
},
|
||||||
|
/// Move another computer's folders into this computer's backup and sync
|
||||||
|
/// them here. The other computer stays registered, without those folders.
|
||||||
|
Migrate {
|
||||||
|
/// Device uid (from `devices list`).
|
||||||
|
uid: String,
|
||||||
|
/// Skip the confirmation and accept every proposed local path.
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -967,6 +976,24 @@ 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::Migrate { uid, yes } => {
|
||||||
|
if !yes {
|
||||||
|
println!(
|
||||||
|
"Every folder that device backs up moves into this computer's backup.\n\
|
||||||
|
The device stays registered, without those folders. Stop Proton Drive\n\
|
||||||
|
on that computer first, or it sees its folders disappear."
|
||||||
|
);
|
||||||
|
confirm("Move them here?")?;
|
||||||
|
}
|
||||||
|
let response = control_request(CtlRequest::MigrateDevice { uid })?;
|
||||||
|
// A script gets the one reply and runs `sync restore` itself.
|
||||||
|
if emit_json(&response)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ok_or_bail(response)?;
|
||||||
|
// The folders are this device's now, so the plain restore offers them.
|
||||||
|
cmd_sync_restore(yes, None)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2309,42 +2309,26 @@ mod tests {
|
|||||||
/// call also needs.
|
/// call also needs.
|
||||||
#[test]
|
#[test]
|
||||||
fn budget_check_is_free_when_under_budget() {
|
fn budget_check_is_free_when_under_budget() {
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
// Cap far above what we store, so every store stays under budget.
|
// Cap far above what we store, so every store stays under budget.
|
||||||
let (c, _d) = cache_capped(1 << 30);
|
let (c, _d) = cache_capped(1 << 30);
|
||||||
let payload = vec![0u8; 4096];
|
let payload = vec![0u8; 4096];
|
||||||
|
for i in 0..8u64 {
|
||||||
// Cost of a store against a near-empty index...
|
|
||||||
let t = Instant::now();
|
|
||||||
for i in 0..500u64 {
|
|
||||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
let empty = t.elapsed() / 500;
|
|
||||||
|
|
||||||
// ...and against one with a few thousand entries in it.
|
// Plant an index row far over the cap behind the running total's back,
|
||||||
for i in 500..2500u64 {
|
// and older than every real block. Any budget check that consults the
|
||||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
// index would see the pool over budget, evict, and reseed the total.
|
||||||
.unwrap();
|
c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).unwrap();
|
||||||
}
|
|
||||||
let t = Instant::now();
|
|
||||||
for i in 2500..3000u64 {
|
|
||||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let full = t.elapsed() / 500;
|
|
||||||
|
|
||||||
println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500");
|
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
|
||||||
// A ratio, not a wall-clock bound: the claim is that the under-budget
|
.unwrap();
|
||||||
// path does not grow with the index, and only a ratio tests that. An
|
|
||||||
// absolute threshold measures the machine's load instead, and this test
|
// Not a timing ratio: that measured the CI runner's load and flaked.
|
||||||
// runs alongside the rest of the suite.
|
assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
|
||||||
assert!(
|
let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
|
||||||
full < empty * 3,
|
assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
|
||||||
"under-budget store should not scale with the index: \
|
|
||||||
{empty:?} at ~0 entries vs {full:?} at 2500"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The running totals are a fast path, not a second source of truth: after
|
/// The running totals are a fast path, not a second source of truth: after
|
||||||
|
|||||||
@@ -446,6 +446,13 @@ pub enum Request {
|
|||||||
/// of registering a duplicate. `uid: None` clears the pin.
|
/// of registering a duplicate. `uid: None` clears the pin.
|
||||||
/// Replies with [`Response::Ok`].
|
/// Replies with [`Response::Ok`].
|
||||||
AdoptDevice { uid: Option<String> },
|
AdoptDevice { uid: Option<String> },
|
||||||
|
/// Move every folder another device backs up into this machine's own
|
||||||
|
/// device, as server-side moves on the main volume. The other device stays
|
||||||
|
/// registered, without those folders. Sync folders here that already track
|
||||||
|
/// one of them follow it to its new share. Nothing is downloaded: the moved
|
||||||
|
/// folders are then offered by [`Request::ListRestorableFolders`].
|
||||||
|
/// Replies with [`Response::Ok`].
|
||||||
|
MigrateDevice { uid: String },
|
||||||
|
|
||||||
// ---- device folder sync ----------------------------------
|
// ---- device folder sync ----------------------------------
|
||||||
/// Add a local folder to this machine's device, uploading its tree and
|
/// Add a local folder to this machine's device, uploading its tree and
|
||||||
@@ -1218,6 +1225,12 @@ pub struct JobItem {
|
|||||||
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
|
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
|
||||||
/// May *grow* mid-job as more work is discovered.
|
/// May *grow* mid-job as more work is discovered.
|
||||||
pub total: u64,
|
pub total: u64,
|
||||||
|
/// Housekeeping that changes nothing in the user's files: a mirror folder
|
||||||
|
/// scan that finds nothing to do yet, an index rebuild, a cache check. Front
|
||||||
|
/// ends may show it, but should not report the drive as busy over it. Absent
|
||||||
|
/// from older daemons, which never set it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub background: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
|
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
|
||||||
@@ -3082,6 +3095,9 @@ mod tests {
|
|||||||
uid: Some("dev-1".into()),
|
uid: Some("dev-1".into()),
|
||||||
},
|
},
|
||||||
Request::AdoptDevice { uid: None },
|
Request::AdoptDevice { uid: None },
|
||||||
|
Request::MigrateDevice {
|
||||||
|
uid: "dev-2".into(),
|
||||||
|
},
|
||||||
Request::ListRestorableFolders,
|
Request::ListRestorableFolders,
|
||||||
Request::RestoreSyncFolders {
|
Request::RestoreSyncFolders {
|
||||||
items: vec![RestoreItem {
|
items: vec![RestoreItem {
|
||||||
|
|||||||
@@ -139,6 +139,17 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-point a synced folder at the share that now holds its remote folder,
|
||||||
|
/// after the folder moved to another device without changing its uid.
|
||||||
|
pub fn sync_folder_set_share(&self, id: i64, share_id: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sync_folder SET remote_share_id = ?2 WHERE id = ?1",
|
||||||
|
params![id, share_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
|
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
|
||||||
/// is safe to switch.
|
/// is safe to switch.
|
||||||
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
|
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
|
||||||
|
|||||||
@@ -4940,6 +4940,28 @@ fn pending_mode_is_queued_until_the_mode_is_reached() {
|
|||||||
assert_eq!(folder.pending_mode, None);
|
assert_eq!(folder.pending_mode, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moved_sync_folder_points_at_its_new_share() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let id = db
|
||||||
|
.sync_folder_add("/tmp/pdfs-moved-share", "vol~folder", "windows-share")
|
||||||
|
.unwrap();
|
||||||
|
let other = db
|
||||||
|
.sync_folder_add("/tmp/pdfs-unmoved-share", "vol~other", "windows-share")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
db.sync_folder_set_share(id, "linux-share").unwrap();
|
||||||
|
|
||||||
|
let folder = db.sync_folder_get(id).unwrap().unwrap();
|
||||||
|
assert_eq!(folder.remote_share_id, "linux-share");
|
||||||
|
// A move keeps the uid, so nothing but the share changes.
|
||||||
|
assert_eq!(folder.remote_uid, "vol~folder");
|
||||||
|
assert_eq!(
|
||||||
|
db.sync_folder_get(other).unwrap().unwrap().remote_share_id,
|
||||||
|
"windows-share"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_commit_clears_only_the_intent_it_satisfies() {
|
fn mode_commit_clears_only_the_intent_it_satisfies() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
|||||||
@@ -710,7 +710,7 @@ fn scan_local_once(
|
|||||||
|
|
||||||
// The walk has no idea how many files it will find, so the job counts what it
|
// The walk has no idea how many files it will find, so the job counts what it
|
||||||
// has seen and stays indeterminate.
|
// has seen and stays indeterminate.
|
||||||
let job = transfers.begin_job("Indexing this computer");
|
let job = transfers.begin_background_job("Indexing this computer");
|
||||||
job.detail("Scanning your files");
|
job.detail("Scanning your files");
|
||||||
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) {
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
|||||||
RefreshScope::Photos { .. } => &[],
|
RefreshScope::Photos { .. } => &[],
|
||||||
},
|
},
|
||||||
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||||
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations],
|
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||||
R::AddSyncFolder { .. }
|
R::AddSyncFolder { .. }
|
||||||
| R::RemoveSyncFolder { .. }
|
| R::RemoveSyncFolder { .. }
|
||||||
| R::SetSyncFolderMode { .. }
|
| R::SetSyncFolderMode { .. }
|
||||||
@@ -1297,6 +1297,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(message) => CtlResponse::Ok { message },
|
Ok(message) => CtlResponse::Ok { message },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::MigrateDevice { uid }) => match core.migrate_device(&uid) {
|
||||||
|
Ok(message) => CtlResponse::Ok { message },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::AddSyncFolder { local_path }) => {
|
Ok(CtlRequest::AddSyncFolder { local_path }) => {
|
||||||
// Registering the device and uploading a folder tree far outlasts the
|
// Registering the device and uploading a folder tree far outlasts the
|
||||||
// socket read timeout, so ack immediately and work on a background
|
// socket read timeout, so ack immediately and work on a background
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use pdfs_core::control::{
|
|||||||
};
|
};
|
||||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||||
use pdfs_core::mounts::MountMode;
|
use pdfs_core::mounts::MountMode;
|
||||||
|
use pdfs_core::profile::PROFILE_DIR_NAME;
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
||||||
use proton_drive_rs::{DeviceType, Node};
|
use proton_drive_rs::{DeviceType, Node};
|
||||||
@@ -114,6 +115,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
|||||||
.map_err(|e| CoreError::internal(format!("write config: {e}")))
|
.map_err(|e| CoreError::internal(format!("write config: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name a folder moved in from `device` takes when `name` is already
|
||||||
|
/// taken under this device's root, or `None` when it can keep its own.
|
||||||
|
fn migrated_name(name: &str, device: &str, taken: &[String]) -> Option<String> {
|
||||||
|
let free = |candidate: &str| !taken.iter().any(|t| t == candidate);
|
||||||
|
if free(name) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base = format!("{name} ({device})");
|
||||||
|
if free(&base) {
|
||||||
|
return Some(base);
|
||||||
|
}
|
||||||
|
(2..)
|
||||||
|
.map(|n| format!("{base} {n}"))
|
||||||
|
.find(|candidate| free(candidate))
|
||||||
|
}
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
// ---- devices ----------------------------------------------------------
|
// ---- devices ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -260,6 +277,107 @@ impl Core {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Move every folder another device backs up into this machine's own
|
||||||
|
/// device, so this machine carries on backing them up as itself. The other
|
||||||
|
/// device stays registered, without those folders.
|
||||||
|
///
|
||||||
|
/// Device roots all live on the main volume, so each folder is one
|
||||||
|
/// server-side move: nothing is downloaded or uploaded, and no quota is
|
||||||
|
/// used twice. A folder whose name is taken here keeps its content under
|
||||||
|
/// "<name> (<device>)". A failed folder does not stop the others, and a
|
||||||
|
/// second run picks up whatever is still left on the other device.
|
||||||
|
///
|
||||||
|
/// Nothing is synced to this disk here. The moved folders are now this
|
||||||
|
/// device's, so the ordinary restore offers them. Only folders that were
|
||||||
|
/// already synced here from the other device are touched: their uid
|
||||||
|
/// survives the move but their share does not.
|
||||||
|
pub(crate) fn migrate_device(&self, uid: &str) -> CoreResult<String> {
|
||||||
|
let source = self.restore_source(Some(uid))?;
|
||||||
|
let target = self.ensure_device()?;
|
||||||
|
if source.uid == target.uid {
|
||||||
|
return Err(CoreError::invalid(format!(
|
||||||
|
"{} is this computer's own device",
|
||||||
|
source.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let bad_root = |d: &StoredDevice| {
|
||||||
|
parse_uid(&d.root_uid)
|
||||||
|
.ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", d.root_uid)))
|
||||||
|
};
|
||||||
|
let (source_root, target_root) = (bad_root(&source)?, bad_root(&target)?);
|
||||||
|
if source_root.volume_id != target_root.volume_id {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"the two devices are on different Proton Drive volumes; copy it instead",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut taken: Vec<String> = self
|
||||||
|
.device_root_nodes(&target_root)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| !n.trashed)
|
||||||
|
.map(|n| n.name)
|
||||||
|
.collect();
|
||||||
|
let synced = self
|
||||||
|
.db
|
||||||
|
.sync_folder_list()
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
|
||||||
|
let mut moved = 0usize;
|
||||||
|
let mut renamed: Vec<String> = Vec::new();
|
||||||
|
let mut left: Vec<String> = Vec::new();
|
||||||
|
let mut failed: Vec<String> = Vec::new();
|
||||||
|
for node in self.device_root_nodes(&source_root)? {
|
||||||
|
if node.trashed || node.name == PROFILE_DIR_NAME {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A device root only ever holds folders (B68); anything else was
|
||||||
|
// put there by hand and is left for the user to deal with.
|
||||||
|
if !node.is_folder() {
|
||||||
|
left.push(node.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let new_name = migrated_name(&node.name, &source.name, &taken);
|
||||||
|
if let Err(e) = self.move_rename_remote(&node.uid, &target_root, new_name.as_deref()) {
|
||||||
|
warn!(name = %node.name, error = %e, "migrate: could not move folder");
|
||||||
|
failed.push(format!("{}: {e}", node.name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let landed = new_name.clone().unwrap_or_else(|| node.name.clone());
|
||||||
|
if new_name.is_some() {
|
||||||
|
renamed.push(format!("{} -> {landed}", node.name));
|
||||||
|
}
|
||||||
|
taken.push(landed);
|
||||||
|
moved += 1;
|
||||||
|
|
||||||
|
let uid = node.uid.to_string();
|
||||||
|
for folder in synced.iter().filter(|f| f.remote_uid == uid) {
|
||||||
|
if let Err(e) = self.db.sync_folder_set_share(folder.id, &target.share_id) {
|
||||||
|
warn!(id = folder.id, error = ?e, "migrate: could not re-point sync folder");
|
||||||
|
}
|
||||||
|
self.sync_now(Some(folder.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.invalidate_children_of(&source_root);
|
||||||
|
self.invalidate_children_of(&target_root);
|
||||||
|
|
||||||
|
let mut message = format!("moved {moved} folder(s) from {}", source.name);
|
||||||
|
if !renamed.is_empty() {
|
||||||
|
message.push_str(&format!("; renamed: {}", renamed.join(", ")));
|
||||||
|
}
|
||||||
|
if !left.is_empty() {
|
||||||
|
message.push_str(&format!("; left behind: {}", left.join(", ")));
|
||||||
|
}
|
||||||
|
if !failed.is_empty() {
|
||||||
|
message.push_str(&format!("; failed: {}", failed.join(", ")));
|
||||||
|
}
|
||||||
|
info!(from = %source.name, to = %target.name, moved, failed = failed.len(),
|
||||||
|
"device migrated");
|
||||||
|
if moved == 0 && !failed.is_empty() {
|
||||||
|
return Err(CoreError::conflict(message));
|
||||||
|
}
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- device folder sync -------------------------
|
// ---- device folder sync -------------------------
|
||||||
|
|
||||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||||
@@ -360,21 +478,25 @@ impl Core {
|
|||||||
root_uid: &NodeUid,
|
root_uid: &NodeUid,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> CoreResult<Option<NodeUid>> {
|
) -> CoreResult<Option<NodeUid>> {
|
||||||
|
Ok(self
|
||||||
|
.device_root_nodes(root_uid)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
||||||
|
.map(|n| n.uid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything directly under a device root, trashed nodes included.
|
||||||
|
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(None);
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let nodes = self
|
self.rt
|
||||||
.rt
|
|
||||||
.block_on(self.drive.enumerate_nodes(&uids))
|
.block_on(self.drive.enumerate_nodes(&uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))
|
||||||
Ok(nodes
|
|
||||||
.into_iter()
|
|
||||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
|
||||||
.map(|n| n.uid))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a local folder to this device's sync set: register the device if
|
/// Add a local folder to this device's sync set: register the device if
|
||||||
@@ -535,12 +657,16 @@ impl Core {
|
|||||||
} else {
|
} else {
|
||||||
p.total.max(p.done) as u64
|
p.total.max(p.done) as u64
|
||||||
},
|
},
|
||||||
|
// Most passes find nothing; only one that queues work is
|
||||||
|
// the drive syncing.
|
||||||
|
background: true,
|
||||||
},
|
},
|
||||||
SyncPhase::Applying => JobItem {
|
SyncPhase::Applying => JobItem {
|
||||||
title: format!("Syncing {folder}"),
|
title: format!("Syncing {folder}"),
|
||||||
detail: p.current.clone(),
|
detail: p.current.clone(),
|
||||||
done: p.done as u64,
|
done: p.done as u64,
|
||||||
total: p.total.max(p.done) as u64,
|
total: p.total.max(p.done) as u64,
|
||||||
|
background: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -842,6 +968,23 @@ impl Core {
|
|||||||
if folder.state != "idle" {
|
if folder.state != "idle" {
|
||||||
return Err(SwitchBlocked::NotNow);
|
return Err(SwitchBlocked::NotNow);
|
||||||
}
|
}
|
||||||
|
// `idle` only says the last pass applied everything it tried.
|
||||||
|
// It never tries an ignored path, a symlink or a folder it
|
||||||
|
// cannot read, it defers a file open for writing, and a file can
|
||||||
|
// change after it ends. The eviction below deletes all of those
|
||||||
|
// for good, so it waits until nothing here is missing from Drive
|
||||||
|
// (B198).
|
||||||
|
let baseline = self
|
||||||
|
.db
|
||||||
|
.sync_entries(id)
|
||||||
|
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
|
||||||
|
if let Some(reason) =
|
||||||
|
sync::mirror_tree_unsynced(&local, &baseline, &self.ignore_rules(&local))
|
||||||
|
{
|
||||||
|
return Err(SwitchBlocked::Failed(format!(
|
||||||
|
"going online-only would delete what is only on this computer: {reason}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
||||||
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
||||||
})?;
|
})?;
|
||||||
@@ -1107,10 +1250,38 @@ mod tests {
|
|||||||
use pdfs_core::db::StoredSyncFolder;
|
use pdfs_core::db::StoredSyncFolder;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
|
ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches,
|
||||||
restore_snapshot_matches, select_mode_transition,
|
restore_snapshot_matches, select_mode_transition,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrated_folder_keeps_its_name_when_it_is_free() {
|
||||||
|
let taken = vec!["Pictures".to_string()];
|
||||||
|
assert_eq!(migrated_name("Documents", "DESKTOP-1", &taken), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrated_folder_is_named_after_its_device_on_a_clash() {
|
||||||
|
let taken = vec!["Documents".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
|
||||||
|
Some("Documents (DESKTOP-1)")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrated_folder_is_numbered_when_the_device_name_is_taken_too() {
|
||||||
|
let taken = vec![
|
||||||
|
"Documents".to_string(),
|
||||||
|
"Documents (DESKTOP-1)".to_string(),
|
||||||
|
"Documents (DESKTOP-1) 2".to_string(),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
|
||||||
|
Some("Documents (DESKTOP-1) 3")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_mode_cannot_select_a_destructive_transition() {
|
fn unknown_mode_cannot_select_a_destructive_transition() {
|
||||||
let mut unmounts = 0;
|
let mut unmounts = 0;
|
||||||
|
|||||||
@@ -744,6 +744,9 @@ struct Core {
|
|||||||
/// uploading that same tree — the engine would upload files as they vanish
|
/// uploading that same tree — the engine would upload files as they vanish
|
||||||
/// and then walk the FUSE mount as if it were local.
|
/// and then walk the FUSE mount as if it were local.
|
||||||
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
|
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
|
||||||
|
/// Per-sync-folder paths the last pass could not read, so the activity feed
|
||||||
|
/// says so when the set changes rather than on every poll (B196).
|
||||||
|
sync_unscanned: Arc<Mutex<HashMap<i64, Vec<String>>>>,
|
||||||
/// Nodes this daemon changed on the remote itself, and how many echoes of
|
/// Nodes this daemon changed on the remote itself, and how many echoes of
|
||||||
/// those changes the event feed still owes us.
|
/// those changes the event feed still owes us.
|
||||||
///
|
///
|
||||||
@@ -1031,7 +1034,9 @@ impl Core {
|
|||||||
|
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let job = core.transfers.begin_job("Checking the cache database");
|
let job = core
|
||||||
|
.transfers
|
||||||
|
.begin_background_job("Checking the cache database");
|
||||||
job.detail("Verifying every page");
|
job.detail("Verifying every page");
|
||||||
let problems = match core.db.integrity_check() {
|
let problems = match core.db.integrity_check() {
|
||||||
Ok(problems) => problems,
|
Ok(problems) => problems,
|
||||||
|
|||||||
@@ -475,6 +475,7 @@ pub(crate) fn mount_with(
|
|||||||
sync_tx,
|
sync_tx,
|
||||||
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
|
||||||
states: Arc::new(StateRegistry::default()),
|
states: Arc::new(StateRegistry::default()),
|
||||||
};
|
};
|
||||||
// Before anything can queue work against it: the drain thread below reaches
|
// Before anything can queue work against it: the drain thread below reaches
|
||||||
|
|||||||
@@ -660,6 +660,12 @@ impl Core {
|
|||||||
/// the shared uids; the per-node detail is then gathered best-effort — a single
|
/// the shared uids; the per-node detail is then gathered best-effort — a single
|
||||||
/// node racing with an unshare drops from the list rather than failing the whole
|
/// node racing with an unshare drops from the list rather than failing the whole
|
||||||
/// request.
|
/// request.
|
||||||
|
///
|
||||||
|
/// A share with no member, no invitation and no link is left out: it is
|
||||||
|
/// shared with no one. Stop Sharing leaves exactly that behind, since the SDK
|
||||||
|
/// cannot delete a share, and Drive keeps listing it until its backend gets
|
||||||
|
/// round to removing it, minutes later. A share whose details could not all
|
||||||
|
/// be read stays listed, so a failed lookup never hides a live share.
|
||||||
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
|
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
@@ -678,24 +684,26 @@ impl Core {
|
|||||||
let members = self
|
let members = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_members(&uid))
|
.block_on(self.client.list_share_members(&uid))
|
||||||
.map(|m| m.len())
|
.map(|m| m.len());
|
||||||
.unwrap_or(0);
|
|
||||||
let proton_invites = self
|
let proton_invites = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_invitations(&uid))
|
.block_on(self.client.list_share_invitations(&uid))
|
||||||
.map(|i| i.len())
|
.map(|i| i.len());
|
||||||
.unwrap_or(0);
|
|
||||||
let external_invites = self
|
let external_invites = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_external_invitations(&uid))
|
.block_on(self.client.list_external_invitations(&uid))
|
||||||
.map(|i| i.len())
|
.map(|i| i.len());
|
||||||
.unwrap_or(0);
|
let link = self.rt.block_on(self.client.get_public_link(&uid));
|
||||||
let link = self
|
if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
|
||||||
.rt
|
(&members, &proton_invites, &external_invites, &link)
|
||||||
.block_on(self.client.get_public_link(&uid))
|
{
|
||||||
.ok()
|
debug!(%uid, "shared by me: skipping an emptied share");
|
||||||
.flatten()
|
continue;
|
||||||
.map(public_link_info);
|
}
|
||||||
|
let members = members.unwrap_or(0);
|
||||||
|
let proton_invites = proton_invites.unwrap_or(0);
|
||||||
|
let external_invites = external_invites.unwrap_or(0);
|
||||||
|
let link = link.ok().flatten().map(public_link_info);
|
||||||
items.push(SharedItem {
|
items.push(SharedItem {
|
||||||
uid: uid.to_string(),
|
uid: uid.to_string(),
|
||||||
is_dir: n.is_folder(),
|
is_dir: n.is_folder(),
|
||||||
|
|||||||
+569
-100
@@ -50,7 +50,7 @@ mod planner;
|
|||||||
use engine::SYNC_CONCURRENCY;
|
use engine::SYNC_CONCURRENCY;
|
||||||
pub(crate) use engine::{SyncMsg, spawn};
|
pub(crate) use engine::{SyncMsg, spawn};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use engine::{classify, settle_with};
|
use engine::{classify, event_target, settle_with};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use local_scan::is_write_mode;
|
use local_scan::is_write_mode;
|
||||||
use local_scan::open_for_write_set;
|
use local_scan::open_for_write_set;
|
||||||
@@ -59,8 +59,9 @@ pub(crate) use planner::base_name;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use planner::conflict_path;
|
use planner::conflict_path;
|
||||||
use planner::{
|
use planner::{
|
||||||
FilePlan, classification_order, conflict_path_with_suffix, filter_baseline, guard_local_wipe,
|
FilePlan, Unscanned, classification_order, conflict_path_with_suffix, filter_baseline,
|
||||||
join_rel, parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size,
|
guard_local_wipe, join_rel, local_deletions, parent_rel, plan_file, rel_to_path, remote_sig,
|
||||||
|
tracked_baseline, unchanged_remote_size,
|
||||||
};
|
};
|
||||||
/// What a [`Pending`] op did, so the reconcile can update shared state on the
|
/// What a [`Pending`] op did, so the reconcile can update shared state on the
|
||||||
/// engine thread (never inside a task).
|
/// engine thread (never inside a task).
|
||||||
@@ -187,6 +188,7 @@ impl Core {
|
|||||||
"idle"
|
"idle"
|
||||||
};
|
};
|
||||||
let _ = self.db.sync_folder_set_state(folder.id, state, now_secs());
|
let _ = self.db.sync_folder_set_state(folder.id, state, now_secs());
|
||||||
|
self.report_unscanned(folder.id, name, &outcome.unscanned);
|
||||||
// Summarise a pass that actually moved something. A poll over an
|
// Summarise a pass that actually moved something. A poll over an
|
||||||
// unchanged folder does nothing and says nothing — otherwise the
|
// unchanged folder does nothing and says nothing — otherwise the
|
||||||
// feed would fill with "synced, 0 changes" every two minutes.
|
// feed would fill with "synced, 0 changes" every two minutes.
|
||||||
@@ -243,6 +245,37 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tell the activity feed what a pass over this folder had to leave out
|
||||||
|
/// because it cannot be read, once each time that changes. Every other path
|
||||||
|
/// in the folder still synced, so this does not make the folder's state an
|
||||||
|
/// error; but a folder the user has no permission for is never going to
|
||||||
|
/// reach Drive, and they should hear that once rather than not at all
|
||||||
|
/// (B196).
|
||||||
|
fn report_unscanned(&self, folder_id: i64, name: &str, unscanned: &[String]) {
|
||||||
|
let previous = {
|
||||||
|
let mut last = self.sync_unscanned.lock();
|
||||||
|
if unscanned.is_empty() {
|
||||||
|
last.remove(&folder_id)
|
||||||
|
} else {
|
||||||
|
last.insert(folder_id, unscanned.to_vec())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if unscanned.is_empty() || previous.as_deref() == Some(unscanned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const SHOWN: usize = 3;
|
||||||
|
let mut detail = unscanned[..unscanned.len().min(SHOWN)].join(", ");
|
||||||
|
if unscanned.len() > SHOWN {
|
||||||
|
detail.push_str(&format!(" and {} more", unscanned.len() - SHOWN));
|
||||||
|
}
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::Sync,
|
||||||
|
format!("{} item(s) in {name} cannot be synced", unscanned.len()),
|
||||||
|
detail,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Compile the ignore rules for a synced folder: the global patterns from
|
/// Compile the ignore rules for a synced folder: the global patterns from
|
||||||
/// the config, plus any `.pdfsignore` at `local_root`.
|
/// the config, plus any `.pdfsignore` at `local_root`.
|
||||||
///
|
///
|
||||||
@@ -250,7 +283,7 @@ impl Core {
|
|||||||
/// than to "ignore nothing" — the defaults exclude build and VCS trees, and
|
/// than to "ignore nothing" — the defaults exclude build and VCS trees, and
|
||||||
/// silently uploading those because a config read failed is the outcome this
|
/// silently uploading those because a config read failed is the outcome this
|
||||||
/// feature exists to prevent.
|
/// feature exists to prevent.
|
||||||
fn ignore_rules(&self, local_root: &Path) -> IgnoreRules {
|
pub(crate) fn ignore_rules(&self, local_root: &Path) -> IgnoreRules {
|
||||||
let globals = match pdfs_core::config::AppDirs::new() {
|
let globals = match pdfs_core::config::AppDirs::new() {
|
||||||
Ok(dirs) => dirs.load_config().resolved_ignore_patterns(),
|
Ok(dirs) => dirs.load_config().resolved_ignore_patterns(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -313,15 +346,23 @@ impl Core {
|
|||||||
self.progress_scan_total(folder_id, baseline.len());
|
self.progress_scan_total(folder_id, baseline.len());
|
||||||
|
|
||||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
|
let mut unscanned = Unscanned::default();
|
||||||
let writing = open_for_write_set(local_root);
|
let writing = open_for_write_set(local_root);
|
||||||
self.walk_local(
|
self.walk_local(
|
||||||
folder_id, local_root, local_root, rules, &writing, &mut local,
|
folder_id,
|
||||||
|
local_root,
|
||||||
|
rules,
|
||||||
|
&writing,
|
||||||
|
&mut local,
|
||||||
|
&mut unscanned,
|
||||||
)?;
|
)?;
|
||||||
// The guard compares against the paths this pass could still see: an
|
// The guard compares against the paths this pass could still see: an
|
||||||
// ignored path is absent from `local` by rule, not by loss, and counting
|
// ignored or unreadable path is absent from `local` by rule or by
|
||||||
// those as missing would trip the guard on every pass — wedging the
|
// failure, not by loss, and counting those as missing would trip the
|
||||||
// folder's sync permanently — the moment a rule covers the whole tree.
|
// guard on every pass — wedging the folder's sync permanently — the
|
||||||
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?;
|
// moment a rule covers the whole tree.
|
||||||
|
let tracked = tracked_baseline(&baseline, rules, &unscanned);
|
||||||
|
guard_local_wipe(&tracked, &local)?;
|
||||||
|
|
||||||
// The remote folder uids come from the baseline instead of a walk: every
|
// The remote folder uids come from the baseline instead of a walk: every
|
||||||
// directory this device has synced recorded its uid there when it was
|
// directory this device has synced recorded its uid there when it was
|
||||||
@@ -391,6 +432,7 @@ impl Core {
|
|||||||
|
|
||||||
let mut outcome = Outcome {
|
let mut outcome = Outcome {
|
||||||
deferred: local.values().filter(|item| item.open_for_write).count(),
|
deferred: local.values().filter(|item| item.open_for_write).count(),
|
||||||
|
unscanned: unscanned.report(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut order: Vec<&String> = local.keys().collect();
|
let mut order: Vec<&String> = local.keys().collect();
|
||||||
@@ -515,13 +557,8 @@ impl Core {
|
|||||||
// showing the folder's remote contents — otherwise everything they deleted
|
// showing the folder's remote contents — otherwise everything they deleted
|
||||||
// comes back the moment the switch lands. Shallowest first, skipping anything
|
// comes back the moment the switch lands. Shallowest first, skipping anything
|
||||||
// under a folder already trashed (its children went with it).
|
// under a folder already trashed (its children went with it).
|
||||||
let mut missing: Vec<&String> = baseline
|
|
||||||
.keys()
|
|
||||||
.filter(|rel| !local.contains_key(*rel))
|
|
||||||
.collect();
|
|
||||||
missing.sort_by_key(|p| p.matches('/').count());
|
|
||||||
let mut trashed: Vec<String> = Vec::new();
|
let mut trashed: Vec<String> = Vec::new();
|
||||||
for rel in missing {
|
for rel in &local_deletions(&tracked, &local) {
|
||||||
if trashed
|
if trashed
|
||||||
.iter()
|
.iter()
|
||||||
.any(|dir| rel.starts_with(&format!("{dir}/")))
|
.any(|dir| rel.starts_with(&format!("{dir}/")))
|
||||||
@@ -586,12 +623,18 @@ impl Core {
|
|||||||
self.progress_scan_total(folder_id, baseline.len() * 2);
|
self.progress_scan_total(folder_id, baseline.len() * 2);
|
||||||
|
|
||||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
|
let mut unscanned = Unscanned::default();
|
||||||
let writing = open_for_write_set(local_root);
|
let writing = open_for_write_set(local_root);
|
||||||
self.walk_local(
|
self.walk_local(
|
||||||
folder_id, local_root, local_root, rules, &writing, &mut local,
|
folder_id,
|
||||||
|
local_root,
|
||||||
|
rules,
|
||||||
|
&writing,
|
||||||
|
&mut local,
|
||||||
|
&mut unscanned,
|
||||||
)?;
|
)?;
|
||||||
// See `push_pass`: the guard must not count rule-excluded paths as lost.
|
// See `push_pass`: the guard must not count excluded paths as lost.
|
||||||
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?;
|
guard_local_wipe(&tracked_baseline(&baseline, rules, &unscanned), &local)?;
|
||||||
|
|
||||||
let mut remote: HashMap<String, RemoteItem> = HashMap::new();
|
let mut remote: HashMap<String, RemoteItem> = HashMap::new();
|
||||||
let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new();
|
let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new();
|
||||||
@@ -605,9 +648,15 @@ impl Core {
|
|||||||
&baseline,
|
&baseline,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let order = classification_order(&local, &remote, &baseline, rules);
|
let mut order = classification_order(&local, &remote, &baseline, rules);
|
||||||
|
// A path the scan could not read is no more a local deletion than an
|
||||||
|
// ignored one, and a remote path under it has nowhere to be written.
|
||||||
|
order.retain(|rel| !unscanned.covers(rel));
|
||||||
|
|
||||||
let mut outcome = Outcome::default();
|
let mut outcome = Outcome {
|
||||||
|
unscanned: unscanned.report(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
// Folders to delete, collected here and removed deepest-first at the end
|
// Folders to delete, collected here and removed deepest-first at the end
|
||||||
// so a parent is never removed before its children.
|
// so a parent is never removed before its children.
|
||||||
let mut delete_local_dirs: Vec<String> = Vec::new();
|
let mut delete_local_dirs: Vec<String> = Vec::new();
|
||||||
@@ -873,19 +922,19 @@ impl Core {
|
|||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively walk a local directory into `out`, keyed by `/`-joined relative
|
/// Recursively walk a local tree into `out`, keyed by `/`-joined relative
|
||||||
/// path. Symlinks and other special files are skipped. Reports each entry to the
|
/// path. Symlinks and other special files are skipped, and paths that cannot
|
||||||
/// pass's scan progress.
|
/// be read go to `unscanned`. Reports each entry to the pass's scan progress.
|
||||||
fn walk_local(
|
fn walk_local(
|
||||||
&self,
|
&self,
|
||||||
folder_id: i64,
|
folder_id: i64,
|
||||||
root: &Path,
|
root: &Path,
|
||||||
dir: &Path,
|
|
||||||
rules: &IgnoreRules,
|
rules: &IgnoreRules,
|
||||||
writing: &HashSet<PathBuf>,
|
writing: &HashSet<PathBuf>,
|
||||||
out: &mut HashMap<String, LocalItem>,
|
out: &mut HashMap<String, LocalItem>,
|
||||||
|
unscanned: &mut Unscanned,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
walk_local_tree(root, dir, rules, writing, out, &mut |rel| {
|
walk_local_tree(root, rules, writing, out, unscanned, &mut |rel| {
|
||||||
self.progress_scanned(folder_id, base_name(rel))
|
self.progress_scanned(folder_id, base_name(rel))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1582,51 +1631,120 @@ fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the
|
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the
|
||||||
/// local tree under `dir` into `out`, reporting each entry to `scanned`.
|
/// local tree under `root` into `out`, reporting each entry to `scanned`.
|
||||||
///
|
///
|
||||||
/// **Every failure here is fatal to the pass, deliberately.** A `read_dir` that
|
/// **A path the scan cannot read is left out and recorded in `unscanned`, never
|
||||||
/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of
|
/// silently dropped.** Every pass drops what `unscanned` covers from
|
||||||
/// them would, if skipped, leave `out` describing a *smaller* tree than the one
|
/// classification, so a baseline row under it is neither a local deletion nor a
|
||||||
/// on disk. Reconciliation reads a path present in the baseline and absent from
|
/// download target (`docs/BUGS.md` B55). That covers a subfolder the pass has no
|
||||||
/// the scan as "the user deleted this", and propagates that to Drive. So an
|
/// permission for, an entry that vanished between the listing and its `stat`,
|
||||||
/// unreadable subdirectory would trash its remote counterpart, and the deeper
|
/// and a name that is not UTF-8. Failing the whole pass on any of them, as this
|
||||||
/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the
|
/// did before, left one root-owned folder stopping everything else in the tree
|
||||||
/// whole pass keeps the previous baseline and leaves the remote alone; the next
|
/// from syncing, on every pass, for good (B196).
|
||||||
/// pass retries once the transient condition clears.
|
///
|
||||||
|
/// The root is the exception, and so is any other I/O error: an absent or
|
||||||
|
/// unreadable root fails the pass, since leaving the root out would leave
|
||||||
|
/// nothing in, and an error this cannot name a cause for is not one to guess
|
||||||
|
/// about.
|
||||||
///
|
///
|
||||||
/// Split out from the method so exactly that can be tested against a real tree
|
/// Split out from the method so exactly that can be tested against a real tree
|
||||||
/// with a real unreadable directory in it, which needs no `Core` at all.
|
/// with a real unreadable directory in it, which needs no `Core` at all.
|
||||||
fn walk_local_tree(
|
fn walk_local_tree(
|
||||||
|
root: &Path,
|
||||||
|
rules: &IgnoreRules,
|
||||||
|
writing: &HashSet<PathBuf>,
|
||||||
|
out: &mut HashMap<String, LocalItem>,
|
||||||
|
unscanned: &mut Unscanned,
|
||||||
|
scanned: &mut dyn FnMut(&str),
|
||||||
|
) -> Result<(), String> {
|
||||||
|
walk_local_dir(root, root, rules, writing, out, unscanned, scanned).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why [`walk_local_dir`] could not finish a folder.
|
||||||
|
enum ScanError {
|
||||||
|
/// An I/O error on `what`, which the caller may leave out of the pass.
|
||||||
|
Io { what: String, error: std::io::Error },
|
||||||
|
/// Anything else, which fails the pass.
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ScanError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ScanError::Io { what, error } => write!(f, "{what}: {error}"),
|
||||||
|
ScanError::Failed(message) => f.write_str(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave `rel` out of the pass for `failure`, or hand it back when it is one
|
||||||
|
/// that fails the pass. Permission denied is reported to the user; an entry
|
||||||
|
/// that vanished mid-scan is not, since the next pass sees it settled.
|
||||||
|
fn leave_out(rel: &str, failure: ScanError, unscanned: &mut Unscanned) -> Result<(), ScanError> {
|
||||||
|
let ScanError::Io { what, error } = &failure else {
|
||||||
|
return Err(failure);
|
||||||
|
};
|
||||||
|
match error.kind() {
|
||||||
|
std::io::ErrorKind::PermissionDenied => {
|
||||||
|
warn!(%what, %error, "sync: cannot read; leaving it out of the pass");
|
||||||
|
unscanned.unreadable(rel.to_string(), &error.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
std::io::ErrorKind::NotFound => {
|
||||||
|
debug!(%what, "sync: gone mid-scan; leaving it out of the pass");
|
||||||
|
unscanned.vanished(rel.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(failure),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_local_dir(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
rules: &IgnoreRules,
|
rules: &IgnoreRules,
|
||||||
writing: &HashSet<PathBuf>,
|
writing: &HashSet<PathBuf>,
|
||||||
out: &mut HashMap<String, LocalItem>,
|
out: &mut HashMap<String, LocalItem>,
|
||||||
|
unscanned: &mut Unscanned,
|
||||||
scanned: &mut dyn FnMut(&str),
|
scanned: &mut dyn FnMut(&str),
|
||||||
) -> Result<(), String> {
|
) -> Result<(), ScanError> {
|
||||||
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
let io = |what: String| move |error| ScanError::Io { what, error };
|
||||||
|
let entries = std::fs::read_dir(dir).map_err(io(format!("read {}", dir.display())))?;
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
|
let entry = entry.map_err(io(format!("read entry in {}", dir.display())))?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let meta = std::fs::symlink_metadata(&path)
|
|
||||||
.map_err(|e| format!("stat {}: {e}", path.display()))?;
|
|
||||||
let stripped = path.strip_prefix(root).map_err(|e| {
|
let stripped = path.strip_prefix(root).map_err(|e| {
|
||||||
format!(
|
ScanError::Failed(format!(
|
||||||
"local path {} escaped sync root {}: {e}",
|
"local path {} escaped sync root {}: {e}",
|
||||||
path.display(),
|
path.display(),
|
||||||
root.display()
|
root.display()
|
||||||
)
|
))
|
||||||
})?;
|
|
||||||
let rel = stripped.to_str().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"local path {} is not valid UTF-8; refusing a destructive sync pass",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
let Some(rel) = stripped.to_str() else {
|
||||||
|
// Nothing can be keyed by this name: not the baseline, which only
|
||||||
|
// ever held paths this scan produced, and not Drive, whose names are
|
||||||
|
// UTF-8. Leaving it out cannot read as a deletion of anything.
|
||||||
|
warn!(path = %path.display(), "sync: name is not valid UTF-8; leaving it out of the pass");
|
||||||
|
unscanned.unreadable(
|
||||||
|
stripped.to_string_lossy().into_owned(),
|
||||||
|
"name is not valid UTF-8",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
// Ignore our own in-flight download temp files.
|
// Ignore our own in-flight download temp files.
|
||||||
if rel.contains(".pdfs-tmp-") {
|
if rel.contains(".pdfs-tmp-") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let meta = match std::fs::symlink_metadata(&path) {
|
||||||
|
Ok(meta) => meta,
|
||||||
|
// Gone since the listing, or in a folder that lists but does not
|
||||||
|
// allow `stat`. Either way this pass cannot say what it is.
|
||||||
|
Err(error) => {
|
||||||
|
let what = format!("stat {}", path.display());
|
||||||
|
leave_out(rel, ScanError::Io { what, error }, unscanned)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
// Ignored paths are dropped here as well as from the classification
|
// Ignored paths are dropped here as well as from the classification
|
||||||
// union — not for correctness (the union filter is what makes this
|
// union — not for correctness (the union filter is what makes this
|
||||||
// safe) but so an ignored `node_modules/` is never descended into or
|
// safe) but so an ignored `node_modules/` is never descended into or
|
||||||
@@ -1646,7 +1764,15 @@ fn walk_local_tree(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
scanned(rel);
|
scanned(rel);
|
||||||
walk_local_tree(root, &path, rules, writing, out, scanned)?;
|
if let Err(failure) =
|
||||||
|
walk_local_dir(root, &path, rules, writing, out, unscanned, scanned)
|
||||||
|
{
|
||||||
|
leave_out(rel, failure, unscanned)?;
|
||||||
|
// What the folder did list before failing goes with it: a
|
||||||
|
// half-read folder is as unknown as an unread one.
|
||||||
|
let prefix = format!("{rel}/");
|
||||||
|
out.retain(|k, _| k != rel && !k.starts_with(&prefix));
|
||||||
|
}
|
||||||
} else if meta.is_file() {
|
} else if meta.is_file() {
|
||||||
// Check whether any process holds this file open for writing.
|
// Check whether any process holds this file open for writing.
|
||||||
// The canonical path is used because /proc/*/fd links resolve
|
// The canonical path is used because /proc/*/fd links resolve
|
||||||
@@ -1684,7 +1810,7 @@ pub(crate) fn mirror_subtree_unsynced(
|
|||||||
baseline: &HashMap<String, StoredSyncEntry>,
|
baseline: &HashMap<String, StoredSyncEntry>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
if let Some(reason) = unsynced_local(root, rel, baseline, &mut seen) {
|
if let Some(reason) = unsynced_local(root, rel, baseline, None, &mut seen) {
|
||||||
return Some(reason);
|
return Some(reason);
|
||||||
}
|
}
|
||||||
let prefix = format!("{rel}/");
|
let prefix = format!("{rel}/");
|
||||||
@@ -1695,45 +1821,93 @@ pub(crate) fn mirror_subtree_unsynced(
|
|||||||
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`mirror_subtree_unsynced`] for a whole mirror folder: why its local copy
|
||||||
|
/// holds something Proton Drive does not, or `None` when evicting it loses
|
||||||
|
/// nothing. The switch to on-demand asks this before it empties the folder.
|
||||||
|
///
|
||||||
|
/// A push pass that ends `idle` is not enough on its own. It never uploads an
|
||||||
|
/// ignored path, a symlink or a folder it cannot read, it defers a file open
|
||||||
|
/// for writing, and a file can change after it finishes; the eviction deleted
|
||||||
|
/// all of those for good (B198).
|
||||||
|
///
|
||||||
|
/// A tracked path deleted here but still in the baseline counts, since the
|
||||||
|
/// mount would show it again. An ignored one does not: ignoring never carries
|
||||||
|
/// a deletion to Drive, so its row stays in the baseline for good, and holding
|
||||||
|
/// the switch on it would hold it forever.
|
||||||
|
pub(crate) fn mirror_tree_unsynced(
|
||||||
|
root: &Path,
|
||||||
|
baseline: &HashMap<String, StoredSyncEntry>,
|
||||||
|
rules: &IgnoreRules,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
if let Some(reason) = unsynced_local(root, "", baseline, Some(rules), &mut seen) {
|
||||||
|
return Some(reason);
|
||||||
|
}
|
||||||
|
let mut missing: Vec<String> = filter_baseline(baseline, rules)
|
||||||
|
.into_keys()
|
||||||
|
.filter(|r| !seen.contains(r.as_str()))
|
||||||
|
.collect();
|
||||||
|
missing.sort_unstable();
|
||||||
|
missing
|
||||||
|
.first()
|
||||||
|
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The walk behind both checks. `rules`, when given, only names an ignored
|
||||||
|
/// path as such in the reason; it is still something only this disk has.
|
||||||
fn unsynced_local(
|
fn unsynced_local(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
rel: &str,
|
rel: &str,
|
||||||
baseline: &HashMap<String, StoredSyncEntry>,
|
baseline: &HashMap<String, StoredSyncEntry>,
|
||||||
|
rules: Option<&IgnoreRules>,
|
||||||
seen: &mut HashSet<String>,
|
seen: &mut HashSet<String>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let path = root.join(rel_to_path(rel));
|
let path = if rel.is_empty() {
|
||||||
let meta = match std::fs::symlink_metadata(&path) {
|
root.to_path_buf()
|
||||||
Ok(meta) => meta,
|
} else {
|
||||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
root.join(rel_to_path(rel))
|
||||||
};
|
};
|
||||||
let Some(base) = baseline.get(rel) else {
|
// The folder itself has no baseline row; only what is in it does.
|
||||||
return Some(format!("{rel} is not on Proton Drive yet"));
|
if !rel.is_empty() {
|
||||||
};
|
let meta = match std::fs::symlink_metadata(&path) {
|
||||||
seen.insert(rel.to_string());
|
Ok(meta) => meta,
|
||||||
if meta.is_file() {
|
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||||
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base)))
|
};
|
||||||
.then(|| format!("{rel} has changes that are not on Proton Drive yet"));
|
let Some(base) = baseline.get(rel) else {
|
||||||
}
|
if rules.is_some_and(|rules| rules.is_ignored(rel, meta.is_dir())) {
|
||||||
if !meta.is_dir() {
|
return Some(format!(
|
||||||
return Some(format!("{rel} is not a regular file or folder"));
|
"{rel} is excluded from sync, so it is only on this computer"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Some(format!("{rel} is not on Proton Drive yet"));
|
||||||
|
};
|
||||||
|
seen.insert(rel.to_string());
|
||||||
|
if meta.is_file() {
|
||||||
|
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base)))
|
||||||
|
.then(|| format!("{rel} has changes that are not on Proton Drive yet"));
|
||||||
|
}
|
||||||
|
if !meta.is_dir() {
|
||||||
|
return Some(format!("{rel} is not a regular file or folder"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
let shown = if rel.is_empty() { "the folder" } else { rel };
|
||||||
let entries = match std::fs::read_dir(&path) {
|
let entries = match std::fs::read_dir(&path) {
|
||||||
Ok(entries) => entries,
|
Ok(entries) => entries,
|
||||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
Err(e) => return Some(format!("cannot read {shown}: {e}")),
|
||||||
};
|
};
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let name = match entry {
|
let name = match entry {
|
||||||
Ok(entry) => entry.file_name(),
|
Ok(entry) => entry.file_name(),
|
||||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
Err(e) => return Some(format!("cannot read {shown}: {e}")),
|
||||||
};
|
};
|
||||||
let Some(name) = name.to_str() else {
|
let Some(name) = name.to_str() else {
|
||||||
return Some(format!("{rel} holds a name that is not valid UTF-8"));
|
return Some(format!("{shown} holds a name that is not valid UTF-8"));
|
||||||
};
|
};
|
||||||
// A download the engine left half done; it is the engine's to clean up.
|
// A download the engine left half done; it is the engine's to clean up.
|
||||||
if name.contains(".pdfs-tmp-") {
|
if name.contains(".pdfs-tmp-") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(reason) = unsynced_local(root, &format!("{rel}/{name}"), baseline, seen) {
|
if let Some(reason) = unsynced_local(root, &join_rel(rel, name), baseline, rules, seen) {
|
||||||
return Some(reason);
|
return Some(reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1923,6 +2097,32 @@ mod tests {
|
|||||||
(ids, started.elapsed())
|
(ids, started.elapsed())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_change_in_one_mirror_folder_reconciles_only_that_folder() {
|
||||||
|
let watched = vec![
|
||||||
|
(PathBuf::from("/home/me/Docs"), 1),
|
||||||
|
(PathBuf::from("/home/me/Photos"), 2),
|
||||||
|
(PathBuf::from("/home/me/Photos/Raw"), 3),
|
||||||
|
];
|
||||||
|
let target = |paths: &[&str]| {
|
||||||
|
let paths: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
||||||
|
match event_target(&watched, &paths) {
|
||||||
|
SyncMsg::Reconcile(id) => Some(id),
|
||||||
|
SyncMsg::ReconcileAll => None,
|
||||||
|
_ => panic!("unexpected message"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(target(&["/home/me/Docs/a.txt"]), Some(1));
|
||||||
|
// A rename within one folder.
|
||||||
|
assert_eq!(target(&["/home/me/Docs/a", "/home/me/Docs/b"]), Some(1));
|
||||||
|
// The deepest root wins.
|
||||||
|
assert_eq!(target(&["/home/me/Photos/Raw/x.cr2"]), Some(3));
|
||||||
|
// Across folders, outside them all, or no path at all: walk everything.
|
||||||
|
assert_eq!(target(&["/home/me/Docs/a", "/home/me/Photos/a"]), None);
|
||||||
|
assert_eq!(target(&["/home/me/Docsx/a"]), None);
|
||||||
|
assert_eq!(target(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
/// The bug this replaced a fixed sleep for: a save that keeps writing past
|
/// The bug this replaced a fixed sleep for: a save that keeps writing past
|
||||||
/// the debounce used to be walked mid-write, uploading a torn snapshot as a
|
/// the debounce used to be walked mid-write, uploading a torn snapshot as a
|
||||||
/// real revision. The window has to restart on every event, so settling
|
/// real revision. The window has to restart on every event, so settling
|
||||||
@@ -2154,7 +2354,15 @@ mod tests {
|
|||||||
let writing = HashSet::new();
|
let writing = HashSet::new();
|
||||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
let mut progress = no_progress();
|
let mut progress = no_progress();
|
||||||
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
walk_local_tree(
|
||||||
|
&root,
|
||||||
|
&rules,
|
||||||
|
&writing,
|
||||||
|
&mut local,
|
||||||
|
&mut Unscanned::default(),
|
||||||
|
&mut progress,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(local.is_empty(), "the replacement root is genuinely empty");
|
assert!(local.is_empty(), "the replacement root is genuinely empty");
|
||||||
|
|
||||||
for size in 1..=3 {
|
for size in 1..=3 {
|
||||||
@@ -2170,7 +2378,15 @@ mod tests {
|
|||||||
// remounted folder resumes rather than staying wedged.
|
// remounted folder resumes rather than staying wedged.
|
||||||
std::fs::write(root.join("file0.txt"), b"back").unwrap();
|
std::fs::write(root.join("file0.txt"), b"back").unwrap();
|
||||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
walk_local_tree(
|
||||||
|
&root,
|
||||||
|
&rules,
|
||||||
|
&writing,
|
||||||
|
&mut local,
|
||||||
|
&mut Unscanned::default(),
|
||||||
|
&mut progress,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
|
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
|
||||||
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
|
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
|
||||||
|
|
||||||
@@ -2475,33 +2691,176 @@ mod tests {
|
|||||||
|_: &str| {}
|
|_: &str| {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A directory the pass cannot read must fail the pass. Omitting it would
|
/// Scan `root` with no rules, returning what it found and left out.
|
||||||
/// hand reconciliation a baseline-minus-subtree, which it reads as the user
|
fn scan(root: &Path) -> Result<(HashMap<String, LocalItem>, Unscanned), String> {
|
||||||
/// having deleted that subtree and propagates to Drive (B55).
|
let mut out = HashMap::new();
|
||||||
|
let mut unscanned = Unscanned::default();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
walk_local_tree(
|
||||||
|
root,
|
||||||
|
&rules_for(&[]),
|
||||||
|
&HashSet::new(),
|
||||||
|
&mut out,
|
||||||
|
&mut unscanned,
|
||||||
|
&mut progress,
|
||||||
|
)?;
|
||||||
|
Ok((out, unscanned))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sorted_keys<V>(map: &HashMap<String, V>) -> Vec<&str> {
|
||||||
|
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
|
||||||
|
keys.sort_unstable();
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B196, the report in #33: a folder the user may not read, such as a
|
||||||
|
/// container's root-owned cache, is left out of the pass and named, and
|
||||||
|
/// everything around it still scans. It used to fail the whole pass, so
|
||||||
|
/// nothing else in the tree ever synced again.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it() {
|
fn an_unreadable_subdirectory_is_left_out_and_the_rest_scans() {
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
let root = sync_test_dir("scan-eacces");
|
let root = sync_test_dir("scan-eacces");
|
||||||
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||||
let locked = root.join("locked");
|
std::fs::create_dir_all(root.join("docker/cache")).unwrap();
|
||||||
|
std::fs::write(root.join("docker/compose.yml"), b"c").unwrap();
|
||||||
|
let locked = root.join("docker/cache/locked");
|
||||||
std::fs::create_dir(&locked).unwrap();
|
std::fs::create_dir(&locked).unwrap();
|
||||||
std::fs::write(locked.join("inner.txt"), b"b").unwrap();
|
std::fs::write(locked.join("inner.txt"), b"b").unwrap();
|
||||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
|
||||||
let rules = rules_for(&[]);
|
let result = scan(&root);
|
||||||
let writing = HashSet::new();
|
|
||||||
let mut out = HashMap::new();
|
|
||||||
let mut progress = no_progress();
|
|
||||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
|
||||||
|
|
||||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
std::fs::remove_dir_all(&root).unwrap();
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
let error = result.expect_err("an unreadable directory must abort the pass");
|
let (out, unscanned) = result.expect("one unreadable folder must not fail the pass");
|
||||||
assert!(
|
assert_eq!(
|
||||||
error.contains("locked"),
|
sorted_keys(&out),
|
||||||
"the error should name the subtree it could not read: {error}"
|
vec!["docker", "docker/cache", "docker/compose.yml", "keep.txt"]
|
||||||
|
);
|
||||||
|
assert!(unscanned.covers("docker/cache/locked"));
|
||||||
|
assert!(unscanned.covers("docker/cache/locked/inner.txt"));
|
||||||
|
assert!(!unscanned.covers("docker/cache"));
|
||||||
|
let report = unscanned.report();
|
||||||
|
assert_eq!(report.len(), 1);
|
||||||
|
assert!(report[0].starts_with("docker/cache/locked ("), "{report:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder with read but no search permission lists its names but cannot
|
||||||
|
/// `stat` them. Whatever it did list must go with it: a half-read folder
|
||||||
|
/// is as unknown as an unread one.
|
||||||
|
#[test]
|
||||||
|
fn a_listable_but_unsearchable_folder_is_left_out_whole() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let root = sync_test_dir("scan-no-search");
|
||||||
|
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||||
|
let half = root.join("half");
|
||||||
|
std::fs::create_dir(&half).unwrap();
|
||||||
|
std::fs::write(half.join("one.txt"), b"1").unwrap();
|
||||||
|
std::fs::write(half.join("two.txt"), b"2").unwrap();
|
||||||
|
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o400)).unwrap();
|
||||||
|
|
||||||
|
let result = scan(&root);
|
||||||
|
|
||||||
|
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
let (out, unscanned) = result.unwrap();
|
||||||
|
// Each entry fails its own `stat` and is left out by name.
|
||||||
|
assert_eq!(sorted_keys(&out), vec!["half", "keep.txt"]);
|
||||||
|
assert!(unscanned.covers("half/one.txt"));
|
||||||
|
assert!(unscanned.covers("half/two.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other half of B196: what was left out is not a deletion, and not a
|
||||||
|
/// download target either. Its baseline rows leave the wipe guard's set
|
||||||
|
/// and a push pass's deletions, and its paths leave classification.
|
||||||
|
#[test]
|
||||||
|
fn a_path_the_scan_left_out_is_never_a_local_deletion() {
|
||||||
|
let mut baseline = baseline_with("keep.txt", 1, 1);
|
||||||
|
baseline.extend(baseline_with("locked", 0, 0));
|
||||||
|
baseline.extend(baseline_with("locked/inner.txt", 1, 1));
|
||||||
|
baseline.extend(baseline_with("gone.txt", 1, 1));
|
||||||
|
let local: HashMap<String, LocalItem> = [("keep.txt".to_string(), local_item(false))]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let mut unscanned = Unscanned::default();
|
||||||
|
unscanned.unreadable("locked".to_string(), "Permission denied");
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
|
||||||
|
let tracked = tracked_baseline(&baseline, &rules, &unscanned);
|
||||||
|
assert_eq!(sorted_keys(&tracked), vec!["gone.txt", "keep.txt"]);
|
||||||
|
assert_eq!(local_deletions(&tracked, &local), vec!["gone.txt"]);
|
||||||
|
|
||||||
|
// Every tracked path but one is gone, and the guard still lets the
|
||||||
|
// pass run: one survivor proves the tree is there.
|
||||||
|
assert!(guard_local_wipe(&tracked, &local).is_ok());
|
||||||
|
// With everything readable left out, nothing is tracked, so nothing
|
||||||
|
// can be lost and the guard has nothing to object to.
|
||||||
|
let mut all = Unscanned::default();
|
||||||
|
all.unreadable("keep.txt".to_string(), "Permission denied");
|
||||||
|
all.unreadable("locked".to_string(), "Permission denied");
|
||||||
|
all.vanished("gone.txt".to_string());
|
||||||
|
assert!(tracked_baseline(&baseline, &rules, &all).is_empty());
|
||||||
|
|
||||||
|
let remote: HashMap<String, RemoteItem> = HashMap::new();
|
||||||
|
let mut order = classification_order(&local, &remote, &baseline, &rules);
|
||||||
|
order.retain(|rel| !unscanned.covers(rel));
|
||||||
|
order.sort_unstable();
|
||||||
|
assert_eq!(order, vec!["gone.txt", "keep.txt"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings() {
|
||||||
|
let mut unscanned = Unscanned::default();
|
||||||
|
unscanned.unreadable("a/locked".to_string(), "Permission denied");
|
||||||
|
unscanned.vanished("b.txt".to_string());
|
||||||
|
|
||||||
|
assert!(unscanned.covers("a/locked"));
|
||||||
|
assert!(unscanned.covers("a/locked/x/y.txt"));
|
||||||
|
assert!(unscanned.covers("b.txt"));
|
||||||
|
assert!(!unscanned.covers("a"));
|
||||||
|
assert!(!unscanned.covers("a/locked2"));
|
||||||
|
assert!(!unscanned.covers("b.txt.bak"));
|
||||||
|
// A file that only vanished mid-scan is not worth telling anyone about.
|
||||||
|
assert_eq!(unscanned.report(), vec!["a/locked (Permission denied)"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B197: a push pass read its deletions from the raw baseline, so a path
|
||||||
|
/// synced before it became ignored, which the walk skips, was trashed on
|
||||||
|
/// Drive, and the switch the push pass ran for then evicted the local copy.
|
||||||
|
#[test]
|
||||||
|
fn a_push_pass_never_reads_an_ignored_path_as_deleted() {
|
||||||
|
let mut baseline = baseline_with("notes.md", 1, 1);
|
||||||
|
baseline.extend(baseline_with("node_modules", 0, 0));
|
||||||
|
baseline.extend(baseline_with("node_modules/x/index.js", 1, 1));
|
||||||
|
baseline.extend(baseline_with("old.md", 1, 1));
|
||||||
|
let local: HashMap<String, LocalItem> = [("notes.md".to_string(), local_item(false))]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let rules = rules_for(&["node_modules/"]);
|
||||||
|
|
||||||
|
let tracked = tracked_baseline(&baseline, &rules, &Unscanned::default());
|
||||||
|
|
||||||
|
assert_eq!(local_deletions(&tracked, &local), vec!["old.md"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletions come out shallowest first, so a trashed folder can take its
|
||||||
|
/// children with it.
|
||||||
|
#[test]
|
||||||
|
fn local_deletions_come_out_shallowest_first() {
|
||||||
|
let mut baseline = baseline_with("d/e/f.txt", 1, 1);
|
||||||
|
baseline.extend(baseline_with("d", 0, 0));
|
||||||
|
baseline.extend(baseline_with("d/e", 0, 0));
|
||||||
|
let local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
|
let tracked = tracked_baseline(&baseline, &rules_for(&[]), &Unscanned::default());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
local_deletions(&tracked, &local),
|
||||||
|
vec!["d", "d/e", "d/e/f.txt"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2593,6 +2952,94 @@ mod tests {
|
|||||||
assert_eq!(reason, None);
|
assert_eq!(reason, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- B198: going online-only never evicts what Drive does not have -----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fully_synced_mirror_folder_may_go_online_only() {
|
||||||
|
let (root, baseline) = synced_subtree("evict-synced");
|
||||||
|
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
assert_eq!(reason, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The case that lost data: `.git/` is ignored by default, so no pass ever
|
||||||
|
/// uploads it, and the eviction deleted it with the rest.
|
||||||
|
#[test]
|
||||||
|
fn an_ignored_folder_only_this_disk_has_holds_the_switch_back() {
|
||||||
|
let (root, baseline) = synced_subtree("evict-ignored");
|
||||||
|
std::fs::create_dir(root.join(".git")).unwrap();
|
||||||
|
std::fs::write(root.join(".git/HEAD"), b"ref").unwrap();
|
||||||
|
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[".git/"]));
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
let reason = reason.expect("an ignored folder is only on this disk");
|
||||||
|
assert!(
|
||||||
|
reason.contains(".git") && reason.contains("excluded"),
|
||||||
|
"{reason}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ignored row stays in the baseline for good once its file is gone, so
|
||||||
|
/// counting it as a deletion not carried up would hold the switch forever.
|
||||||
|
#[test]
|
||||||
|
fn a_deleted_ignored_path_does_not_hold_the_switch_back() {
|
||||||
|
let (root, mut baseline) = synced_subtree("evict-ignored-gone");
|
||||||
|
baseline.insert(
|
||||||
|
"build/out.o".to_string(),
|
||||||
|
StoredSyncEntry {
|
||||||
|
rel_path: "build/out.o".to_string(),
|
||||||
|
..baseline["dir/a.txt"].clone()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let rules = rules_for(&["build/"]);
|
||||||
|
let ignored = mirror_tree_unsynced(&root, &baseline, &rules);
|
||||||
|
let tracked = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
assert_eq!(ignored, None);
|
||||||
|
assert!(tracked.unwrap().contains("build/out.o"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edit_after_the_last_pass_holds_the_switch_back() {
|
||||||
|
let (root, baseline) = synced_subtree("evict-edit");
|
||||||
|
std::fs::write(root.join("dir/sub/b.txt"), b"edited since").unwrap();
|
||||||
|
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
assert!(reason.unwrap().contains("dir/sub/b.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_file_at_the_root_holds_the_switch_back() {
|
||||||
|
let (root, baseline) = synced_subtree("evict-new");
|
||||||
|
std::fs::write(root.join("new.txt"), b"n").unwrap();
|
||||||
|
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
assert!(reason.unwrap().contains("new.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder B196 now leaves out of the pass instead of failing it is,
|
||||||
|
/// by the same token, something the eviction must not delete.
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_folder_holds_the_switch_back() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let (root, baseline) = synced_subtree("evict-eacces");
|
||||||
|
let locked = root.join("dir/sub");
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
assert!(reason.unwrap().contains("dir/sub"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_symlink_holds_the_switch_back() {
|
||||||
|
let (root, baseline) = synced_subtree("evict-symlink");
|
||||||
|
std::os::unix::fs::symlink("dir/a.txt", root.join("link")).unwrap();
|
||||||
|
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
assert!(reason.unwrap().contains("link"));
|
||||||
|
}
|
||||||
|
|
||||||
/// The same rule one level up: if the sync root itself cannot be read, the
|
/// The same rule one level up: if the sync root itself cannot be read, the
|
||||||
/// pass sees nothing, and "nothing" must never mean "delete everything".
|
/// pass sees nothing, and "nothing" must never mean "delete everything".
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2607,7 +3054,14 @@ mod tests {
|
|||||||
let writing = HashSet::new();
|
let writing = HashSet::new();
|
||||||
let mut out = HashMap::new();
|
let mut out = HashMap::new();
|
||||||
let mut progress = no_progress();
|
let mut progress = no_progress();
|
||||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
let result = walk_local_tree(
|
||||||
|
&root,
|
||||||
|
&rules,
|
||||||
|
&writing,
|
||||||
|
&mut out,
|
||||||
|
&mut Unscanned::default(),
|
||||||
|
&mut progress,
|
||||||
|
);
|
||||||
|
|
||||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
std::fs::remove_dir_all(&root).unwrap();
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
@@ -2628,30 +3082,40 @@ mod tests {
|
|||||||
let writing = HashSet::new();
|
let writing = HashSet::new();
|
||||||
let mut out = HashMap::new();
|
let mut out = HashMap::new();
|
||||||
let mut progress = no_progress();
|
let mut progress = no_progress();
|
||||||
assert!(walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress).is_err());
|
assert!(
|
||||||
|
walk_local_tree(
|
||||||
|
&root,
|
||||||
|
&rules,
|
||||||
|
&writing,
|
||||||
|
&mut out,
|
||||||
|
&mut Unscanned::default(),
|
||||||
|
&mut progress
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert!(out.is_empty());
|
assert!(out.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A name that is not valid UTF-8 cannot be keyed into the scan, so it
|
/// A name that is not valid UTF-8 cannot be keyed into the scan, but it
|
||||||
/// cannot be compared against the baseline either. Refusing the pass is the
|
/// cannot be in the baseline or on Drive either, so leaving it out reads as
|
||||||
/// only safe reading: skipping it would classify the file as deleted.
|
/// the deletion of nothing. It used to fail the pass (B196).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_non_utf8_name_fails_the_scan() {
|
fn a_non_utf8_name_is_left_out_and_reported() {
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::os::unix::ffi::OsStrExt as _;
|
use std::os::unix::ffi::OsStrExt as _;
|
||||||
|
|
||||||
let root = sync_test_dir("scan-non-utf8");
|
let root = sync_test_dir("scan-non-utf8");
|
||||||
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
|
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
|
||||||
|
std::fs::write(root.join("good.txt"), b"y").unwrap();
|
||||||
|
|
||||||
let rules = rules_for(&[]);
|
let result = scan(&root);
|
||||||
let writing = HashSet::new();
|
|
||||||
let mut out = HashMap::new();
|
|
||||||
let mut progress = no_progress();
|
|
||||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
|
||||||
std::fs::remove_dir_all(&root).unwrap();
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
let error = result.expect_err("a name the scan cannot key must abort the pass");
|
let (out, unscanned) = result.unwrap();
|
||||||
assert!(error.contains("UTF-8"), "{error}");
|
assert_eq!(sorted_keys(&out), vec!["good.txt"]);
|
||||||
|
let report = unscanned.report();
|
||||||
|
assert_eq!(report.len(), 1);
|
||||||
|
assert!(report[0].contains("UTF-8"), "{report:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The complement: a scan that *can* read everything reports everything, so
|
/// The complement: a scan that *can* read everything reports everything, so
|
||||||
@@ -2670,9 +3134,14 @@ mod tests {
|
|||||||
let writing = HashSet::new();
|
let writing = HashSet::new();
|
||||||
let mut out = HashMap::new();
|
let mut out = HashMap::new();
|
||||||
let mut seen: Vec<String> = Vec::new();
|
let mut seen: Vec<String> = Vec::new();
|
||||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| {
|
let result = walk_local_tree(
|
||||||
seen.push(rel.to_string())
|
&root,
|
||||||
});
|
&rules,
|
||||||
|
&writing,
|
||||||
|
&mut out,
|
||||||
|
&mut Unscanned::default(),
|
||||||
|
&mut |rel| seen.push(rel.to_string()),
|
||||||
|
);
|
||||||
std::fs::remove_dir_all(&root).unwrap();
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
result.unwrap();
|
result.unwrap();
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::Jo
|
|||||||
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||||
// Paths the watcher currently covers, mapped to their folder id, so an event
|
// Paths the watcher currently covers, mapped to their folder id, so an event
|
||||||
// path can be resolved back to the folder it belongs to.
|
// path can be resolved back to the folder it belongs to.
|
||||||
let watched: Mutex<Vec<(PathBuf, i64)>> = Mutex::new(Vec::new());
|
let watched: Arc<Mutex<Vec<(PathBuf, i64)>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
let tx_events = core.sync_tx.clone();
|
let tx_events = core.sync_tx.clone();
|
||||||
|
let watched_events = watched.clone();
|
||||||
let mut watcher =
|
let mut watcher =
|
||||||
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||||
let Ok(event) = res else { return };
|
let Ok(event) = res else { return };
|
||||||
@@ -67,7 +68,8 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
if matches!(event.kind, notify::EventKind::Access(_)) {
|
if matches!(event.kind, notify::EventKind::Access(_)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = tx_events.send(SyncMsg::ReconcileAll);
|
let msg = event_target(&watched_events.lock(), &event.paths);
|
||||||
|
let _ = tx_events.send(msg);
|
||||||
}) {
|
}) {
|
||||||
Ok(w) => w,
|
Ok(w) => w,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -256,8 +258,31 @@ fn reconcile_all(core: &Core) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The reconcile a watcher event asks for: the one mirror folder all its paths
|
||||||
|
/// lie in, so an edit in one folder does not walk every other one too. An event
|
||||||
|
/// that spans folders, or that no watched folder claims (a watch list a moment
|
||||||
|
/// out of date), falls back to every folder.
|
||||||
|
pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> SyncMsg {
|
||||||
|
let mut ids = paths.iter().map(|path| {
|
||||||
|
watched
|
||||||
|
.iter()
|
||||||
|
.filter(|(root, _)| path.starts_with(root))
|
||||||
|
// The deepest root, should one mirror folder sit inside another.
|
||||||
|
.max_by_key(|(root, _)| root.components().count())
|
||||||
|
.map(|(_, id)| *id)
|
||||||
|
});
|
||||||
|
match ids.next() {
|
||||||
|
Some(Some(first)) if ids.all(|id| id == Some(first)) => SyncMsg::Reconcile(first),
|
||||||
|
_ => SyncMsg::ReconcileAll,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Bring the filesystem watches in line with the current mirror-folder set:
|
/// Bring the filesystem watches in line with the current mirror-folder set:
|
||||||
/// watch newly-added folders, drop removed ones.
|
/// watch newly-added folders, drop removed ones.
|
||||||
|
///
|
||||||
|
/// The watch calls run on a copy, outside the lock: the watcher's event
|
||||||
|
/// callback takes the same lock, and a backend that hands a watch request to
|
||||||
|
/// its event thread and waits for the answer would deadlock against it.
|
||||||
fn rewatch(
|
fn rewatch(
|
||||||
core: &Core,
|
core: &Core,
|
||||||
watcher: &mut notify::RecommendedWatcher,
|
watcher: &mut notify::RecommendedWatcher,
|
||||||
@@ -274,7 +299,7 @@ fn rewatch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut have = watched.lock();
|
let mut have = watched.lock().clone();
|
||||||
// Drop watches no longer wanted.
|
// Drop watches no longer wanted.
|
||||||
have.retain(|(path, _)| {
|
have.retain(|(path, _)| {
|
||||||
if want.iter().any(|(p, _)| p == path) {
|
if want.iter().any(|(p, _)| p == path) {
|
||||||
@@ -294,6 +319,7 @@ fn rewatch(
|
|||||||
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
|
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*watched.lock() = have;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- reconcile ------------------------------------------------------------
|
// ---- reconcile ------------------------------------------------------------
|
||||||
@@ -98,6 +98,11 @@ pub(super) struct Outcome {
|
|||||||
pub(super) errors: usize,
|
pub(super) errors: usize,
|
||||||
/// Files skipped because another process held them open for writing.
|
/// Files skipped because another process held them open for writing.
|
||||||
pub(super) deferred: usize,
|
pub(super) deferred: usize,
|
||||||
|
/// Paths the local scan could not read, left out of the pass. Not part of
|
||||||
|
/// [`is_empty`](Self::is_empty) or the summary: an unreadable folder stays
|
||||||
|
/// unreadable from one poll to the next, so the pass reports it once, when
|
||||||
|
/// the set changes, rather than on every poll.
|
||||||
|
pub(super) unscanned: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Outcome {
|
impl Outcome {
|
||||||
|
|||||||
@@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The paths a local scan left out because it could not read them: a folder
|
||||||
|
/// it has no permission for, an entry that vanished between the listing and
|
||||||
|
/// its `stat`, a name that is not UTF-8.
|
||||||
|
///
|
||||||
|
/// A left-out path is not a deleted one. A pass drops everything an entry
|
||||||
|
/// covers from classification, exactly as it drops ignored paths, so a
|
||||||
|
/// baseline row under an unreadable folder is neither read as a local deletion
|
||||||
|
/// nor answered with a download into a folder the pass cannot write. Failing
|
||||||
|
/// the whole pass instead is what B55 did, and it left one root-owned folder
|
||||||
|
/// stopping every other file in the tree from syncing (B196).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct Unscanned {
|
||||||
|
covered: HashSet<String>,
|
||||||
|
/// The subset worth telling the user about, with why: an entry that only
|
||||||
|
/// vanished mid-scan is not, since the next pass sees it settled.
|
||||||
|
unreadable: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Unscanned {
|
||||||
|
/// Leave out `rel`, which vanished while the scan looked at it.
|
||||||
|
pub(super) fn vanished(&mut self, rel: String) {
|
||||||
|
self.covered.insert(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave out `rel`, which cannot be read for `reason`.
|
||||||
|
pub(super) fn unreadable(&mut self, rel: String, reason: &str) {
|
||||||
|
self.unreadable.push(format!("{rel} ({reason})"));
|
||||||
|
self.covered.insert(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `rel` is a left-out path or lies under one.
|
||||||
|
pub(super) fn covers(&self, rel: &str) -> bool {
|
||||||
|
if self.covered.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut at = rel;
|
||||||
|
loop {
|
||||||
|
if self.covered.contains(at) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match at.rfind('/') {
|
||||||
|
Some(i) => at = &at[..i],
|
||||||
|
None => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unreadable paths with their reasons, sorted, for the activity feed.
|
||||||
|
pub(super) fn report(&self) -> Vec<String> {
|
||||||
|
let mut lines = self.unreadable.clone();
|
||||||
|
lines.sort_unstable();
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The baseline paths a pass can see: [`filter_baseline`], minus everything
|
||||||
|
/// under a path the scan could not read. This is the set [`guard_local_wipe`]
|
||||||
|
/// checks and the set a push pass reads deletions from, since a path outside
|
||||||
|
/// it is absent from the local walk by rule or by failure, never by loss.
|
||||||
|
pub(super) fn tracked_baseline<'a, B>(
|
||||||
|
baseline: &'a HashMap<String, B>,
|
||||||
|
rules: &IgnoreRules,
|
||||||
|
unscanned: &Unscanned,
|
||||||
|
) -> HashMap<String, &'a B> {
|
||||||
|
let mut tracked = filter_baseline(baseline, rules);
|
||||||
|
tracked.retain(|rel, _| !unscanned.covers(rel));
|
||||||
|
tracked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The baseline paths a push pass has to trash on Drive: tracked, and gone
|
||||||
|
/// from the local walk. Shallowest first, so a trashed folder takes its
|
||||||
|
/// children with it.
|
||||||
|
///
|
||||||
|
/// Built from [`tracked_baseline`] rather than from the raw baseline. Reading
|
||||||
|
/// it raw trashed every synced path that later became ignored, because the walk
|
||||||
|
/// skips those, and the switch to on-demand that the push pass runs for then
|
||||||
|
/// deleted the local copy too (B197).
|
||||||
|
pub(super) fn local_deletions<B, L>(
|
||||||
|
tracked: &HashMap<String, &B>,
|
||||||
|
local: &HashMap<String, L>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut missing: Vec<String> = tracked
|
||||||
|
.keys()
|
||||||
|
.filter(|rel| !local.contains_key(*rel))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
missing.sort_by_key(|p| p.matches('/').count());
|
||||||
|
missing
|
||||||
|
}
|
||||||
|
|
||||||
/// Refuse to run a pass whose local side has vanished in its entirety.
|
/// Refuse to run a pass whose local side has vanished in its entirety.
|
||||||
///
|
///
|
||||||
/// When every baseline path is absent locally, the likely cause is an unavailable
|
/// When every baseline path is absent locally, the likely cause is an unavailable
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ struct JobEntry {
|
|||||||
detail: Mutex<String>,
|
detail: Mutex<String>,
|
||||||
done: AtomicU64,
|
done: AtomicU64,
|
||||||
total: AtomicU64,
|
total: AtomicU64,
|
||||||
|
/// Housekeeping that moves nothing; see [`JobItem::background`].
|
||||||
|
background: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
|
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
|
||||||
@@ -160,12 +162,25 @@ impl TransferRegistry {
|
|||||||
/// that carries its progress and deregisters the job when dropped. The job
|
/// that carries its progress and deregisters the job when dropped. The job
|
||||||
/// starts indeterminate; give it a `total` once one is known.
|
/// starts indeterminate; give it a `total` once one is known.
|
||||||
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
||||||
|
self.register_job(title.into(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`TransferRegistry::begin_job`] for housekeeping the user did not ask
|
||||||
|
/// for and that changes nothing in their files, such as an index rebuild or
|
||||||
|
/// a cache check. It is reported, but front ends don't call the drive busy
|
||||||
|
/// over it.
|
||||||
|
pub fn begin_background_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
||||||
|
self.register_job(title.into(), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_job(self: &Arc<Self>, title: String, background: bool) -> JobGuard {
|
||||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||||
let entry = Arc::new(JobEntry {
|
let entry = Arc::new(JobEntry {
|
||||||
title: title.into(),
|
title,
|
||||||
detail: Mutex::new(String::new()),
|
detail: Mutex::new(String::new()),
|
||||||
done: AtomicU64::new(0),
|
done: AtomicU64::new(0),
|
||||||
total: AtomicU64::new(0),
|
total: AtomicU64::new(0),
|
||||||
|
background,
|
||||||
});
|
});
|
||||||
self.jobs.lock().insert(id, entry.clone());
|
self.jobs.lock().insert(id, entry.clone());
|
||||||
JobGuard {
|
JobGuard {
|
||||||
@@ -191,6 +206,7 @@ impl TransferRegistry {
|
|||||||
detail: e.detail.lock().clone(),
|
detail: e.detail.lock().clone(),
|
||||||
done: e.done.load(Ordering::Relaxed),
|
done: e.done.load(Ordering::Relaxed),
|
||||||
total: e.total.load(Ordering::Relaxed),
|
total: e.total.load(Ordering::Relaxed),
|
||||||
|
background: e.background,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
|
||||||
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
|
|
||||||
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
|
|
||||||
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 441 B |
@@ -14,7 +14,6 @@
|
|||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
|
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
.sidebar-status image.warning { color: var(--warning-color); }
|
.sidebar-status image.warning { color: var(--warning-color); }
|
||||||
.sidebar-status image.error { color: var(--error-color); }
|
.sidebar-status image.error { color: var(--error-color); }
|
||||||
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
||||||
.sidebar-account { padding: 4px 4px 4px 8px; }
|
/* The account row is the main menu's button: a row, not a pushbutton, so
|
||||||
|
* the name keeps the text weight. */
|
||||||
|
button.sidebar-account { padding: 4px 8px; font-weight: normal; }
|
||||||
/* A count of what waits on a page, at the end of its sidebar row */
|
/* A count of what waits on a page, at the end of its sidebar row */
|
||||||
.sidebar-badge {
|
.sidebar-badge {
|
||||||
min-width: 10px;
|
min-width: 10px;
|
||||||
@@ -48,6 +50,9 @@
|
|||||||
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||||
.badge-issue { color: var(--error-color); }
|
.badge-issue { color: var(--error-color); }
|
||||||
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
||||||
|
/* The folder on screen, last in the breadcrumb trail: a flat button's side
|
||||||
|
* padding, so the "›" before it is spaced like the ones between buttons. */
|
||||||
|
.crumb-current { padding: 0 10px; }
|
||||||
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
||||||
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
||||||
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ use pages::verify::*;
|
|||||||
use reload::{LoadTicket, Loader, replace_items};
|
use reload::{LoadTicket, Loader, replace_items};
|
||||||
use theme::{load_resources, proton_theme_active, set_proton_theme};
|
use theme::{load_resources, proton_theme_active, set_proton_theme};
|
||||||
use widgets::compat::*;
|
use widgets::compat::*;
|
||||||
|
use widgets::crumb::*;
|
||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
use widgets::file_list::*;
|
use widgets::file_list::*;
|
||||||
use widgets::menu::*;
|
use widgets::menu::*;
|
||||||
@@ -830,7 +831,6 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
|||||||
|
|
||||||
let header = adw::HeaderBar::new();
|
let header = adw::HeaderBar::new();
|
||||||
header.set_title_widget(Some(&brand));
|
header.set_title_widget(Some(&brand));
|
||||||
header.pack_end(&build_primary_menu());
|
|
||||||
|
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
@@ -1153,25 +1153,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sidebar's primary (hamburger) menu: the app-level entries that don't
|
/// Back the main menu's entries with window actions.
|
||||||
/// belong on any one page.
|
|
||||||
fn build_primary_menu() -> gtk4::MenuButton {
|
|
||||||
let menu = gio::Menu::new();
|
|
||||||
menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
|
||||||
menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
|
|
||||||
menu.append(
|
|
||||||
Some(&gettext("About Proton Drive for Linux")),
|
|
||||||
Some("win.about"),
|
|
||||||
);
|
|
||||||
gtk4::MenuButton::builder()
|
|
||||||
.icon_name("pdfs-menu-symbolic")
|
|
||||||
.tooltip_text(gettext("Main menu"))
|
|
||||||
.primary(true)
|
|
||||||
.menu_model(&menu)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Back the primary and account menus' entries with window actions.
|
|
||||||
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||||
let preferences = gio::SimpleAction::new("preferences", None);
|
let preferences = gio::SimpleAction::new("preferences", None);
|
||||||
let ui_prefs = ui.clone();
|
let ui_prefs = ui.clone();
|
||||||
|
|||||||
@@ -1220,9 +1220,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
|
|||||||
));
|
));
|
||||||
let mut acc = String::new();
|
let mut acc = String::new();
|
||||||
for (i, seg) in segments.iter().enumerate() {
|
for (i, seg) in segments.iter().enumerate() {
|
||||||
let sep = gtk4::Label::new(Some("›"));
|
ui.browser.crumb.append(&crumb_separator());
|
||||||
sep.add_css_class("dim-label");
|
|
||||||
ui.browser.crumb.append(&sep);
|
|
||||||
acc = if acc.is_empty() {
|
acc = if acc.is_empty() {
|
||||||
seg.to_string()
|
seg.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -1237,12 +1235,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
|
|||||||
/// flat button that navigates to `target` (clearing any active search first).
|
/// flat button that navigates to `target` (clearing any active search first).
|
||||||
pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget {
|
pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget {
|
||||||
if current {
|
if current {
|
||||||
let l = gtk4::Label::builder()
|
return crumb_current(label);
|
||||||
.label(label)
|
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
|
||||||
.build();
|
|
||||||
l.add_css_class("heading");
|
|
||||||
return l.upcast();
|
|
||||||
}
|
}
|
||||||
let button = gtk4::Button::builder().label(label).build();
|
let button = gtk4::Button::builder().label(label).build();
|
||||||
button.add_css_class("flat");
|
button.add_css_class("flat");
|
||||||
|
|||||||
@@ -439,6 +439,10 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
|
|||||||
menu.item(&gettext("Restore to This Computer…"), move || {
|
menu.item(&gettext("Restore to This Computer…"), move || {
|
||||||
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());
|
||||||
|
menu.item(&gettext("Move to This Computer…"), move || {
|
||||||
|
prompt_migrate_device(&ui_c, &uid_c, &name_c)
|
||||||
|
});
|
||||||
menu.section();
|
menu.section();
|
||||||
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("Rename…"), move || {
|
menu.item(&gettext("Rename…"), move || {
|
||||||
@@ -483,9 +487,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
|||||||
nav.is_empty(),
|
nav.is_empty(),
|
||||||
));
|
));
|
||||||
for (i, (_, name)) in nav.iter().enumerate() {
|
for (i, (_, name)) in nav.iter().enumerate() {
|
||||||
let sep = gtk4::Label::new(Some("›"));
|
crumb.append(&crumb_separator());
|
||||||
sep.add_css_class("dim-label");
|
|
||||||
crumb.append(&sep);
|
|
||||||
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,12 +496,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
|||||||
/// that goes back up to `depth` levels below the device list.
|
/// that goes back up to `depth` levels below the device list.
|
||||||
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||||
if current {
|
if current {
|
||||||
let l = gtk4::Label::builder()
|
return crumb_current(label);
|
||||||
.label(label)
|
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
|
||||||
.build();
|
|
||||||
l.add_css_class("heading");
|
|
||||||
return l.upcast();
|
|
||||||
}
|
}
|
||||||
let button = gtk4::Button::builder().label(label).build();
|
let button = gtk4::Button::builder().label(label).build();
|
||||||
button.add_css_class("flat");
|
button.add_css_class("flat");
|
||||||
@@ -1096,6 +1093,67 @@ 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 moving another computer's folders into this computer's backup,
|
||||||
|
/// then offer them for syncing here.
|
||||||
|
///
|
||||||
|
/// Destructive-looking on purpose: the other computer keeps its device but
|
||||||
|
/// loses its folders, and a copy of Proton Drive still running there would
|
||||||
|
/// watch them vanish.
|
||||||
|
pub(crate) fn prompt_migrate_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||||
|
let win = ui_window(ui);
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Move Backup to This Computer?"))
|
||||||
|
// Translators: {name} is the name of the other computer whose folders move to this one.
|
||||||
|
.body(gettext_f(
|
||||||
|
"The folders backed up by “{name}” move into this computer's backup in Proton Drive, and you choose where they go on this computer. “{name}” stays in your account, without those folders.\n\nStop Proton Drive on “{name}” first. Otherwise it sees its folders disappear.",
|
||||||
|
&[("name", name)],
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
|
dialog.add_response("move", &gettext("Move Here"));
|
||||||
|
dialog.set_response_appearance("move", adw::ResponseAppearance::Destructive);
|
||||||
|
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 computer whose folders moved to this one.
|
||||||
|
let done = gettext_f("Moved the backup of “{name}”", &[("name", name)]);
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
if resp != "move" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::MigrateDevice { uid: 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 move that backup");
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
reload_sync_pages(&ui);
|
||||||
|
toast(&ui, &done);
|
||||||
|
// The folders are this computer's now, so its own restore
|
||||||
|
// picker is where the user chooses their local paths.
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a mutation raised from Computers or Locations and reload the page on success.
|
/// Run a mutation raised from Computers or Locations and reload the page on success.
|
||||||
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
|
|||||||
@@ -484,9 +484,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
|||||||
nav.is_empty(),
|
nav.is_empty(),
|
||||||
));
|
));
|
||||||
for (i, (_, name)) in nav.iter().enumerate() {
|
for (i, (_, name)) in nav.iter().enumerate() {
|
||||||
let sep = gtk4::Label::new(Some("›"));
|
crumb.append(&crumb_separator());
|
||||||
sep.add_css_class("dim-label");
|
|
||||||
crumb.append(&sep);
|
|
||||||
crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,12 +493,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
|||||||
/// that goes back up to `depth` folders below the top level.
|
/// that goes back up to `depth` folders below the top level.
|
||||||
fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||||
if current {
|
if current {
|
||||||
let l = gtk4::Label::builder()
|
return crumb_current(label);
|
||||||
.label(label)
|
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
|
||||||
.build();
|
|
||||||
l.add_css_class("heading");
|
|
||||||
return l.upcast();
|
|
||||||
}
|
}
|
||||||
let button = gtk4::Button::builder().label(label).build();
|
let button = gtk4::Button::builder().label(label).build();
|
||||||
button.add_css_class("flat");
|
button.add_css_class("flat");
|
||||||
|
|||||||
@@ -565,6 +565,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||||
dialog.connect_response(Some("stop"), move |_, _| {
|
dialog.connect_response(Some("stop"), move |_, _| {
|
||||||
let done = done.clone();
|
let done = done.clone();
|
||||||
|
let stopped = uids.clone();
|
||||||
run_requests(
|
run_requests(
|
||||||
&ui_c,
|
&ui_c,
|
||||||
uids.iter()
|
uids.iter()
|
||||||
@@ -572,6 +573,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
.collect(),
|
.collect(),
|
||||||
gettext_noop("Couldn't stop sharing"),
|
gettext_noop("Couldn't stop sharing"),
|
||||||
move |ui| {
|
move |ui| {
|
||||||
|
forget_shared_by_me(ui, &stopped);
|
||||||
reload_listing(ui);
|
reload_listing(ui);
|
||||||
toast(ui, &done);
|
toast(ui, &done);
|
||||||
},
|
},
|
||||||
@@ -580,6 +582,18 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
dialog.present(ui_window(ui).as_ref());
|
dialog.present(ui_window(ui).as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Take items whose sharing just stopped off the page now, rather than when
|
||||||
|
/// the reload that confirms it comes back: that reload asks after every share
|
||||||
|
/// in turn and takes a while with many of them.
|
||||||
|
fn forget_shared_by_me(ui: &Rc<Ui>, uids: &[String]) {
|
||||||
|
let items = {
|
||||||
|
let mut items = ui.shared_by_me.items.borrow_mut();
|
||||||
|
items.retain(|item| !uids.contains(&item.uid));
|
||||||
|
items.clone()
|
||||||
|
};
|
||||||
|
repaint_shared_by_me(ui, &items);
|
||||||
|
}
|
||||||
|
|
||||||
/// Open a shared item: a folder in My Files, a file the way My Files would.
|
/// Open a shared item: a folder in My Files, a file the way My Files would.
|
||||||
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
|
|||||||
@@ -381,23 +381,34 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||||
.build();
|
.build();
|
||||||
|
// The app's one menu, on the account row the way Proton's own apps keep it:
|
||||||
|
// app-wide entries first, the account's last. The whole row opens it, and
|
||||||
|
// F10 too, as the primary menu.
|
||||||
|
let app_section = gio::Menu::new();
|
||||||
|
app_section.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
||||||
|
app_section.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
|
||||||
|
app_section.append(
|
||||||
|
Some(&gettext("About Proton Drive for Linux")),
|
||||||
|
Some("win.about"),
|
||||||
|
);
|
||||||
|
let account_section = gio::Menu::new();
|
||||||
|
account_section.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
|
||||||
let account_menu = gio::Menu::new();
|
let account_menu = gio::Menu::new();
|
||||||
account_menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
account_menu.append_section(None, &app_section);
|
||||||
let sign_out = gio::Menu::new();
|
account_menu.append_section(None, &account_section);
|
||||||
sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
|
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||||
account_menu.append_section(None, &sign_out);
|
row.append(&avatar);
|
||||||
let account_button = gtk4::MenuButton::builder()
|
row.append(&account_name);
|
||||||
.icon_name("view-more-symbolic")
|
row.append(>k4::Image::from_icon_name("view-more-symbolic"));
|
||||||
.tooltip_text(gettext("Account"))
|
let account = gtk4::MenuButton::builder()
|
||||||
|
.child(&row)
|
||||||
|
.tooltip_text(gettext("Main menu"))
|
||||||
.menu_model(&account_menu)
|
.menu_model(&account_menu)
|
||||||
.valign(gtk4::Align::Center)
|
.direction(gtk4::ArrowType::Up)
|
||||||
|
.primary(true)
|
||||||
.build();
|
.build();
|
||||||
account_button.add_css_class("flat");
|
account.add_css_class("flat");
|
||||||
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
|
||||||
account.add_css_class("sidebar-account");
|
account.add_css_class("sidebar-account");
|
||||||
account.append(&avatar);
|
|
||||||
account.append(&account_name);
|
|
||||||
account.append(&account_button);
|
|
||||||
|
|
||||||
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||||
footer.add_css_class("sidebar-footer");
|
footer.add_css_class("sidebar-footer");
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// The "›" between two breadcrumb segments.
|
||||||
|
pub(crate) fn crumb_separator() -> gtk4::Label {
|
||||||
|
let sep = gtk4::Label::new(Some("›"));
|
||||||
|
sep.add_css_class("dim-label");
|
||||||
|
sep
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last breadcrumb segment, the folder on screen: a heading rather than a
|
||||||
|
/// button. It carries a flat button's side padding (`.crumb-current`), so the
|
||||||
|
/// "›" before it sits as far from it as from the button before.
|
||||||
|
pub(crate) fn crumb_current(label: &str) -> gtk4::Widget {
|
||||||
|
let l = gtk4::Label::builder()
|
||||||
|
.label(label)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||||
|
.build();
|
||||||
|
l.add_css_class("heading");
|
||||||
|
l.add_css_class("crumb-current");
|
||||||
|
l.upcast()
|
||||||
|
}
|
||||||
@@ -132,8 +132,13 @@ impl FileList {
|
|||||||
// and the grid collapses to a single column. Allowing a mid-word
|
// and the grid collapses to a single column. Allowing a mid-word
|
||||||
// break is what keeps the two-line-then-ellipsis budget below
|
// break is what keeps the two-line-then-ellipsis budget below
|
||||||
// enforceable for *every* name rather than only the ones that
|
// enforceable for *every* name rather than only the ones that
|
||||||
// happen to have spaces.
|
// happen to have spaces. Pango marks such a break with a hyphen
|
||||||
|
// that is not in the name ("SteamSetup.e-xe"); a file name is
|
||||||
|
// not prose, so break it bare, as Nautilus does.
|
||||||
|
let no_hyphens = gtk4::pango::AttrList::new();
|
||||||
|
no_hyphens.insert(gtk4::pango::AttrInt::new_insert_hyphens(false));
|
||||||
let label = gtk4::Label::builder()
|
let label = gtk4::Label::builder()
|
||||||
|
.attributes(&no_hyphens)
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
.justify(gtk4::Justification::Center)
|
.justify(gtk4::Justification::Center)
|
||||||
.max_width_chars(13)
|
.max_width_chars(13)
|
||||||
|
|||||||
@@ -128,20 +128,34 @@ impl ActionMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a menu opened over `anchor` should live in the widget tree: the
|
/// Where a menu opened over `anchor` should live in the widget tree.
|
||||||
/// outermost grid or list view around it, or `anchor` itself. CSS descendant
|
///
|
||||||
/// selectors reach through a popover to its parent, and themes style the
|
/// Not inside a list item: CSS descendant selectors reach through a popover to
|
||||||
/// insides of list items (`gridview > child box { margin: 12px }` in
|
/// its parent, and themes style the insides of list items (`gridview > child
|
||||||
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide
|
/// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those
|
||||||
/// margin around every section. Under the view itself it is no item's child.
|
/// rules up and puts a wide margin around every section. So the search starts
|
||||||
|
/// at the outermost grid or list view around `anchor`.
|
||||||
|
///
|
||||||
|
/// Not on a widget without a layout manager either: a popover is sized and
|
||||||
|
/// placed again when its parent's layout manager allocates it. The list views
|
||||||
|
/// and `ScrolledWindow` have none, so a menu parented there kept the size it was
|
||||||
|
/// first given, often too small, and opened scrolling. The host is the first
|
||||||
|
/// widget from there up that has one.
|
||||||
fn popover_host(anchor: >k4::Widget) -> gtk4::Widget {
|
fn popover_host(anchor: >k4::Widget) -> gtk4::Widget {
|
||||||
let mut host = anchor.clone();
|
let mut start = anchor.clone();
|
||||||
let mut widget = anchor.parent();
|
let mut widget = anchor.parent();
|
||||||
while let Some(w) = widget {
|
while let Some(w) = widget {
|
||||||
widget = w.parent();
|
widget = w.parent();
|
||||||
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
|
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
|
||||||
host = w;
|
start = w;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
host
|
let mut host = Some(start.clone());
|
||||||
|
while let Some(w) = host {
|
||||||
|
if w.layout_manager().is_some() {
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
host = w.parent();
|
||||||
|
}
|
||||||
|
start
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub(crate) mod compat;
|
pub(crate) mod compat;
|
||||||
|
pub(crate) mod crumb;
|
||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
pub(crate) mod file_list;
|
pub(crate) mod file_list;
|
||||||
pub(crate) mod menu;
|
pub(crate) mod menu;
|
||||||
|
|||||||
@@ -127,6 +127,26 @@ impl Drop for Busy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A section's action, such as Send Invitations. It goes under the section's
|
||||||
|
/// rows, at their right edge, rather than in a row of its own: a row in a boxed
|
||||||
|
/// list reads as a setting, and a button alone in one looks misplaced.
|
||||||
|
fn group_button(label: &str) -> gtk4::Button {
|
||||||
|
let button = gtk4::Button::builder()
|
||||||
|
.label(label)
|
||||||
|
.halign(gtk4::Align::End)
|
||||||
|
.margin_top(12)
|
||||||
|
.build();
|
||||||
|
button.add_css_class("suggested-action");
|
||||||
|
button
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A section's only row while the share is being read.
|
||||||
|
fn loading_row() -> adw::ActionRow {
|
||||||
|
let row = adw::ActionRow::builder().title(gettext("Loading…")).build();
|
||||||
|
row.add_prefix(&spinner());
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
/// How an open Share dialog addresses its node.
|
/// How an open Share dialog addresses its node.
|
||||||
///
|
///
|
||||||
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
||||||
@@ -287,26 +307,17 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
.selected(0)
|
.selected(0)
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build();
|
// The role goes with the addresses it applies to, at the end of their row.
|
||||||
role_wrap.add_suffix(&role_drop);
|
role_drop.set_tooltip_text(Some(&gettext("Role")));
|
||||||
|
email_row.add_suffix(&role_drop);
|
||||||
let message_row = adw::EntryRow::builder()
|
let message_row = adw::EntryRow::builder()
|
||||||
.title(gettext("Message (optional)"))
|
.title(gettext("Message (optional)"))
|
||||||
.build();
|
.build();
|
||||||
let invite_btn = gtk4::Button::builder()
|
let invite_btn = group_button(&gettext("Send Invitations"));
|
||||||
.label(gettext("Send Invitations"))
|
|
||||||
.halign(gtk4::Align::End)
|
|
||||||
.margin_top(6)
|
|
||||||
.build();
|
|
||||||
invite_btn.add_css_class("suggested-action");
|
|
||||||
invite_btn.set_sensitive(false);
|
invite_btn.set_sensitive(false);
|
||||||
let invite_wrap = adw::PreferencesRow::builder()
|
|
||||||
.activatable(false)
|
|
||||||
.child(&invite_btn)
|
|
||||||
.build();
|
|
||||||
invite_group.add(&email_row);
|
invite_group.add(&email_row);
|
||||||
invite_group.add(&role_wrap);
|
|
||||||
invite_group.add(&message_row);
|
invite_group.add(&message_row);
|
||||||
invite_group.add(&invite_wrap);
|
invite_group.add(&invite_btn);
|
||||||
|
|
||||||
let people = adw::PreferencesGroup::builder()
|
let people = adw::PreferencesGroup::builder()
|
||||||
.title(gettext("People with access"))
|
.title(gettext("People with access"))
|
||||||
@@ -315,21 +326,18 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
.title(gettext("Public link"))
|
.title(gettext("Public link"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
// Until the share is read, each section says so rather than standing
|
||||||
content.set_margin_top(12);
|
// empty under its title.
|
||||||
content.set_margin_bottom(12);
|
let people_loading = loading_row();
|
||||||
content.set_margin_start(12);
|
people.add(&people_loading);
|
||||||
content.set_margin_end(12);
|
let link_loading = loading_row();
|
||||||
content.append(&invite_group);
|
link_group.add(&link_loading);
|
||||||
content.append(&people);
|
|
||||||
content.append(&link_group);
|
let content = adw::PreferencesPage::new();
|
||||||
let clamp = adw::Clamp::builder().child(&content).build();
|
content.add(&invite_group);
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
content.add(&people);
|
||||||
.vexpand(true)
|
content.add(&link_group);
|
||||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
toolbar.set_content(Some(&content));
|
||||||
.child(&clamp)
|
|
||||||
.build();
|
|
||||||
toolbar.set_content(Some(&scroll));
|
|
||||||
|
|
||||||
let dialog = adw::Dialog::builder()
|
let dialog = adw::Dialog::builder()
|
||||||
.title(pgettext("verb", "Share"))
|
.title(pgettext("verb", "Share"))
|
||||||
@@ -343,8 +351,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
target,
|
target,
|
||||||
people,
|
people,
|
||||||
link_group,
|
link_group,
|
||||||
people_rows: RefCell::new(Vec::new()),
|
people_rows: RefCell::new(vec![people_loading.upcast()]),
|
||||||
link_rows: RefCell::new(Vec::new()),
|
link_rows: RefCell::new(vec![link_loading.upcast()]),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check the addresses as they are typed: a malformed one marks the row as
|
// Check the addresses as they are typed: a malformed one marks the row as
|
||||||
@@ -430,17 +438,35 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
|||||||
repaint_share_link(&state, link.as_ref());
|
repaint_share_link(&state, link.as_ref());
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, .. })) => {
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
share_dialog_unread(&state);
|
||||||
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
|
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
|
||||||
}
|
}
|
||||||
_ => toast_error(
|
_ => {
|
||||||
&state.ui,
|
share_dialog_unread(&state);
|
||||||
&gettext("Couldn't load sharing"),
|
toast_error(
|
||||||
&gettext("The Proton Drive service didn't respond."),
|
&state.ui,
|
||||||
),
|
&gettext("Couldn't load sharing"),
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The share couldn't be read: what the sections showed (rows still loading,
|
||||||
|
/// or ones that may now be stale) gives way to one line that says so.
|
||||||
|
fn share_dialog_unread(state: &Rc<ShareDialog>) {
|
||||||
|
for row in state.people_rows.borrow_mut().drain(..) {
|
||||||
|
state.people.remove(&row);
|
||||||
|
}
|
||||||
|
for row in state.link_rows.borrow_mut().drain(..) {
|
||||||
|
state.link_group.remove(&row);
|
||||||
|
}
|
||||||
|
let row = dim_row(&gettext("Couldn't load sharing"));
|
||||||
|
state.people.add(&row);
|
||||||
|
state.people_rows.borrow_mut().push(row.upcast());
|
||||||
|
}
|
||||||
|
|
||||||
/// Rebuild the "People with access" rows from a fresh share listing.
|
/// Rebuild the "People with access" rows from a fresh share listing.
|
||||||
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
|
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
|
||||||
for row in state.people_rows.borrow_mut().drain(..) {
|
for row in state.people_rows.borrow_mut().drain(..) {
|
||||||
@@ -455,15 +481,18 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
// A member is the plain case and needs no word; an invitation says
|
||||||
|
// it is still waiting on the other side.
|
||||||
let subtitle = match entry.kind {
|
let subtitle = match entry.kind {
|
||||||
ShareEntryKind::Member => gettext("Member"),
|
ShareEntryKind::Member => String::new(),
|
||||||
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
|
ShareEntryKind::ProtonInvite => gettext("Invitation pending"),
|
||||||
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
|
ShareEntryKind::ExternalInvite => gettext("Invitation pending, no Proton account yet"),
|
||||||
};
|
};
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&entry.email)
|
.title(&entry.email)
|
||||||
.subtitle(&subtitle)
|
.subtitle(&subtitle)
|
||||||
.build();
|
.build();
|
||||||
|
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
|
||||||
|
|
||||||
// External invites can't have their role changed; show it read-only.
|
// External invites can't have their role changed; show it read-only.
|
||||||
// Members and Proton invites get a role dropdown.
|
// Members and Proton invites get a role dropdown.
|
||||||
@@ -653,16 +682,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
.build();
|
.build();
|
||||||
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
||||||
expiry_row.add_suffix(&expiry_drop);
|
expiry_row.add_suffix(&expiry_drop);
|
||||||
let create = gtk4::Button::builder()
|
let create = group_button(&gettext("Create Public Link"));
|
||||||
.label(gettext("Create Public Link"))
|
|
||||||
.halign(gtk4::Align::End)
|
|
||||||
.margin_top(6)
|
|
||||||
.build();
|
|
||||||
create.add_css_class("suggested-action");
|
|
||||||
let create_wrap = adw::PreferencesRow::builder()
|
|
||||||
.activatable(false)
|
|
||||||
.child(&create)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Enter in the password field creates the link.
|
// Enter in the password field creates the link.
|
||||||
let btn = create.clone();
|
let btn = create.clone();
|
||||||
@@ -689,11 +709,11 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
state.link_group.add(&role_row);
|
state.link_group.add(&role_row);
|
||||||
state.link_group.add(&pw_row);
|
state.link_group.add(&pw_row);
|
||||||
state.link_group.add(&expiry_row);
|
state.link_group.add(&expiry_row);
|
||||||
state.link_group.add(&create_wrap);
|
state.link_group.add(&create);
|
||||||
rows.push(role_row.upcast());
|
rows.push(role_row.upcast());
|
||||||
rows.push(pw_row.upcast());
|
rows.push(pw_row.upcast());
|
||||||
rows.push(expiry_row.upcast());
|
rows.push(expiry_row.upcast());
|
||||||
rows.push(create_wrap.upcast());
|
rows.push(create.upcast());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*state.link_rows.borrow_mut() = rows;
|
*state.link_rows.borrow_mut() = rows;
|
||||||
|
|||||||
+60
-17
@@ -88,8 +88,9 @@ struct DriveState {
|
|||||||
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
||||||
/// transfer names the file and its percentage; several collapse to counts so the
|
/// transfer names the file and its percentage; several collapse to counts so the
|
||||||
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
|
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
|
||||||
/// running job speaks for itself — a scan or an index rebuild is still "busy",
|
/// running job speaks for itself — a scan or an index rebuild is still work
|
||||||
/// and the tray saying nothing there reads as "finished".
|
/// going on, and the menu saying nothing there reads as "finished". The icon is
|
||||||
|
/// [`busy`]'s call, not this line's.
|
||||||
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
||||||
match items {
|
match items {
|
||||||
[] => match jobs.first() {
|
[] => match jobs.first() {
|
||||||
@@ -143,12 +144,12 @@ struct DriveTray {
|
|||||||
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||||
match send(socket, &Request::Status) {
|
match send(socket, &Request::Status) {
|
||||||
Ok(status @ Response::Status { .. }) => {
|
Ok(status @ Response::Status { .. }) => {
|
||||||
// Same daemon is up, so a cheap follow-up request gives the sync line.
|
// Same daemon is up, so a cheap follow-up request says what moves.
|
||||||
let sync = match send(socket, &Request::GetQueueStatus) {
|
let (items, jobs) = match send(socket, &Request::GetQueueStatus) {
|
||||||
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
Ok(Response::Transfers { items, jobs }) => (items, jobs),
|
||||||
_ => String::new(),
|
_ => (Vec::new(), Vec::new()),
|
||||||
};
|
};
|
||||||
status_state(&status, sync, default_mountpoint)
|
status_state(&status, &items, &jobs, default_mountpoint)
|
||||||
}
|
}
|
||||||
// Socket answered but with something unexpected — treat as up but odd.
|
// Socket answered but with something unexpected — treat as up but odd.
|
||||||
Ok(_) => unexpected_reply(default_mountpoint),
|
Ok(_) => unexpected_reply(default_mountpoint),
|
||||||
@@ -217,9 +218,14 @@ fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The state a running daemon's [`Response::Status`] describes, with `sync`
|
/// The state a running daemon's [`Response::Status`] describes, with `items`
|
||||||
/// saying what it is moving right now.
|
/// and `jobs` saying what it is working on right now.
|
||||||
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState {
|
fn status_state(
|
||||||
|
status: &Response,
|
||||||
|
items: &[TransferItem],
|
||||||
|
jobs: &[JobItem],
|
||||||
|
default_mountpoint: &Path,
|
||||||
|
) -> DriveState {
|
||||||
let Response::Status {
|
let Response::Status {
|
||||||
mountpoint,
|
mountpoint,
|
||||||
pinned,
|
pinned,
|
||||||
@@ -239,7 +245,7 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
|||||||
paused,
|
paused,
|
||||||
failing_ops,
|
failing_ops,
|
||||||
online,
|
online,
|
||||||
queued.is_some() || !sync.is_empty(),
|
queued.is_some() || busy(items, jobs),
|
||||||
);
|
);
|
||||||
DriveState {
|
DriveState {
|
||||||
line: match (online, queued) {
|
line: match (online, queued) {
|
||||||
@@ -278,11 +284,19 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
|||||||
mounted: true,
|
mounted: true,
|
||||||
paused,
|
paused,
|
||||||
mountpoint: PathBuf::from(mountpoint),
|
mountpoint: PathBuf::from(mountpoint),
|
||||||
sync,
|
sync: sync_line(items, jobs),
|
||||||
failing: failing_ops,
|
failing: failing_ops,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the daemon is changing something: bytes moving, or a job the user
|
||||||
|
/// is waiting on. A mirror folder's routine scan and the like are background
|
||||||
|
/// jobs; they still get the menu line, but the icon spinning every two minutes
|
||||||
|
/// for a scan that finds nothing reads as constant syncing.
|
||||||
|
fn busy(items: &[TransferItem], jobs: &[JobItem]) -> bool {
|
||||||
|
!items.is_empty() || jobs.iter().any(|j| !j.background)
|
||||||
|
}
|
||||||
|
|
||||||
/// The phase a running mount is in. A pause is the user's own doing and says
|
/// The phase a running mount is in. A pause is the user's own doing and says
|
||||||
/// so first; failures outrank everything else the daemon reports.
|
/// so first; failures outrank everything else the daemon reports.
|
||||||
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
||||||
@@ -547,11 +561,7 @@ fn main() -> Result<()> {
|
|||||||
heard.take(feed);
|
heard.take(feed);
|
||||||
}
|
}
|
||||||
let st = match &heard.status {
|
let st = match &heard.status {
|
||||||
Some(status) => status_state(
|
Some(status) => status_state(status, &heard.items, &heard.jobs, &default_mountpoint),
|
||||||
status,
|
|
||||||
sync_line(&heard.items, &heard.jobs),
|
|
||||||
&default_mountpoint,
|
|
||||||
),
|
|
||||||
// Lost the daemon: ask once, which describes the sign-in instead.
|
// Lost the daemon: ask once, which describes the sign-in instead.
|
||||||
None => poll_state(&socket, &default_mountpoint),
|
None => poll_state(&socket, &default_mountpoint),
|
||||||
};
|
};
|
||||||
@@ -596,6 +606,39 @@ mod tests {
|
|||||||
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
|
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn job(title: &str, background: bool) -> JobItem {
|
||||||
|
JobItem {
|
||||||
|
title: title.to_string(),
|
||||||
|
detail: String::new(),
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
background,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_routine_scan_is_not_busy_but_a_sync_is() {
|
||||||
|
assert!(!busy(&[], &[]));
|
||||||
|
assert!(!busy(&[], &[job("Checking Documents", true)]));
|
||||||
|
assert!(busy(
|
||||||
|
&[],
|
||||||
|
&[
|
||||||
|
job("Checking Documents", true),
|
||||||
|
job("Syncing Photos", false)
|
||||||
|
]
|
||||||
|
));
|
||||||
|
assert!(busy(&[], &[job("Uploading files", false)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_job_from_an_older_daemon_counts_as_busy() {
|
||||||
|
let job: JobItem = serde_json::from_str(
|
||||||
|
r#"{"title":"Checking Documents","detail":"","done":0,"total":0}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!job.background);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_issues_item_counts_in_the_singular_and_plural() {
|
fn the_issues_item_counts_in_the_singular_and_plural() {
|
||||||
assert_eq!(issues_label(1), "1 issue — View");
|
assert_eq!(issues_label(1), "1 issue — View");
|
||||||
|
|||||||
+10
-2
@@ -310,8 +310,10 @@ its changes go through the queue like My files.
|
|||||||
A filesystem watcher (`notify`) and a remote poll every 120 s feed a debounced pass (2 s, at most
|
A filesystem watcher (`notify`) and a remote poll every 120 s feed a debounced pass (2 s, at most
|
||||||
30 s). Passes are serialized per daemon. A pass:
|
30 s). Passes are serialized per daemon. A pass:
|
||||||
|
|
||||||
1. **Walks the local tree.** Any `readdir`, metadata or permission failure marks the scan
|
1. **Walks the local tree.** A path the user may not read, an entry that vanishes mid-scan and a
|
||||||
incomplete, and an incomplete scan cannot delete anything.
|
name that is not UTF-8 are left out, and the pass drops everything under them, as it drops
|
||||||
|
ignored paths, so a left-out path is never read as deleted. An unreadable root and any other
|
||||||
|
I/O error fail the pass.
|
||||||
2. **Walks the remote tree.**
|
2. **Walks the remote tree.**
|
||||||
3. **Loads the baseline**, the `sync_entry` rows: both sides as of the last successful pass.
|
3. **Loads the baseline**, the `sync_entry` rows: both sides as of the last successful pass.
|
||||||
4. **Classifies each path** by comparing local, remote and baseline, with `(mtime, size)` as the
|
4. **Classifies each path** by comparing local, remote and baseline, with `(mtime, size)` as the
|
||||||
@@ -377,6 +379,12 @@ Restore lists the device's folders, proposes a local path from the profile, and
|
|||||||
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).
|
||||||
|
|
||||||
|
`Core::migrate_device` moves another device's top-level folders under this machine's device root.
|
||||||
|
Device roots are separate shares on the main volume, so each folder is one server-side move that
|
||||||
|
keeps its uid. A name already taken here gets the other device's name as a suffix. A synced folder
|
||||||
|
that already tracked one of them keeps its `remote_uid` and only has its `remote_share_id`
|
||||||
|
re-pointed. The moved folders are then offered by the ordinary restore.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Control protocol
|
## 8. Control protocol
|
||||||
|
|||||||
+123
@@ -81,6 +81,7 @@ original wording.
|
|||||||
|
|
||||||
| Entry | Title | What is left |
|
| Entry | Title | What is left |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| [B199](#b199--a-stopped-share-stays-on-the-shared-by-me-page-for-minutes) | A stopped share stays on the Shared by me page for minutes | The share itself is not deleted; the SDK has no call for it |
|
||||||
| [B88](#b88--pdfs-pin-rename-move-delete-and-sharing-still-cannot-name-a-path-under-a-secondary-mount) | `pdfs pin`, `rename`, `move`, `delete` and sharing still cannot name a path under a secondary mount | `pin`, `unpin`, opening a file, thumbnails, uploads, sharing and versions |
|
| [B88](#b88--pdfs-pin-rename-move-delete-and-sharing-still-cannot-name-a-path-under-a-secondary-mount) | `pdfs pin`, `rename`, `move`, `delete` and sharing still cannot name a path under a secondary mount | `pin`, `unpin`, opening a file, thumbnails, uploads, sharing and versions |
|
||||||
| [B72](#b72--pdfs-trash-never-returns) | `pdfs trash` never returns | The slowness of the first refresh is unmeasured |
|
| [B72](#b72--pdfs-trash-never-returns) | `pdfs trash` never returns | The slowness of the first refresh is unmeasured |
|
||||||
| [B71](#b71--conflict-sweep-is-not-production-ready-auto-trash-loop-crit-13) | Conflict sweep is not production-ready (auto-trash loop, CRIT-13) | Items 5 to 10 of the entry |
|
| [B71](#b71--conflict-sweep-is-not-production-ready-auto-trash-loop-crit-13) | Conflict sweep is not production-ready (auto-trash loop, CRIT-13) | Items 5 to 10 of the entry |
|
||||||
@@ -100,6 +101,9 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [B198](#b198--switching-to-online-only-deletes-local-files-that-never-reached-drive) | Switching to online-only deletes local files that never reached Drive |
|
||||||
|
| [B197](#b197--switching-to-online-only-trashes-synced-files-that-became-ignored) | Switching to online-only trashes synced files that became ignored |
|
||||||
|
| [B196](#b196--one-unreadable-folder-stops-the-whole-synced-folder-from-syncing) | One unreadable folder stops the whole synced folder from syncing |
|
||||||
| [B195](#b195--the-app-crashes-when-proton-asks-for-a-captcha-on-secureblue) | The app crashes when Proton asks for a CAPTCHA on secureblue |
|
| [B195](#b195--the-app-crashes-when-proton-asks-for-a-captcha-on-secureblue) | The app crashes when Proton asks for a CAPTCHA on secureblue |
|
||||||
| [B193](#b193--deleting-a-file-whose-create-failed-can-trash-a-file-of-the-users-on-drive) | Deleting a file whose create failed can trash a file of the user's on Drive |
|
| [B193](#b193--deleting-a-file-whose-create-failed-can-trash-a-file-of-the-users-on-drive) | Deleting a file whose create failed can trash a file of the user's on Drive |
|
||||||
| [B191](#b191--a-file-deleted-after-its-create-failed-and-was-written-again-can-stay-on-drive) | A file deleted after its create failed and was written again can stay on Drive |
|
| [B191](#b191--a-file-deleted-after-its-create-failed-and-was-written-again-can-stay-on-drive) | A file deleted after its create failed and was written again can stay on Drive |
|
||||||
@@ -298,6 +302,121 @@ a live account since.
|
|||||||
|
|
||||||
Newest first.
|
Newest first.
|
||||||
|
|
||||||
|
### B199 — A stopped share stays on the Shared by me page for minutes
|
||||||
|
|
||||||
|
**Status:** Partly fixed. The share itself is still not deleted.
|
||||||
|
**Found:** 2026-10-09, reported from the app: after **Stop Sharing**, the row stayed, and an
|
||||||
|
older share showed with no people and no link.
|
||||||
|
**Where:** `crates/pdfs-fuse/src/sharing.rs`, `Core::stop_sharing_for_uid` and
|
||||||
|
`Core::list_shared_by_me`; `crates/pdfs-gui/src/app/pages/shared_by_me.rs`, `prompt_stop_sharing`
|
||||||
|
|
||||||
|
**Repro:** Share a folder with a person, then choose **Stop Sharing…** on the Shared by me page.
|
||||||
|
The row stays, now with "—" for people and link, until a reload some minutes later.
|
||||||
|
|
||||||
|
**Cause.** The Rust SDK has no call that deletes a share. Stop Sharing removes the public link, the
|
||||||
|
invitations and the members one by one, and leaves an empty share behind. Drive deletes an empty
|
||||||
|
share on its own, but only after a while, and until then `GET v2/volumes/{vid}/shares` still lists
|
||||||
|
it. The JS SDK ends `unshareNode` with `DELETE drive/shares/{shareId}?Force=1` for exactly this
|
||||||
|
reason.
|
||||||
|
|
||||||
|
**Fix.** The daemon leaves a share with no member, no invitation and no link out of the Shared by
|
||||||
|
me listing, unless one of those lookups failed. The app also drops a stopped item from the page as
|
||||||
|
soon as the request succeeds, before the confirming reload. Left: a `delete_share` call in
|
||||||
|
`proton-drive-rs`, made at the end of `stop_sharing_for_uid`, so the share is really gone.
|
||||||
|
|
||||||
|
**Test:** none automated; the listing needs a live account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B198 — Switching to online-only deletes local files that never reached Drive
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified).
|
||||||
|
**Found:** 2026-10-08, reading the switch while fixing B196.
|
||||||
|
**Where:** `crates/pdfs-fuse/src/devices.rs`, `Core::apply_sync_folder_mode`;
|
||||||
|
`crates/pdfs-fuse/src/sync.rs`, `mirror_tree_unsynced`
|
||||||
|
|
||||||
|
**Repro:** Mirror a project folder with a `.git/` in it, then switch it to **Online only**.
|
||||||
|
`.git/` is gone from the disk and was never on Drive.
|
||||||
|
|
||||||
|
**Cause.** The switch evicts the whole local tree once the folder is `idle`. `idle` only says the
|
||||||
|
last pass applied everything it tried, and a pass never tries an ignored path (`.git/` is ignored
|
||||||
|
by default), a symlink, or a folder it cannot read (B196). It also defers a file that is open for
|
||||||
|
writing without leaving `idle`, and a file can change after the pass ends. The eviction deleted
|
||||||
|
all of those.
|
||||||
|
|
||||||
|
**Fix.** Before the mode is written and anything is evicted, the switch walks the folder against
|
||||||
|
the baseline, as a move out of a mirror folder already does, and refuses when something is only on
|
||||||
|
this disk. The message names the path; an ignored one is called excluded from sync. A tracked file
|
||||||
|
deleted here but not yet on Drive also refuses. An ignored one does not, since its baseline row
|
||||||
|
stays for good and would hold the switch forever.
|
||||||
|
|
||||||
|
**Test:** unit tests `a_fully_synced_mirror_folder_may_go_online_only`,
|
||||||
|
`an_ignored_folder_only_this_disk_has_holds_the_switch_back`,
|
||||||
|
`a_deleted_ignored_path_does_not_hold_the_switch_back`,
|
||||||
|
`an_edit_after_the_last_pass_holds_the_switch_back`, `a_new_file_at_the_root_holds_the_switch_back`,
|
||||||
|
`an_unreadable_folder_holds_the_switch_back` and `a_symlink_holds_the_switch_back`. The switch
|
||||||
|
itself has not been driven against a live account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B197 — Switching to online-only trashes synced files that became ignored
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified).
|
||||||
|
**Found:** 2026-10-08, reading the push pass while fixing B196.
|
||||||
|
**Where:** `crates/pdfs-fuse/src/sync.rs`, `Core::push_pass`; `crates/pdfs-fuse/src/sync/planner.rs`,
|
||||||
|
`local_deletions`
|
||||||
|
|
||||||
|
**Repro:** Mirror a folder until `build/` is on Drive, add `build/` to `.pdfsignore`, then switch
|
||||||
|
the folder to **Online only**. `build/` goes to the Drive trash, and the switch then deletes the
|
||||||
|
local copy.
|
||||||
|
|
||||||
|
**Cause.** The push pass that runs before the switch read its deletions as "in the baseline, not in
|
||||||
|
the local walk". The walk skips ignored paths, so every synced path that later became ignored read
|
||||||
|
as deleted. A full pass does not do this: it drops ignored paths from classification, which is
|
||||||
|
what the user guide promises ("Ignoring never deletes anything").
|
||||||
|
|
||||||
|
**Fix.** The push pass reads its deletions from the same filtered baseline the wipe guard checks:
|
||||||
|
without ignored paths, and without paths the scan could not read (B196).
|
||||||
|
|
||||||
|
**Test:** unit tests `a_push_pass_never_reads_an_ignored_path_as_deleted` and
|
||||||
|
`local_deletions_come_out_shallowest_first`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B196 — One unreadable folder stops the whole synced folder from syncing
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified). The reporter has not run the fix yet.
|
||||||
|
**Found:** 2026-10-08, [#33](https://github.com/narrrl/proton-drive-linux/issues/33). A synced
|
||||||
|
`~/Documents` held a restic cache a Docker container had created, which the user may not read.
|
||||||
|
Every pass failed with `Couldn't sync Documents: read …/restic/cache/…/snapshots: Permission
|
||||||
|
denied`, and nothing else in `Documents` synced.
|
||||||
|
**Where:** `crates/pdfs-fuse/src/sync.rs`, `walk_local_tree`; `crates/pdfs-fuse/src/sync/planner.rs`,
|
||||||
|
`Unscanned`
|
||||||
|
|
||||||
|
**Repro:** In a mirror folder, `sudo mkdir locked && sudo chmod 700 locked`, then sync.
|
||||||
|
|
||||||
|
**Cause.** B55 made every scan failure fatal to the pass, because a path left out of the scan read
|
||||||
|
as a local deletion and was trashed on Drive. That was safe, but a folder the user may not read is
|
||||||
|
not transient: the pass failed on every poll, for good. The same held for a file deleted between
|
||||||
|
the listing and its `stat`, which a busy folder hits now and then, and for a name that is not UTF-8.
|
||||||
|
|
||||||
|
**Fix.** The scan leaves such a path out and records it, and the pass drops everything under it
|
||||||
|
from classification, the wipe guard and the push pass's deletions, as it already did for ignored
|
||||||
|
paths. A left-out path is then neither deleted on Drive nor downloaded into. A folder that lists
|
||||||
|
but does not allow `stat` is left out whole. An unreadable or absent root, and every other I/O
|
||||||
|
error, still fails the pass. The activity feed names the unreadable paths once each time the set
|
||||||
|
changes, and the folder stays `idle`. Adding the folder to `.pdfsignore` was already a workaround
|
||||||
|
and still silences the report.
|
||||||
|
|
||||||
|
**Test:** unit tests `an_unreadable_subdirectory_is_left_out_and_the_rest_scans`,
|
||||||
|
`a_listable_but_unsearchable_folder_is_left_out_whole`,
|
||||||
|
`a_path_the_scan_left_out_is_never_a_local_deletion`,
|
||||||
|
`unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings` and
|
||||||
|
`a_non_utf8_name_is_left_out_and_reported`. `an_unreadable_root_fails_the_scan` and
|
||||||
|
`an_absent_root_fails_the_scan` still hold.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### B195 — The app crashes when Proton asks for a CAPTCHA on secureblue
|
### B195 — The app crashes when Proton asks for a CAPTCHA on secureblue
|
||||||
|
|
||||||
**Status:** Fixed (unverified). On 2026-10-08 the reporter confirmed that the app survives, but
|
**Status:** Fixed (unverified). On 2026-10-08 the reporter confirmed that the app survives, but
|
||||||
@@ -4831,6 +4950,10 @@ subdirectory holding a file, asserting the error names the subtree),
|
|||||||
strictness must not cost a working pass, so a healthy tree still reports every entry and still
|
strictness must not cost a working pass, so a healthy tree still reports every entry and still
|
||||||
skips symlinks by design.
|
skips symlinks by design.
|
||||||
|
|
||||||
|
B196 later relaxed this: a path the user may not read, an entry that vanishes mid-scan and a name
|
||||||
|
that is not UTF-8 are left out of the pass instead of failing it, and the pass drops everything
|
||||||
|
under them from classification, so the B55 guarantee holds without the wedge.
|
||||||
|
|
||||||
Not covered: transient I/O errors, and an entry that disappears between `read_dir` and `stat`.
|
Not covered: transient I/O errors, and an entry that disappears between `read_dir` and `stat`.
|
||||||
Both are races that would need a filesystem shim to force; the code path they take is the same
|
Both are races that would need a filesystem shim to force; the code path they take is the same
|
||||||
`symlink_metadata` failure the permission test drives.
|
`symlink_metadata` failure the permission test drives.
|
||||||
|
|||||||
@@ -17,6 +17,47 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
once you are done. `pdfs login` now handles a CAPTCHA the same way instead of failing.
|
once you are done. `pdfs login` now handles a CAPTCHA the same way instead of failing.
|
||||||
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||||
|
|
||||||
|
## [3.2.0] — 2026-10-08
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **One unreadable folder stopped a synced folder from syncing at all.** A folder you may not
|
||||||
|
read, such as a cache a container created, failed every pass, so nothing else in the synced
|
||||||
|
folder reached Drive. Such a folder is now left out and named once in the activity feed, and
|
||||||
|
everything around it syncs. A file deleted during a scan, and a name that is not UTF-8, no
|
||||||
|
longer fail the pass either.
|
||||||
|
([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
|
||||||
|
- **Switching a folder to online-only could delete files that were never on Drive.** Ignored
|
||||||
|
folders such as `.git/`, symlinks, unreadable folders and files changed after the last sync were
|
||||||
|
deleted with the local copy. The switch now refuses and names what is only on this computer.
|
||||||
|
- **Switching to online-only trashed synced files that had become ignored.** Adding a synced path
|
||||||
|
to `.pdfsignore` and then switching moved it to the Drive trash. Ignoring never deletes now, as
|
||||||
|
the user guide says.
|
||||||
|
- **A stopped share stayed on the Shared by me page for minutes.** Stop Sharing now takes it off
|
||||||
|
the page at once, and an emptied share no longer shows with no people and no link.
|
||||||
|
- **The tray icon showed syncing every two minutes.** The routine check of a synced folder no
|
||||||
|
longer counts as syncing. Only work that changes files spins the icon. An edit in one synced
|
||||||
|
folder also no longer rescans all the others.
|
||||||
|
- **Right-click menus sometimes opened too short and had to be scrolled.**
|
||||||
|
- **The Share dialog looked unfinished.** Its buttons now sit under their sections instead of in a
|
||||||
|
row of their own. The role is chosen beside the addresses, people show with avatars, and both
|
||||||
|
sections show that they are loading.
|
||||||
|
- **Breadcrumbs spaced the "›" unevenly, and grid file names were broken with hyphens they do not
|
||||||
|
have.**
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **One main menu.** The menu button beside "Proton Drive" is gone. Preferences, Keyboard
|
||||||
|
Shortcuts, About and Sign Out are all in the account row at the bottom of the sidebar. F10 opens
|
||||||
|
it.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Move another computer's backup to this one.** **Move to This Computer…** on the Computers page,
|
||||||
|
or `pdfs devices migrate UID`, moves every folder another computer backs up, for example an old
|
||||||
|
Windows PC, into this computer's backup on Proton Drive. Then it offers them for syncing here. The
|
||||||
|
folders move on Proton Drive itself, so nothing is uploaded again. The other computer stays in the
|
||||||
|
account, without those folders.
|
||||||
|
|
||||||
## [3.1.2] — 2026-10-08
|
## [3.1.2] — 2026-10-08
|
||||||
|
|
||||||
Schema: **42**, unchanged.
|
Schema: **42**, unchanged.
|
||||||
@@ -1671,6 +1712,8 @@ 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.2.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.0
|
||||||
|
|
||||||
[3.1.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2
|
[3.1.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2
|
||||||
|
|
||||||
[3.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1
|
[3.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| `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 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). |
|
||||||
|
|
||||||
## Trash
|
## Trash
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,29 @@ pdfs sync restore --device UID
|
|||||||
The folders stay part of the other computer's backup and sync in both directions. This machine
|
The folders stay part of the other computer's backup and sync in both directions. This machine
|
||||||
does not take over that computer's backup.
|
does not take over that computer's backup.
|
||||||
|
|
||||||
|
### Moving another computer's backup here
|
||||||
|
|
||||||
|
To switch computers for good, for example from a Windows PC to this Linux machine, move the old
|
||||||
|
computer's folders into this computer's backup instead. Use **Move to This Computer…** on that
|
||||||
|
computer's row, or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdfs devices list # find the old computer's UID
|
||||||
|
pdfs devices migrate UID
|
||||||
|
```
|
||||||
|
|
||||||
|
Each folder moves on Proton Drive itself, so nothing is uploaded again and no storage is used
|
||||||
|
twice. A folder whose name this computer already uses gets the old computer's name added, for
|
||||||
|
example `Documents (DESKTOP-1)`. The restore picker then opens, as in
|
||||||
|
[Step 3](#step-3-restore-the-folders), so you choose where each folder goes here.
|
||||||
|
|
||||||
|
The old computer stays in your account, without those folders. Unlike
|
||||||
|
[adoption](#step-2-continue-the-old-computers-backup), this machine keeps its own name and backup.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Stop Proton Drive on the old computer first. A client still running there sees its folders
|
||||||
|
> disappear, and may upload them again as new.
|
||||||
|
|
||||||
## 3. How the restore works
|
## 3. How the restore works
|
||||||
|
|
||||||
This section is for the curious and for anyone debugging a restore.
|
This section is for the curious and for anyone debugging a restore.
|
||||||
|
|||||||
+9
-1
@@ -96,7 +96,9 @@ app and in the web app.
|
|||||||
Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount
|
Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount
|
||||||
of the folder, so the files stay visible but take no disk space until opened. Switch with the
|
of the folder, so the files stay visible but take no disk space until opened. Switch with the
|
||||||
**Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back
|
**Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back
|
||||||
with `pdfs sync mode <id> mirror`. `pdfs locations` lists every local place the service occupies.
|
with `pdfs sync mode <id> mirror`. The switch waits until everything in the folder is on Proton
|
||||||
|
Drive, and refuses while the folder holds something that never goes there: an ignored path such
|
||||||
|
as `.git/`, a symlink, or a folder you may not read. The message names it. `pdfs locations` lists every local place the service occupies.
|
||||||
|
|
||||||
### Moving between synced folders and My files
|
### Moving between synced folders and My files
|
||||||
|
|
||||||
@@ -146,6 +148,9 @@ places, and both apply:
|
|||||||
Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is
|
Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is
|
||||||
simply no longer tracked, and its copy on Proton Drive stays.
|
simply no longer tracked, and its copy on Proton Drive stays.
|
||||||
|
|
||||||
|
A folder you may not read, such as a cache a container created as root, is skipped the same way,
|
||||||
|
and the activity feed names it once. Add it to `.pdfsignore` to stop that message.
|
||||||
|
|
||||||
## Changes waiting for Proton Drive
|
## Changes waiting for Proton Drive
|
||||||
|
|
||||||
A change you made in the Proton Drive folder exists only on this computer until it reaches
|
A change you made in the Proton Drive folder exists only on this computer until it reaches
|
||||||
@@ -278,6 +283,9 @@ Proton Drive keeps earlier versions of every file. **Details → Versions…** i
|
|||||||
**Restore to This Computer…** downloads its folders here.
|
**Restore to This Computer…** downloads its folders here.
|
||||||
- **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
|
||||||
|
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.
|
||||||
|
|
||||||
## Search launcher
|
## Search launcher
|
||||||
|
|
||||||
|
|||||||
+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.1.2
|
pkgver=3.2.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-bin
|
pkgbase = proton-drive-for-linux-bin
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||||
pkgver = 3.1.1
|
pkgver = 3.1.2
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -21,21 +21,21 @@ pkgbase = proton-drive-for-linux-bin
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=3.1.1
|
provides = proton-drive-for-linux=3.1.2
|
||||||
provides = proton-drive-linux=3.1.1
|
provides = proton-drive-linux=3.1.2
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !strip
|
options = !strip
|
||||||
options = !debug
|
options = !debug
|
||||||
source = proton-drive-linux-3.1.1-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.1.1/proton-drive-linux-3.1.1-x86_64.tar.gz
|
source = proton-drive-linux-3.1.2-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.1.2/proton-drive-linux-3.1.2-x86_64.tar.gz
|
||||||
source = io.narl.proton-drive-linux-3.1.1.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/packaging/io.narl.proton-drive-linux.desktop
|
source = io.narl.proton-drive-linux-3.1.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.desktop
|
||||||
source = io.narl.proton-drive-linux-tray-3.1.1.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/packaging/io.narl.proton-drive-linux-tray.desktop
|
source = io.narl.proton-drive-linux-tray-3.1.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||||
source = io.narl.proton-drive-linux-3.1.1.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/packaging/io.narl.proton-drive-linux.svg
|
source = io.narl.proton-drive-linux-3.1.2.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.svg
|
||||||
source = proton-drive-3.1.1.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/packaging/proton-drive.service
|
source = proton-drive-3.1.2.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/proton-drive.service
|
||||||
source = LICENSE-3.1.1::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/LICENSE
|
source = LICENSE-3.1.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/LICENSE
|
||||||
sha256sums = e8a2ca20dc5a4b050a85056196eb7f3f4a428db2a8d41aa09b813e007609c0b6
|
sha256sums = 71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7
|
||||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-bin
|
pkgname=proton-drive-for-linux-bin
|
||||||
pkgver=3.1.1
|
pkgver=3.1.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -34,7 +34,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
|
|||||||
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||||
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
||||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||||
sha256sums=('e8a2ca20dc5a4b050a85056196eb7f3f4a428db2a8d41aa09b813e007609c0b6'
|
sha256sums=('71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7'
|
||||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-git
|
pkgbase = proton-drive-for-linux-git
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||||
pkgver = 3.1.1.r0.gedd62c5
|
pkgver = 3.1.2.r0.g07c9b67
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -24,8 +24,8 @@ pkgbase = proton-drive-for-linux-git
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=3.1.1.r0.gedd62c5
|
provides = proton-drive-for-linux=3.1.2.r0.g07c9b67
|
||||||
provides = proton-drive-linux=3.1.1.r0.gedd62c5
|
provides = proton-drive-linux=3.1.2.r0.g07c9b67
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-git
|
pkgname=proton-drive-for-linux-git
|
||||||
pkgver=3.1.1.r0.gedd62c5
|
pkgver=3.1.2.r0.g07c9b67
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux
|
pkgbase = proton-drive-for-linux
|
||||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||||
pkgver = 3.1.1
|
pkgver = 3.1.2
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -23,13 +23,13 @@ pkgbase = proton-drive-for-linux
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-linux=3.1.1
|
provides = proton-drive-linux=3.1.2
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux-bin
|
conflicts = proton-drive-for-linux-bin
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !lto
|
options = !lto
|
||||||
source = proton-drive-for-linux-3.1.1.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.1.1.tar.gz
|
source = proton-drive-for-linux-3.1.2.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.1.2.tar.gz
|
||||||
sha256sums = c16019c75959d5056c8ae486e0a4dc1f6346828ea93f346dfeae7da717629256
|
sha256sums = 260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1
|
||||||
|
|
||||||
pkgname = proton-drive-for-linux
|
pkgname = proton-drive-for-linux
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux
|
pkgname=proton-drive-for-linux
|
||||||
pkgver=3.1.1
|
pkgver=3.1.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -24,7 +24,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
|
|||||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||||
options=('!lto')
|
options=('!lto')
|
||||||
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
sha256sums=('c16019c75959d5056c8ae486e0a4dc1f6346828ea93f346dfeae7da717629256')
|
sha256sums=('260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
|
|||||||
+520
-476
File diff suppressed because it is too large.
Load diff
+495
-470
File diff suppressed because it is too large.
Load diff
+521
-476
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user