mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-07 17:12:10 +01:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c77c548d7e | ||
|
|
d4a14a17a8 | ||
|
|
5ca0272f15 | ||
|
|
800705ccb0 | ||
|
|
7ab14bb8f7 |
No files matched your search
@@ -696,6 +696,15 @@ enum SyncCmd {
|
|||||||
/// Local directory to sync.
|
/// Local directory to sync.
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
|
/// Sync a real local folder with an existing folder in My files.
|
||||||
|
/// The first pass preserves local-only files and keeps differing versions.
|
||||||
|
Attach {
|
||||||
|
/// Existing local directory; it remains a real local directory.
|
||||||
|
path: PathBuf,
|
||||||
|
/// Existing folder in My files, relative to the cloud mount or inside it.
|
||||||
|
#[arg(long)]
|
||||||
|
to: PathBuf,
|
||||||
|
},
|
||||||
/// List this machine's synced folders.
|
/// List this machine's synced folders.
|
||||||
List,
|
List,
|
||||||
/// Remove a synced folder by id.
|
/// Remove a synced folder by id.
|
||||||
@@ -1116,6 +1125,22 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
|||||||
.to_string();
|
.to_string();
|
||||||
ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)?
|
ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)?
|
||||||
}
|
}
|
||||||
|
SyncCmd::Attach { path, to } => {
|
||||||
|
let abs = std::fs::canonicalize(&path)
|
||||||
|
.with_context(|| format!("resolve {}", path.display()))?;
|
||||||
|
let local_path = abs
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| anyhow!("path is not valid UTF-8"))?
|
||||||
|
.to_owned();
|
||||||
|
let remote_path = to
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| anyhow!("remote path is not valid UTF-8"))?
|
||||||
|
.to_owned();
|
||||||
|
ok_or_bail(control_request(CtlRequest::AttachSyncFolder {
|
||||||
|
local_path,
|
||||||
|
remote_path,
|
||||||
|
})?)?
|
||||||
|
}
|
||||||
SyncCmd::List => {
|
SyncCmd::List => {
|
||||||
let response = control_request(CtlRequest::ListSyncFolders)?;
|
let response = control_request(CtlRequest::ListSyncFolders)?;
|
||||||
if emit_json(&response)? {
|
if emit_json(&response)? {
|
||||||
@@ -2294,6 +2319,9 @@ fn cmd_locations() -> Result<()> {
|
|||||||
MountKind::Device { sync_folder_id } => {
|
MountKind::Device { sync_folder_id } => {
|
||||||
format!("Device folder {sync_folder_id}")
|
format!("Device folder {sync_folder_id}")
|
||||||
}
|
}
|
||||||
|
MountKind::Attached { sync_folder_id } => {
|
||||||
|
format!("My files mirror {sync_folder_id}")
|
||||||
|
}
|
||||||
MountKind::Shared { .. } => "Shared folder".to_string(),
|
MountKind::Shared { .. } => "Shared folder".to_string(),
|
||||||
};
|
};
|
||||||
let mode = match location.pending_mode {
|
let mode = match location.pending_mode {
|
||||||
@@ -4268,6 +4296,7 @@ mod diagnose_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_synced_folder_in_error_is_reported() {
|
fn a_synced_folder_in_error_is_reported() {
|
||||||
let folder = |id: i64, state: &str| pdfs_core::control::SyncFolderInfo {
|
let folder = |id: i64, state: &str| pdfs_core::control::SyncFolderInfo {
|
||||||
|
kind: "device".into(),
|
||||||
id,
|
id,
|
||||||
local_path: format!("/home/u/f{id}"),
|
local_path: format!("/home/u/f{id}"),
|
||||||
remote_uid: "vol~link".into(),
|
remote_uid: "vol~link".into(),
|
||||||
|
|||||||
@@ -469,6 +469,13 @@ pub enum Request {
|
|||||||
/// 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
|
||||||
/// registering the device on first use. Replies with [`Response::Ok`].
|
/// registering the device on first use. Replies with [`Response::Ok`].
|
||||||
AddSyncFolder { local_path: String },
|
AddSyncFolder { local_path: String },
|
||||||
|
/// Keep a real local directory in step with an existing folder in My files.
|
||||||
|
/// First sync unions one-sided files and preserves differing versions.
|
||||||
|
/// Replies with [`Response::Ok`]; no device backup is created.
|
||||||
|
AttachSyncFolder {
|
||||||
|
local_path: String,
|
||||||
|
remote_path: String,
|
||||||
|
},
|
||||||
/// List this device's synced folders. Replies with [`Response::SyncFolders`].
|
/// List this device's synced folders. Replies with [`Response::SyncFolders`].
|
||||||
ListSyncFolders,
|
ListSyncFolders,
|
||||||
/// Remove a synced folder by id; `delete_remote` also trashes its cloud copy.
|
/// Remove a synced folder by id; `delete_remote` also trashes its cloud copy.
|
||||||
@@ -985,14 +992,17 @@ impl SyncIssue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
|
/// One device backup or attached My files mirror (in [`Response::SyncFolders`]).
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct SyncFolderInfo {
|
pub struct SyncFolderInfo {
|
||||||
/// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`].
|
/// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`].
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
/// `device` backup or `attached` My files mirror. Older daemons only had backups.
|
||||||
|
#[serde(default = "default_sync_folder_kind")]
|
||||||
|
pub kind: String,
|
||||||
/// Absolute local folder path.
|
/// Absolute local folder path.
|
||||||
pub local_path: String,
|
pub local_path: String,
|
||||||
/// The uid of the folder's remote root under the device root.
|
/// The uid of the remote root under the device root or My files.
|
||||||
pub remote_uid: String,
|
pub remote_uid: String,
|
||||||
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
|
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
|
||||||
pub mode: String,
|
pub mode: String,
|
||||||
@@ -1015,6 +1025,10 @@ pub struct SyncFolderInfo {
|
|||||||
pub paused: bool,
|
pub paused: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_sync_folder_kind() -> String {
|
||||||
|
"device".into()
|
||||||
|
}
|
||||||
|
|
||||||
/// A folder under this machine's device offered for restore (in
|
/// A folder under this machine's device offered for restore (in
|
||||||
/// [`Response::RestorableFolders`]).
|
/// [`Response::RestorableFolders`]).
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 44;
|
pub(super) const SCHEMA_VERSION: i64 = 45;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -467,6 +467,29 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V44)?;
|
tx.execute_batch(MIGRATION_V44)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 45 {
|
||||||
|
let has_folders: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_folder'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_kind: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('sync_folder') WHERE name = 'kind'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_folders && !has_kind {
|
||||||
|
tx.execute_batch(MIGRATION_V45_FOLDERS)?;
|
||||||
|
let has_mounts: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'mount'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_mounts {
|
||||||
|
tx.execute_batch(MIGRATION_V45_MOUNTS)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -1382,3 +1405,57 @@ CREATE INDEX IF NOT EXISTS pending_op_kind_name ON pending_op(kind, name);
|
|||||||
const MIGRATION_V44: &str = "
|
const MIGRATION_V44: &str = "
|
||||||
ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT;
|
ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// Schema v45: distinguish device backups from My files attachments. Existing
|
||||||
|
/// released rows are device backups. Attachments cannot evict their local tree.
|
||||||
|
const MIGRATION_V45_FOLDERS: &str = "
|
||||||
|
ALTER TABLE sync_folder ADD COLUMN kind TEXT NOT NULL DEFAULT 'device'
|
||||||
|
CHECK (kind IN ('device', 'attached'));
|
||||||
|
CREATE TRIGGER sync_folder_attached_insert
|
||||||
|
BEFORE INSERT ON sync_folder
|
||||||
|
WHEN NEW.kind = 'attached' AND (NEW.mode != 'mirror' OR NEW.pending_mode IS NOT NULL)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'attached folders only support mirror mode'); END;
|
||||||
|
CREATE TRIGGER sync_folder_attached_update
|
||||||
|
BEFORE UPDATE ON sync_folder
|
||||||
|
WHEN NEW.kind = 'attached' AND (NEW.mode != 'mirror' OR NEW.pending_mode IS NOT NULL)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'attached folders only support mirror mode'); END;
|
||||||
|
";
|
||||||
|
|
||||||
|
const MIGRATION_V45_MOUNTS: &str = "
|
||||||
|
DROP TRIGGER mount_sync_folder_insert;
|
||||||
|
DROP INDEX mount_myfiles;
|
||||||
|
DROP INDEX mount_device;
|
||||||
|
DROP INDEX mount_shared;
|
||||||
|
ALTER TABLE mount RENAME TO mount_v44;
|
||||||
|
CREATE TABLE mount (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('myfiles', 'device', 'attached', 'shared')),
|
||||||
|
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
|
||||||
|
share_root_uid TEXT,
|
||||||
|
local_path TEXT NOT NULL DEFAULT '',
|
||||||
|
root_uid TEXT NOT NULL DEFAULT '',
|
||||||
|
root_share_id TEXT NOT NULL DEFAULT '',
|
||||||
|
mode TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
access TEXT NOT NULL DEFAULT 'rw' CHECK (access IN ('rw', 'ro')),
|
||||||
|
CHECK (
|
||||||
|
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
|
||||||
|
OR (kind IN ('device', 'attached') AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
|
||||||
|
OR (kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
INSERT INTO mount SELECT * FROM mount_v44;
|
||||||
|
DROP TABLE mount_v44;
|
||||||
|
CREATE UNIQUE INDEX mount_myfiles ON mount(kind) WHERE kind = 'myfiles';
|
||||||
|
CREATE UNIQUE INDEX mount_device ON mount(sync_folder_id) WHERE kind IN ('device', 'attached');
|
||||||
|
CREATE UNIQUE INDEX mount_shared ON mount(share_root_uid) WHERE kind = 'shared';
|
||||||
|
CREATE TRIGGER mount_sync_folder_insert
|
||||||
|
AFTER INSERT ON sync_folder
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO mount (kind, sync_folder_id) VALUES (NEW.kind, NEW.id);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER mount_sync_folder_kind_update
|
||||||
|
AFTER UPDATE OF kind ON sync_folder
|
||||||
|
BEGIN
|
||||||
|
UPDATE mount SET kind = NEW.kind WHERE sync_folder_id = NEW.id;
|
||||||
|
END;
|
||||||
|
";
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Unified mount/location presentation rows.
|
//! Unified mount/location presentation rows.
|
||||||
//!
|
//!
|
||||||
//! A device row stores only its `sync_folder` identity. Its path, remote root,
|
//! A device or attached row stores only its `sync_folder` identity. Its path, remote root,
|
||||||
//! mode, state, pending mode, and last-sync timestamp are selected from
|
//! mode, state, pending mode, and last-sync timestamp are selected from
|
||||||
//! `sync_folder`, which remains the authoritative sync-engine table.
|
//! `sync_folder`, which remains the authoritative sync-engine table.
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ impl Db {
|
|||||||
)? > 0)
|
)? > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every local location, with device fields joined from `sync_folder`.
|
/// Every local location, with synced-folder fields joined from `sync_folder`.
|
||||||
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
@@ -81,14 +81,14 @@ impl Db {
|
|||||||
m.kind,
|
m.kind,
|
||||||
m.sync_folder_id,
|
m.sync_folder_id,
|
||||||
m.share_root_uid,
|
m.share_root_uid,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END,
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.local_path ELSE m.local_path END,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END,
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.remote_uid ELSE m.root_uid END,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END,
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.remote_share_id ELSE m.root_share_id END,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END,
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.mode ELSE m.mode END,
|
||||||
m.access,
|
m.access,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END,
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.state ELSE 'idle' END,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END,
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.last_sync ELSE 0 END,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.pending_mode ELSE NULL END
|
||||||
FROM mount AS m
|
FROM mount AS m
|
||||||
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
|
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
|
||||||
ORDER BY CASE m.kind
|
ORDER BY CASE m.kind
|
||||||
@@ -138,6 +138,11 @@ impl Db {
|
|||||||
Error::Other(format!("mount row {id} has no sync_folder_id"))
|
Error::Other(format!("mount row {id} has no sync_folder_id"))
|
||||||
})?,
|
})?,
|
||||||
},
|
},
|
||||||
|
"attached" => MountKind::Attached {
|
||||||
|
sync_folder_id: sync_folder_id.ok_or_else(|| {
|
||||||
|
Error::Other(format!("mount row {id} has no sync_folder_id"))
|
||||||
|
})?,
|
||||||
|
},
|
||||||
"shared" => MountKind::Shared {
|
"shared" => MountKind::Shared {
|
||||||
share_root_uid: share_root_uid.ok_or_else(|| {
|
share_root_uid: share_root_uid.ok_or_else(|| {
|
||||||
Error::Other(format!("mount row {id} has no share_root_uid"))
|
Error::Other(format!("mount row {id} has no share_root_uid"))
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
pub struct StoredSyncFolder {
|
pub struct StoredSyncFolder {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
/// `device` backup or `attached` My files mirror.
|
||||||
|
pub kind: String,
|
||||||
pub local_path: String,
|
pub local_path: String,
|
||||||
pub remote_uid: String,
|
pub remote_uid: String,
|
||||||
pub remote_share_id: String,
|
pub remote_share_id: String,
|
||||||
@@ -59,12 +61,32 @@ impl Db {
|
|||||||
local_path: &str,
|
local_path: &str,
|
||||||
remote_uid: &str,
|
remote_uid: &str,
|
||||||
remote_share_id: &str,
|
remote_share_id: &str,
|
||||||
|
) -> Result<i64> {
|
||||||
|
self.sync_folder_insert(local_path, remote_uid, remote_share_id, "device")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach an existing My files folder, without creating a device backup.
|
||||||
|
pub fn sync_folder_attach(
|
||||||
|
&self,
|
||||||
|
local_path: &str,
|
||||||
|
remote_uid: &str,
|
||||||
|
remote_share_id: &str,
|
||||||
|
) -> Result<i64> {
|
||||||
|
self.sync_folder_insert(local_path, remote_uid, remote_share_id, "attached")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_folder_insert(
|
||||||
|
&self,
|
||||||
|
local_path: &str,
|
||||||
|
remote_uid: &str,
|
||||||
|
remote_share_id: &str,
|
||||||
|
kind: &str,
|
||||||
) -> Result<i64> {
|
) -> Result<i64> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id)
|
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id, kind)
|
||||||
VALUES (?1, ?2, ?3)",
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![local_path, remote_uid, remote_share_id],
|
params![local_path, remote_uid, remote_share_id, kind],
|
||||||
)?;
|
)?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
}
|
}
|
||||||
@@ -73,7 +95,7 @@ impl Db {
|
|||||||
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync, kind
|
||||||
FROM sync_folder ORDER BY id",
|
FROM sync_folder ORDER BY id",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
@@ -86,6 +108,7 @@ impl Db {
|
|||||||
pending_mode: r.get(5)?,
|
pending_mode: r.get(5)?,
|
||||||
state: r.get(6)?,
|
state: r.get(6)?,
|
||||||
last_sync: r.get(7)?,
|
last_sync: r.get(7)?,
|
||||||
|
kind: r.get(8)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
@@ -99,7 +122,7 @@ impl Db {
|
|||||||
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync, kind
|
||||||
FROM sync_folder WHERE id = ?1",
|
FROM sync_folder WHERE id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
|r| {
|
|r| {
|
||||||
@@ -112,6 +135,7 @@ impl Db {
|
|||||||
pending_mode: r.get(5)?,
|
pending_mode: r.get(5)?,
|
||||||
state: r.get(6)?,
|
state: r.get(6)?,
|
||||||
last_sync: r.get(7)?,
|
last_sync: r.get(7)?,
|
||||||
|
kind: r.get(8)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2076,7 +2076,11 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
|
|||||||
{
|
{
|
||||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"DROP TRIGGER mount_sync_folder_insert;
|
"DROP TRIGGER IF EXISTS mount_sync_folder_kind_update;
|
||||||
|
DROP TRIGGER mount_sync_folder_insert;
|
||||||
|
DROP TRIGGER sync_folder_attached_insert;
|
||||||
|
DROP TRIGGER sync_folder_attached_update;
|
||||||
|
ALTER TABLE sync_folder DROP COLUMN kind;
|
||||||
DROP TABLE mount;
|
DROP TABLE mount;
|
||||||
DROP TABLE share_access;
|
DROP TABLE share_access;
|
||||||
DROP TABLE album_photos;
|
DROP TABLE album_photos;
|
||||||
@@ -7691,3 +7695,69 @@ fn a_queued_op_stops_existing_once_the_node_is_deleted() {
|
|||||||
assert_eq!(blobs, vec!["/staging/journal"]);
|
assert_eq!(blobs, vec!["/staging/journal"]);
|
||||||
assert!(!db.op_exists(id).unwrap());
|
assert!(!db.op_exists(id).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachments_have_their_own_location_and_cannot_evict_local_files() {
|
||||||
|
use crate::mounts::MountKind;
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let id = db
|
||||||
|
.sync_folder_attach("/work", "vol~work", "myfiles-share")
|
||||||
|
.unwrap();
|
||||||
|
let folder = db.sync_folder_get(id).unwrap().unwrap();
|
||||||
|
assert_eq!(folder.kind, "attached");
|
||||||
|
assert_eq!(folder.mode, "mirror");
|
||||||
|
assert_eq!(db.sync_folder_list().unwrap()[0].kind, "attached");
|
||||||
|
let locations = db.mount_list().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(locations[0].kind, MountKind::Attached { sync_folder_id } if sync_folder_id == id)
|
||||||
|
);
|
||||||
|
assert_eq!(locations[0].local_path, "/work");
|
||||||
|
assert_eq!(locations[0].root_share_id, "myfiles-share");
|
||||||
|
assert!(db.sync_folder_set_mode(id, "ondemand").is_err());
|
||||||
|
assert!(
|
||||||
|
db.sync_folder_set_pending_mode(id, Some("ondemand"))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(db.sync_folder_get(id).unwrap().unwrap().mode, "mirror");
|
||||||
|
db.sync_folder_remove(id).unwrap();
|
||||||
|
assert!(db.mount_list().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v45_keeps_existing_device_folders_and_location_ids() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let id = db
|
||||||
|
.sync_folder_add("/backup", "vol~backup", "share")
|
||||||
|
.unwrap();
|
||||||
|
db.sync_folder_set_mode(id, "ondemand").unwrap();
|
||||||
|
db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap();
|
||||||
|
let location_id = db.mount_list().unwrap()[0].id;
|
||||||
|
db.conn
|
||||||
|
.lock()
|
||||||
|
.execute_batch(
|
||||||
|
"DROP TRIGGER sync_folder_attached_insert;
|
||||||
|
DROP TRIGGER sync_folder_attached_update;
|
||||||
|
DROP TRIGGER IF EXISTS mount_sync_folder_kind_update;
|
||||||
|
DROP TRIGGER mount_sync_folder_insert;
|
||||||
|
ALTER TABLE sync_folder DROP COLUMN kind;
|
||||||
|
CREATE TRIGGER mount_sync_folder_insert AFTER INSERT ON sync_folder BEGIN
|
||||||
|
INSERT INTO mount (kind, sync_folder_id) VALUES ('device', NEW.id); END;
|
||||||
|
UPDATE sync_state SET value = '44' WHERE key = 'schema_version';",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
|
let folder = db.sync_folder_get(id).unwrap().unwrap();
|
||||||
|
assert_eq!(folder.kind, "device");
|
||||||
|
assert_eq!(folder.mode, "ondemand");
|
||||||
|
assert_eq!(folder.pending_mode.as_deref(), Some("mirror"));
|
||||||
|
assert_eq!(db.mount_list().unwrap()[0].id, location_id);
|
||||||
|
let attached = db
|
||||||
|
.sync_folder_attach("/work", "vol~work", "myfiles")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.sync_folder_get(attached).unwrap().unwrap().kind,
|
||||||
|
"attached"
|
||||||
|
);
|
||||||
|
db.migrate().unwrap();
|
||||||
|
assert_eq!(db.mount_list().unwrap().len(), 2);
|
||||||
|
}
|
||||||
@@ -81,6 +81,8 @@ pub enum MountKind {
|
|||||||
MyFiles,
|
MyFiles,
|
||||||
/// A folder owned by this machine's Proton Drive device.
|
/// A folder owned by this machine's Proton Drive device.
|
||||||
Device { sync_folder_id: i64 },
|
Device { sync_folder_id: i64 },
|
||||||
|
/// A real local mirror of an existing My files folder.
|
||||||
|
Attached { sync_folder_id: i64 },
|
||||||
/// A future standalone mount rooted at a node shared with this account.
|
/// A future standalone mount rooted at a node shared with this account.
|
||||||
Shared { share_root_uid: String },
|
Shared { share_root_uid: String },
|
||||||
}
|
}
|
||||||
@@ -88,8 +90,8 @@ pub enum MountKind {
|
|||||||
/// One local Proton Drive location as exposed over the control protocol.
|
/// One local Proton Drive location as exposed over the control protocol.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct MountSpec {
|
pub struct MountSpec {
|
||||||
/// Presentation-row id. Device actions use the `sync_folder_id` carried by
|
/// Presentation-row id. Synced-folder actions use the `sync_folder_id`
|
||||||
/// [`MountKind::Device`], not this id.
|
/// carried by [`MountKind::Device`] or [`MountKind::Attached`], not this id.
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub kind: MountKind,
|
pub kind: MountKind,
|
||||||
pub local_path: String,
|
pub local_path: String,
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//! Explicitly bind an existing My files folder to a real local mirror.
|
||||||
|
//!
|
||||||
|
//! Unlike a FUSE mount, this never covers or evicts the local directory.
|
||||||
|
//! The existing three-way engine performs the initial, non-destructive union.
|
||||||
|
|
||||||
|
use super::{Core, parse_uid, sync};
|
||||||
|
use pdfs_core::db::StoredSyncFolder;
|
||||||
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// A bounded, server-verified ancestry walk. Do not decide ownership or
|
||||||
|
/// remote-root overlap from a possibly incomplete cached folder listing.
|
||||||
|
pub(super) fn attach_ancestors(&self, start: &NodeUid) -> CoreResult<Vec<NodeUid>> {
|
||||||
|
let mut ancestors = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut next = Some(start.clone());
|
||||||
|
while let Some(uid) = next {
|
||||||
|
if !seen.insert(uid.clone()) || ancestors.len() >= 256 {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"invalid or excessively deep remote folder ancestry",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ancestors.push(uid.clone());
|
||||||
|
if uid == self.primary_root_uid {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let nodes = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.drive.enumerate_nodes(std::slice::from_ref(&uid)))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "verify sync folder ancestry"))?;
|
||||||
|
let node = nodes
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| CoreError::not_found("remote sync folder is missing"))?;
|
||||||
|
if node.trashed {
|
||||||
|
return Err(CoreError::invalid("remote sync folder is in the trash"));
|
||||||
|
}
|
||||||
|
next = node.parent_uid;
|
||||||
|
}
|
||||||
|
Ok(ancestors)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn attach_sync_folder(
|
||||||
|
&self,
|
||||||
|
local: &Path,
|
||||||
|
remote: &Path,
|
||||||
|
mountpoint: &Path,
|
||||||
|
) -> CoreResult<StoredSyncFolder> {
|
||||||
|
let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock();
|
||||||
|
if !self.primary {
|
||||||
|
return Err(CoreError::invalid("attach must use My files"));
|
||||||
|
}
|
||||||
|
if remote.is_absolute()
|
||||||
|
|| remote
|
||||||
|
.components()
|
||||||
|
.any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"remote folder must stay inside My files",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let local = local
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| CoreError::invalid(format!("resolve {}: {e}", local.display())))?;
|
||||||
|
if !local.is_dir() {
|
||||||
|
return Err(CoreError::invalid("local sync path is not a directory"));
|
||||||
|
}
|
||||||
|
let existing = self
|
||||||
|
.db
|
||||||
|
.sync_folder_list()
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
let others = existing
|
||||||
|
.iter()
|
||||||
|
.map(|f| PathBuf::from(&f.local_path))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut reserved = crate::devices::sync_root_reserved();
|
||||||
|
reserved.mountpoint = Some(
|
||||||
|
mountpoint
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| CoreError::internal(format!("resolve mountpoint: {e}")))?,
|
||||||
|
);
|
||||||
|
if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) {
|
||||||
|
return Err(CoreError::invalid(problem));
|
||||||
|
}
|
||||||
|
let (ino, remote_uid) = self.resolve(remote)?;
|
||||||
|
let is_folder = self
|
||||||
|
.state()
|
||||||
|
.entries
|
||||||
|
.get(&ino)
|
||||||
|
.is_some_and(|e| e.node.is_folder() && !e.node.trashed);
|
||||||
|
if !is_folder {
|
||||||
|
return Err(CoreError::invalid("remote sync path is not a folder"));
|
||||||
|
}
|
||||||
|
let ancestors = self.attach_ancestors(&remote_uid)?;
|
||||||
|
if remote_uid == self.primary_root_uid || !ancestors.contains(&self.primary_root_uid) {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"choose an owned folder below My files, not its root or a shared folder",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for other in existing.iter().filter(|f| f.kind == "attached") {
|
||||||
|
let uid = parse_uid(&other.remote_uid)
|
||||||
|
.ok_or_else(|| CoreError::internal("invalid existing remote sync root"))?;
|
||||||
|
if uid == remote_uid
|
||||||
|
|| ancestors.contains(&uid)
|
||||||
|
|| self.attach_ancestors(&uid)?.contains(&remote_uid)
|
||||||
|
{
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"remote folder overlaps an existing synced folder",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let share_id = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.drive.context_share_id(&self.primary_root_uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "resolve My files share"))?
|
||||||
|
.to_string();
|
||||||
|
if share_id.is_empty() {
|
||||||
|
return Err(CoreError::invalid("My files share is unavailable"));
|
||||||
|
}
|
||||||
|
let id = self
|
||||||
|
.db
|
||||||
|
.sync_folder_attach(&local.to_string_lossy(), &remote_uid.to_string(), &share_id)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
// An empty baseline makes one-sided files new, never deletions. The
|
||||||
|
// engine hashes matching-size files and preserves divergent versions.
|
||||||
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
|
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||||
|
self.db
|
||||||
|
.sync_folder_get(id)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
|
.ok_or_else(|| CoreError::not_found("new sync folder disappeared"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -385,6 +385,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
|||||||
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||||
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
|
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||||
R::AddSyncFolder { .. }
|
R::AddSyncFolder { .. }
|
||||||
|
| R::AttachSyncFolder { .. }
|
||||||
| R::RemoveSyncFolder { .. }
|
| R::RemoveSyncFolder { .. }
|
||||||
| R::SetSyncFolderMode { .. }
|
| R::SetSyncFolderMode { .. }
|
||||||
| R::SetSyncFolderPaused { .. }
|
| R::SetSyncFolderPaused { .. }
|
||||||
@@ -1397,6 +1398,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
message: format!("adding {local_path}"),
|
message: format!("adding {local_path}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::AttachSyncFolder {
|
||||||
|
local_path,
|
||||||
|
remote_path,
|
||||||
|
}) => {
|
||||||
|
let result = rel_to_mount(mountpoint, &remote_path).and_then(|remote| {
|
||||||
|
core.attach_sync_folder(Path::new(&local_path), &remote, mountpoint)
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Ok(folder) => CtlResponse::Ok {
|
||||||
|
message: format!("sync folder {} attached: {}", folder.id, folder.local_path),
|
||||||
|
},
|
||||||
|
Err(error) => CtlResponse::error(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() {
|
Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() {
|
||||||
Ok(items) => CtlResponse::SyncFolders { items },
|
Ok(items) => CtlResponse::SyncFolders { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ use super::{
|
|||||||
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
|
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Registration validates local and remote root overlap before inserting a
|
||||||
|
/// mapping. Serialize all registration paths so concurrent control requests
|
||||||
|
/// cannot both validate against the same old set of roots.
|
||||||
|
pub(super) static SYNC_ROOT_REGISTRATION: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
/// A mode that this daemon is allowed to apply.
|
/// A mode that this daemon is allowed to apply.
|
||||||
///
|
///
|
||||||
/// Persisted and control-protocol values first pass through [`MountMode`] so
|
/// Persisted and control-protocol values first pass through [`MountMode`] so
|
||||||
@@ -152,7 +157,9 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
for location in &mut locations {
|
for location in &mut locations {
|
||||||
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
||||||
if let MountKind::Device { sync_folder_id } = &location.kind {
|
if let MountKind::Device { sync_folder_id } | MountKind::Attached { sync_folder_id } =
|
||||||
|
&location.kind
|
||||||
|
{
|
||||||
location.progress = progress.get(sync_folder_id).cloned();
|
location.progress = progress.get(sync_folder_id).cloned();
|
||||||
location.paused = self.sync_folder_paused(*sync_folder_id);
|
location.paused = self.sync_folder_paused(*sync_folder_id);
|
||||||
}
|
}
|
||||||
@@ -495,7 +502,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Everything directly under a device root, trashed nodes included.
|
/// Everything directly under a device root, trashed nodes included.
|
||||||
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
|
pub(super) 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))
|
||||||
@@ -513,6 +520,7 @@ impl Core {
|
|||||||
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
|
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
|
||||||
/// the two-way engine (Phase 2) reconciles later changes.
|
/// the two-way engine (Phase 2) reconciles later changes.
|
||||||
pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult<StoredSyncFolder> {
|
pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult<StoredSyncFolder> {
|
||||||
|
let _registration = SYNC_ROOT_REGISTRATION.lock();
|
||||||
let meta = std::fs::metadata(local)
|
let meta = std::fs::metadata(local)
|
||||||
.map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?;
|
.map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?;
|
||||||
if !meta.is_dir() {
|
if !meta.is_dir() {
|
||||||
@@ -699,6 +707,11 @@ impl Core {
|
|||||||
.sync_folder_get(id)
|
.sync_folder_get(id)
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
|
if folder.kind == "attached" && delete_remote {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"attached My files folders cannot be deleted by stopping sync; omit --delete-remote",
|
||||||
|
));
|
||||||
|
}
|
||||||
// An `ondemand` folder *is* a FUSE mount over its local path, so dropping
|
// An `ondemand` folder *is* a FUSE mount over its local path, so dropping
|
||||||
// only the row would strand the mount: the path would keep serving a
|
// only the row would strand the mount: the path would keep serving a
|
||||||
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
||||||
@@ -785,6 +798,11 @@ impl Core {
|
|||||||
.sync_folder_get(id)
|
.sync_folder_get(id)
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
|
if folder.kind == "attached" && target_mode != ApplicableMountMode::Mirror {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"attached My files folders only support mirror mode; use the My files mount for online-only access",
|
||||||
|
));
|
||||||
|
}
|
||||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||||
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
||||||
// A switch waits on the folder's reconcile, which a pause skips; refuse
|
// A switch waits on the folder's reconcile, which a pause skips; refuse
|
||||||
@@ -960,6 +978,11 @@ impl Core {
|
|||||||
.sync_folder_get(id)
|
.sync_folder_get(id)
|
||||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
|
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
|
||||||
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
|
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
|
||||||
|
if folder.kind == "attached" && mode != ApplicableMountMode::Mirror {
|
||||||
|
return Err(SwitchBlocked::Failed(
|
||||||
|
"attached My files folders only support mirror mode".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) {
|
if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) {
|
||||||
return Err(SwitchBlocked::Superseded);
|
return Err(SwitchBlocked::Superseded);
|
||||||
}
|
}
|
||||||
@@ -1199,7 +1222,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
for folder in folders {
|
for folder in folders {
|
||||||
if folder.mode != "ondemand" {
|
if folder.kind != "device" || folder.mode != "ondemand" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
|
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
|
||||||
@@ -1395,6 +1418,7 @@ mod tests {
|
|||||||
|
|
||||||
fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder {
|
fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder {
|
||||||
StoredSyncFolder {
|
StoredSyncFolder {
|
||||||
|
kind: "device".into(),
|
||||||
id: 7,
|
id: 7,
|
||||||
local_path: local_path.to_string(),
|
local_path: local_path.to_string(),
|
||||||
remote_uid: remote_uid.to_string(),
|
remote_uid: remote_uid.to_string(),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use futures::StreamExt as _;
|
|||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
use proton_drive_rs::proton_sdk::account::Quota;
|
use proton_drive_rs::proton_sdk::account::Quota;
|
||||||
use proton_drive_rs::proton_sdk::error::Result;
|
use proton_drive_rs::proton_sdk::error::Result;
|
||||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid, ShareId};
|
||||||
use proton_drive_rs::{
|
use proton_drive_rs::{
|
||||||
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
|
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
|
||||||
ProtonDriveClient, RevisionReader, Thumbnail,
|
ProtonDriveClient, RevisionReader, Thumbnail,
|
||||||
@@ -43,6 +43,8 @@ pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>>
|
|||||||
pub(crate) trait DriveApi: Send + Sync {
|
pub(crate) trait DriveApi: Send + Sync {
|
||||||
async fn get_my_files_folder(&self) -> Result<Node>;
|
async fn get_my_files_folder(&self) -> Result<Node>;
|
||||||
|
|
||||||
|
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId>;
|
||||||
|
|
||||||
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
|
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
|
||||||
|
|
||||||
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
||||||
@@ -305,6 +307,10 @@ impl RevisionRead for RevisionReader {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl DriveApi for ProtonDriveClient {
|
impl DriveApi for ProtonDriveClient {
|
||||||
|
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId> {
|
||||||
|
ProtonDriveClient::context_share_id(self, uid).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_my_files_folder(&self) -> Result<Node> {
|
async fn get_my_files_folder(&self) -> Result<Node> {
|
||||||
ProtonDriveClient::get_my_files_folder(self).await
|
ProtonDriveClient::get_my_files_folder(self).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ use proton_drive_rs::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod albums;
|
mod albums;
|
||||||
|
mod attach;
|
||||||
mod background;
|
mod background;
|
||||||
mod conflicts;
|
mod conflicts;
|
||||||
mod control;
|
mod control;
|
||||||
@@ -6441,6 +6442,7 @@ fn sync_folder_info(
|
|||||||
) -> SyncFolderInfo {
|
) -> SyncFolderInfo {
|
||||||
SyncFolderInfo {
|
SyncFolderInfo {
|
||||||
id: f.id,
|
id: f.id,
|
||||||
|
kind: f.kind,
|
||||||
local_path: f.local_path,
|
local_path: f.local_path,
|
||||||
remote_uid: f.remote_uid,
|
remote_uid: f.remote_uid,
|
||||||
mode: f.mode,
|
mode: f.mode,
|
||||||
|
|||||||
@@ -141,19 +141,11 @@ impl Core {
|
|||||||
|
|
||||||
/// Snapshot the local arrangement into a [`Profile`].
|
/// Snapshot the local arrangement into a [`Profile`].
|
||||||
fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult<Profile> {
|
fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult<Profile> {
|
||||||
let folders = self
|
let folders = profile_folders(
|
||||||
.db
|
self.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?,
|
||||||
.into_iter()
|
);
|
||||||
.map(|f| ProfileFolder {
|
|
||||||
remote_uid: f.remote_uid,
|
|
||||||
local_path: f.local_path,
|
|
||||||
// A queued switch is where the user asked the folder to be, so
|
|
||||||
// that is what the next machine should set up.
|
|
||||||
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let pins = self
|
let pins = self
|
||||||
.db
|
.db
|
||||||
.pin_list()
|
.pin_list()
|
||||||
@@ -389,6 +381,7 @@ impl Core {
|
|||||||
items: &[pdfs_core::control::RestoreItem],
|
items: &[pdfs_core::control::RestoreItem],
|
||||||
device: Option<&str>,
|
device: Option<&str>,
|
||||||
) -> CoreResult<RestoreOutcome> {
|
) -> CoreResult<RestoreOutcome> {
|
||||||
|
let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock();
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Err(CoreError::invalid("nothing to restore"));
|
return Err(CoreError::invalid("nothing to restore"));
|
||||||
}
|
}
|
||||||
@@ -398,6 +391,11 @@ impl Core {
|
|||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
|
||||||
|
// A caller must not smuggle a My files root into a device mapping,
|
||||||
|
// where destructive device-only operations would otherwise be allowed.
|
||||||
|
let device_root = super::parse_uid(&device.root_uid)
|
||||||
|
.ok_or_else(|| CoreError::internal("invalid device root"))?;
|
||||||
|
let children = self.device_root_nodes(&device_root)?;
|
||||||
let reserved = crate::devices::sync_root_reserved();
|
let reserved = crate::devices::sync_root_reserved();
|
||||||
let mut roots: Vec<PathBuf> = existing
|
let mut roots: Vec<PathBuf> = existing
|
||||||
.iter()
|
.iter()
|
||||||
@@ -406,6 +404,18 @@ impl Core {
|
|||||||
let mut restored = 0usize;
|
let mut restored = 0usize;
|
||||||
let mut skipped: Vec<String> = Vec::new();
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
|
if !children.iter().any(|node| {
|
||||||
|
node.uid.to_string() == item.remote_uid
|
||||||
|
&& node.is_folder()
|
||||||
|
&& !node.trashed
|
||||||
|
&& node.name != PROFILE_DIR_NAME
|
||||||
|
}) {
|
||||||
|
skipped.push(format!(
|
||||||
|
"{}: not a folder under this device",
|
||||||
|
item.local_path
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// The path is proposed from the profile another machine wrote, and
|
// The path is proposed from the profile another machine wrote, and
|
||||||
// may hold a folder name from Drive: checked like any new root.
|
// may hold a folder name from Drive: checked like any new root.
|
||||||
let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) {
|
let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) {
|
||||||
@@ -468,6 +478,19 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only device backups belong in the device's cross-machine profile.
|
||||||
|
fn profile_folders(folders: Vec<pdfs_core::db::StoredSyncFolder>) -> Vec<ProfileFolder> {
|
||||||
|
folders
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.kind == "device")
|
||||||
|
.map(|f| ProfileFolder {
|
||||||
|
remote_uid: f.remote_uid,
|
||||||
|
local_path: f.local_path,
|
||||||
|
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// What a restore did: how many folders it attached, and why it left out the
|
/// What a restore did: how many folders it attached, and why it left out the
|
||||||
/// others.
|
/// others.
|
||||||
pub(crate) struct RestoreOutcome {
|
pub(crate) struct RestoreOutcome {
|
||||||
@@ -530,6 +553,21 @@ fn default_local_path(name: &str) -> PathBuf {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attached_my_files_folders_are_not_in_the_device_profile() {
|
||||||
|
let dir = crate::sim::daemon::scratch("profile-folder-kinds");
|
||||||
|
let db = pdfs_core::db::Db::open(&dir.join("cache.db")).unwrap();
|
||||||
|
db.sync_folder_add("/backup", "vol~backup", "device-share")
|
||||||
|
.unwrap();
|
||||||
|
db.sync_folder_attach("/work", "vol~work", "my-files-share")
|
||||||
|
.unwrap();
|
||||||
|
let folders = profile_folders(db.sync_folder_list().unwrap());
|
||||||
|
assert_eq!(folders.len(), 1);
|
||||||
|
assert_eq!(folders[0].remote_uid, "vol~backup");
|
||||||
|
drop(db);
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn foreign_home_is_not_reused() {
|
fn foreign_home_is_not_reused() {
|
||||||
assert!(!path_is_usable_here(Path::new(
|
assert!(!path_is_usable_here(Path::new(
|
||||||
|
|||||||
@@ -555,6 +555,7 @@ mod tests {
|
|||||||
|
|
||||||
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
|
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
|
||||||
StoredSyncFolder {
|
StoredSyncFolder {
|
||||||
|
kind: "device".into(),
|
||||||
id,
|
id,
|
||||||
local_path: local_path.to_string(),
|
local_path: local_path.to_string(),
|
||||||
remote_uid: format!("vol~root{id}"),
|
remote_uid: format!("vol~root{id}"),
|
||||||
|
|||||||
@@ -2912,6 +2912,433 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
|
||||||
|
fn attaching_my_files_preserves_local_files_and_syncs_both_ways() {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("attach-my-files");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Work").is_some()
|
||||||
|
}));
|
||||||
|
drive.device().write("Work/cloud.txt", b"from cloud");
|
||||||
|
drive.device().write("Work/common.txt", b"remote version");
|
||||||
|
drive.device().write("Work/identical.txt", b"same bytes");
|
||||||
|
let local = dir.join("local");
|
||||||
|
std::fs::create_dir(&local).unwrap();
|
||||||
|
std::fs::write(local.join("local.txt"), b"only on this computer").unwrap();
|
||||||
|
std::fs::write(local.join("common.txt"), b"local version").unwrap();
|
||||||
|
std::fs::write(local.join("identical.txt"), b"same bytes").unwrap();
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: local.to_str().unwrap().into(),
|
||||||
|
remote_path: "Work".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let folder = match daemon.request(&Request::ListSyncFolders).unwrap() {
|
||||||
|
Response::SyncFolders { items } => {
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.find(|f| f.local_path == local.to_str().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
other => panic!("unexpected reply: {other:?}"),
|
||||||
|
};
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
drive.lookup("Work/local.txt").is_some()
|
||||||
|
&& std::fs::read(local.join("cloud.txt")).is_ok_and(|b| b == b"from cloud")
|
||||||
|
&& std::fs::read(local.join("common.txt")).is_ok_and(|b| b == b"remote version")
|
||||||
|
&& !conflict_copies(&local, "common").is_empty()
|
||||||
|
}));
|
||||||
|
assert_ne!(
|
||||||
|
std::fs::metadata(&local).unwrap().dev(),
|
||||||
|
std::fs::metadata(&daemon.mountpoint).unwrap().dev()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
conflict_copies(&local, "common")
|
||||||
|
.iter()
|
||||||
|
.any(|p| std::fs::read(p).is_ok_and(|b| b == b"local version"))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: local.to_str().unwrap().into(),
|
||||||
|
remote_path: "Work".into()
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let duplicate = dir.join("duplicate");
|
||||||
|
std::fs::create_dir(&duplicate).unwrap();
|
||||||
|
assert!(
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: duplicate.to_str().unwrap().into(),
|
||||||
|
remote_path: "Work".into()
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: duplicate.to_str().unwrap().into(),
|
||||||
|
remote_path: "../Work".into()
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
// Parent/child mappings, the account root and files cannot be attached.
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("Work/Sub")).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
&& drive.lookup("Work/Sub").is_some()
|
||||||
|
}));
|
||||||
|
for remote in ["Work/Sub", ".", "Work/cloud.txt"] {
|
||||||
|
assert!(
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: duplicate.to_str().unwrap().into(),
|
||||||
|
remote_path: remote.into(),
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Local overlap and symlink aliases into the actual cloud mount are refused.
|
||||||
|
let alias = dir.join("cloud-alias");
|
||||||
|
std::os::unix::fs::symlink(daemon.mountpoint.join("Work"), &alias).unwrap();
|
||||||
|
std::fs::create_dir_all(local.join("Sub")).unwrap();
|
||||||
|
for path in [
|
||||||
|
local.join("Sub"),
|
||||||
|
dir.clone(),
|
||||||
|
daemon.mountpoint.join("Work"),
|
||||||
|
alias,
|
||||||
|
] {
|
||||||
|
let error = daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: path.to_string_lossy().into_owned(),
|
||||||
|
remote_path: "Work/Sub".into(),
|
||||||
|
})
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("overlaps"), "{error}");
|
||||||
|
}
|
||||||
|
assert!(conflict_copies(&local, "identical").is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(local.join("identical.txt")).unwrap(),
|
||||||
|
b"same bytes"
|
||||||
|
);
|
||||||
|
// Simultaneous requests cannot register two local trees for one cloud root.
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("Other")).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Other").is_some()
|
||||||
|
}));
|
||||||
|
let candidate_a = dir.join("candidate-a");
|
||||||
|
let candidate_b = dir.join("candidate-b");
|
||||||
|
std::fs::create_dir(&candidate_a).unwrap();
|
||||||
|
std::fs::create_dir(&candidate_b).unwrap();
|
||||||
|
let outcomes = std::thread::scope(|scope| {
|
||||||
|
let a = scope.spawn(|| {
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: candidate_a.to_str().unwrap().into(),
|
||||||
|
remote_path: "Other".into(),
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
});
|
||||||
|
let b = scope.spawn(|| {
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: candidate_b.to_str().unwrap().into(),
|
||||||
|
remote_path: "Other".into(),
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
});
|
||||||
|
[a.join().unwrap(), b.join().unwrap()]
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
outcomes.iter().filter(|result| result.is_ok()).count(),
|
||||||
|
1,
|
||||||
|
"exactly one mapping may own a remote root: {outcomes:?}"
|
||||||
|
);
|
||||||
|
drive
|
||||||
|
.device()
|
||||||
|
.write("Work/cloud.txt", b"new cloud revision");
|
||||||
|
daemon
|
||||||
|
.request(&Request::SyncNow { id: Some(folder) })
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || std::fs::read(
|
||||||
|
local.join("cloud.txt")
|
||||||
|
)
|
||||||
|
.is_ok_and(|b| b == b"new cloud revision")));
|
||||||
|
std::fs::write(local.join("local.txt"), b"new local revision").unwrap();
|
||||||
|
daemon
|
||||||
|
.request(&Request::SyncNow { id: Some(folder) })
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || drive
|
||||||
|
.tree()
|
||||||
|
.get("Work/local.txt")
|
||||||
|
== Some(&Entry::File(Arc::new(b"new local revision".to_vec())))));
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || listed_folder(
|
||||||
|
&daemon, folder
|
||||||
|
)
|
||||||
|
.is_some_and(|f| f.state == "idle")));
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused {
|
||||||
|
id: folder,
|
||||||
|
paused: true,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let before = drive.tree();
|
||||||
|
for entry in std::fs::read_dir(&local).unwrap() {
|
||||||
|
let path = entry.unwrap().path();
|
||||||
|
if path.is_dir() {
|
||||||
|
std::fs::remove_dir_all(path).unwrap();
|
||||||
|
} else {
|
||||||
|
std::fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused {
|
||||||
|
id: folder,
|
||||||
|
paused: false,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
daemon
|
||||||
|
.request(&Request::SyncNow { id: Some(folder) })
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || listed_folder(
|
||||||
|
&daemon, folder
|
||||||
|
)
|
||||||
|
.is_some_and(|f| f.state == "error")));
|
||||||
|
assert_eq!(
|
||||||
|
drive.tree(),
|
||||||
|
before,
|
||||||
|
"the wipe guard must preserve the remote tree"
|
||||||
|
);
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let db = Db::open(&dir.join("pdfs.db")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.sync_folder_get(folder).unwrap().unwrap().remote_share_id,
|
||||||
|
"my-files-share"
|
||||||
|
);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attached_my_files(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 {
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.drained() && drive.lookup("Work").is_some()
|
||||||
|
}));
|
||||||
|
std::fs::create_dir(local).unwrap();
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: local.to_string_lossy().into_owned(),
|
||||||
|
remote_path: "Work".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
match daemon.request(&Request::ListSyncFolders).unwrap() {
|
||||||
|
Response::SyncFolders { items } => {
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.find(|f| f.local_path == local.to_string_lossy())
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
other => panic!("unexpected reply: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
|
||||||
|
fn attached_my_files_views_preserve_concurrent_edits() {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("attach-views");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let local = dir.join("local");
|
||||||
|
let id = attached_my_files(&daemon, &drive, &local);
|
||||||
|
let mounted = daemon.mountpoint.join("Work");
|
||||||
|
// Prime the mount listing before a mirror upload, to catch stale listings.
|
||||||
|
assert_eq!(std::fs::read_dir(&mounted).unwrap().count(), 0);
|
||||||
|
std::fs::write(local.join("mirror.txt"), b"mirror bytes").unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
std::fs::read_dir(&mounted)
|
||||||
|
.unwrap()
|
||||||
|
.any(|e| e.unwrap().file_name() == "mirror.txt")
|
||||||
|
&& std::fs::read(mounted.join("mirror.txt")).is_ok_and(|b| b == b"mirror bytes")
|
||||||
|
}),
|
||||||
|
"mirror upload must reach the mounted view without a restart"
|
||||||
|
);
|
||||||
|
std::fs::write(mounted.join("mount.txt"), b"mounted bytes").unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mounted bytes")
|
||||||
|
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")
|
||||||
|
}),
|
||||||
|
"mounted writes must reach the mirror"
|
||||||
|
);
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused { id, paused: true })
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
listed_folder(&daemon, id).is_some_and(|f| f.state != "syncing")
|
||||||
|
}));
|
||||||
|
std::fs::write(mounted.join("mount.txt"), b"mount changed").unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
|
||||||
|
std::fs::write(local.join("mount.txt"), b"local changed").unwrap();
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused { id, paused: false })
|
||||||
|
.unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mount changed")
|
||||||
|
&& conflict_copies(&local, "mount")
|
||||||
|
.iter()
|
||||||
|
.any(|p| std::fs::read(p).is_ok_and(|b| b == b"local changed"))
|
||||||
|
&& drive.tree().iter().any(|(path, entry)| {
|
||||||
|
path.starts_with("Work/mount (sync-conflict")
|
||||||
|
&& entry == &Entry::File(Arc::new(b"local changed".to_vec()))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
"both edits must survive the next pass"
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = daemon
|
||||||
|
.request(&Request::SetSyncFolderMode {
|
||||||
|
id,
|
||||||
|
mode: "ondemand".into(),
|
||||||
|
})
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("only support mirror"), "{error}");
|
||||||
|
let error = daemon
|
||||||
|
.request(&Request::RemoveSyncFolder {
|
||||||
|
id,
|
||||||
|
delete_remote: true,
|
||||||
|
})
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("cannot be deleted"), "{error}");
|
||||||
|
let folder = listed_folder(&daemon, id).unwrap();
|
||||||
|
assert_eq!(folder.kind, "attached");
|
||||||
|
assert_eq!(folder.mode, "mirror");
|
||||||
|
assert!(folder.pending_mode.is_none());
|
||||||
|
// Restore must not reclassify a My files root as a device backup,
|
||||||
|
// which would make the device-only destructive operations available.
|
||||||
|
let restore_path = dir.join("forged-device-restore");
|
||||||
|
daemon
|
||||||
|
.request(&Request::RestoreSyncFolders {
|
||||||
|
items: vec![control::RestoreItem {
|
||||||
|
remote_uid: folder.remote_uid.clone(),
|
||||||
|
local_path: restore_path.to_string_lossy().into_owned(),
|
||||||
|
mode: "ondemand".into(),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
matches!(daemon.request(&Request::ListActivity { limit: 100 }),
|
||||||
|
Ok(Response::Activity { items }) if items.iter().any(|item| {
|
||||||
|
!item.ok && item.detail.contains("not a folder under this device")
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
assert!(!restore_path.exists());
|
||||||
|
match daemon.request(&Request::ListLocations).unwrap() {
|
||||||
|
Response::Locations { items } => assert!(items.iter().any(|m| {
|
||||||
|
matches!(m.kind, pdfs_core::mounts::MountKind::Attached { sync_folder_id } if sync_folder_id == id)
|
||||||
|
})),
|
||||||
|
other => panic!("unexpected reply: {other:?}"),
|
||||||
|
}
|
||||||
|
daemon
|
||||||
|
.request(&Request::RemoveSyncFolder {
|
||||||
|
id,
|
||||||
|
delete_remote: false,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(listed_folder(&daemon, id).is_none());
|
||||||
|
assert!(drive.lookup("Work/mount.txt").is_some());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(local.join("mount.txt")).unwrap(),
|
||||||
|
b"mount changed"
|
||||||
|
);
|
||||||
|
daemon.stop().unwrap();
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
|
||||||
|
fn attached_my_files_trashed_root_keeps_local_files() {
|
||||||
|
attached_root_loss_keeps_local_files(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
|
||||||
|
fn attached_my_files_root_moved_outside_my_files_keeps_local_files() {
|
||||||
|
attached_root_loss_keeps_local_files(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attached_root_loss_keeps_local_files(move_outside: bool) {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("attach-root-gone");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let local = dir.join("local");
|
||||||
|
let id = attached_my_files(&daemon, &drive, &local);
|
||||||
|
std::fs::write(local.join("keep.txt"), b"keep my bytes").unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
drive.lookup("Work/keep.txt").is_some()
|
||||||
|
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")
|
||||||
|
}));
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused { id, paused: true })
|
||||||
|
.unwrap();
|
||||||
|
if move_outside {
|
||||||
|
use crate::drive::DriveApi;
|
||||||
|
let rt = daemon.rt.as_ref().unwrap();
|
||||||
|
let device = rt
|
||||||
|
.block_on(
|
||||||
|
daemon
|
||||||
|
.client
|
||||||
|
.create_device("other computer", proton_drive_rs::DeviceType::Linux),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let root = drive.lookup("Work").unwrap();
|
||||||
|
rt.block_on(daemon.client.move_node(&root, &device.root_folder_uid))
|
||||||
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
drive.device().trash("Work");
|
||||||
|
}
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused { id, paused: false })
|
||||||
|
.unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
listed_folder(&daemon, id).is_some_and(|f| f.state == "error")
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(local.join("keep.txt")).unwrap(),
|
||||||
|
b"keep my bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read_dir(&local).unwrap().count(), 1);
|
||||||
|
let daemon = daemon.restart().unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
listed_folder(&daemon, id).is_some_and(|f| f.state == "error")
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(local.join("keep.txt")).unwrap(),
|
||||||
|
b"keep my bytes"
|
||||||
|
);
|
||||||
|
daemon.stop().unwrap();
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
/// Register `local` as a synced folder in `mode` and wait until the daemon
|
/// Register `local` as a synced folder in `mode` and wait until the daemon
|
||||||
/// lists it that way, returning its id.
|
/// lists it that way, returning its id.
|
||||||
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
|
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
|
||||||
|
|||||||
@@ -1123,6 +1123,14 @@ fn delete_one(server: &mut Server, uid: &NodeUid) -> Result<()> {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl DriveApi for FakeClient {
|
impl DriveApi for FakeClient {
|
||||||
|
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId> {
|
||||||
|
self.request(false, |server| {
|
||||||
|
assert_eq!(uid, &server.root);
|
||||||
|
Ok(ShareId::new("my-files-share"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_my_files_folder(&self) -> Result<Node> {
|
async fn get_my_files_folder(&self) -> Result<Node> {
|
||||||
self.request(false, |server| {
|
self.request(false, |server| {
|
||||||
let root = server.root.clone();
|
let root = server.root.clone();
|
||||||
|
|||||||
@@ -170,7 +170,24 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
self.progress_begin(folder.id);
|
self.progress_begin(folder.id);
|
||||||
let result = if push_only {
|
// An attachment's root can be trashed or moved by the web app. A
|
||||||
|
// missing root must never turn into a scan of an empty remote tree.
|
||||||
|
let attached_root = if current.kind == "attached" {
|
||||||
|
self.attach_ancestors(&remote_root).and_then(|ancestors| {
|
||||||
|
if ancestors.contains(&self.primary_root_uid) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(pdfs_core::CoreError::invalid(
|
||||||
|
"attached folder is no longer below My files",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
let result = if let Err(error) = attached_root {
|
||||||
|
Err(PassAbort::Failed(error.to_string()))
|
||||||
|
} else if push_only {
|
||||||
self.push_pass(folder.id, &local_root, &remote_root, &rules)
|
self.push_pass(folder.id, &local_root, &remote_root, &rules)
|
||||||
} else {
|
} else {
|
||||||
match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) {
|
match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) {
|
||||||
|
|||||||
@@ -588,6 +588,7 @@ fn platform_label(device_type: &str) -> &str {
|
|||||||
/// How many folders this machine backs up, and whether any of them needs
|
/// How many folders this machine backs up, and whether any of them needs
|
||||||
/// attention — the one fact worth surfacing away from the folder list itself.
|
/// attention — the one fact worth surfacing away from the folder list itself.
|
||||||
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||||
|
let folders: Vec<_> = folders.iter().filter(|f| f.kind == "device").collect();
|
||||||
if folders.is_empty() {
|
if folders.is_empty() {
|
||||||
return gettext("No folders backed up yet");
|
return gettext("No folders backed up yet");
|
||||||
}
|
}
|
||||||
@@ -939,7 +940,13 @@ pub(crate) fn reload_sync_pages(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
|
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
|
||||||
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
|
pub(crate) fn prompt_remove_sync_folder(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
id: i64,
|
||||||
|
path: &str,
|
||||||
|
ondemand: bool,
|
||||||
|
attached: bool,
|
||||||
|
) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
// The two modes leave the user in opposite places, so they can't share a
|
// The two modes leave the user in opposite places, so they can't share a
|
||||||
// sentence. A mirror folder's files are already on this disk and stay there.
|
// sentence. A mirror folder's files are already on this disk and stay there.
|
||||||
@@ -947,7 +954,12 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
|||||||
// in Proton Drive, so unmounting it leaves an empty directory — which is a
|
// in Proton Drive, so unmounting it leaves an empty directory — which is a
|
||||||
// nasty surprise if the dialog claimed the local files were safe, and is
|
// nasty surprise if the dialog claimed the local files were safe, and is
|
||||||
// recoverable only by turning On-demand off *first* and letting it download.
|
// recoverable only by turning On-demand off *first* and letting it download.
|
||||||
let body = if ondemand {
|
let body = if attached {
|
||||||
|
gettext_f(
|
||||||
|
"Stop syncing “{path}”? Both the local files and the folder in My files are kept.",
|
||||||
|
&[("path", path)],
|
||||||
|
)
|
||||||
|
} else if ondemand {
|
||||||
// Translators: {path} is a local folder path.
|
// Translators: {path} is a local folder path.
|
||||||
gettext_f(
|
gettext_f(
|
||||||
"Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.",
|
"Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.",
|
||||||
@@ -975,7 +987,9 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
|||||||
.active(false)
|
.active(false)
|
||||||
.build();
|
.build();
|
||||||
group.add(&delete_remote);
|
group.add(&delete_remote);
|
||||||
dialog.set_extra_child(Some(&group));
|
if !attached {
|
||||||
|
dialog.set_extra_child(Some(&group));
|
||||||
|
}
|
||||||
dialog.add_response("cancel", &gettext("Cancel"));
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
dialog.add_response("remove", &gettext("Stop Syncing"));
|
dialog.add_response("remove", &gettext("Stop Syncing"));
|
||||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||||
@@ -988,7 +1002,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
|||||||
&ui,
|
&ui,
|
||||||
Request::RemoveSyncFolder {
|
Request::RemoveSyncFolder {
|
||||||
id,
|
id,
|
||||||
delete_remote: delete_remote.is_active(),
|
delete_remote: !attached && delete_remote.is_active(),
|
||||||
},
|
},
|
||||||
&gettext("Stopped syncing folder"),
|
&gettext("Stopped syncing folder"),
|
||||||
&gettext("Couldn't stop syncing the folder"),
|
&gettext("Couldn't stop syncing the folder"),
|
||||||
@@ -1291,6 +1305,7 @@ mod tests {
|
|||||||
|
|
||||||
fn folder(state: &str) -> SyncFolderInfo {
|
fn folder(state: &str) -> SyncFolderInfo {
|
||||||
SyncFolderInfo {
|
SyncFolderInfo {
|
||||||
|
kind: "device".into(),
|
||||||
id: 1,
|
id: 1,
|
||||||
local_path: "/home/u/Documents".into(),
|
local_path: "/home/u/Documents".into(),
|
||||||
remote_uid: "vol~link".into(),
|
remote_uid: "vol~link".into(),
|
||||||
@@ -1303,6 +1318,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attached_my_files_folders_do_not_count_as_device_backups() {
|
||||||
|
let mut attached = folder("error");
|
||||||
|
attached.kind = "attached".into();
|
||||||
|
assert_eq!(
|
||||||
|
this_computer_subtitle(&[attached]),
|
||||||
|
"No folders backed up yet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_machine_with_no_folders_says_so() {
|
fn a_machine_with_no_folders_says_so() {
|
||||||
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
|
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
|
||||||
|
|||||||
@@ -1361,6 +1361,9 @@ fn location_row(ui: &Rc<Ui>, spec: &MountSpec) -> LocationRow {
|
|||||||
prompt_mountpoint(&ui)
|
prompt_mountpoint(&ui)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
MountKind::Attached { sync_folder_id } => {
|
||||||
|
add_device_items(ui, &mut menu, spec, *sync_folder_id);
|
||||||
|
}
|
||||||
MountKind::Device { sync_folder_id } => {
|
MountKind::Device { sync_folder_id } => {
|
||||||
row.add_suffix(&mode_button(ui, spec, *sync_folder_id));
|
row.add_suffix(&mode_button(ui, spec, *sync_folder_id));
|
||||||
add_device_items(ui, &mut menu, spec, *sync_folder_id);
|
add_device_items(ui, &mut menu, spec, *sync_folder_id);
|
||||||
@@ -1520,8 +1523,9 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
|
|||||||
// The folder's *current* mode, not a queued one: a switch that hasn't landed
|
// The folder's *current* mode, not a queued one: a switch that hasn't landed
|
||||||
// yet has not moved the files anywhere.
|
// yet has not moved the files anywhere.
|
||||||
let is_ondemand = spec.mode == MountMode::OnDemand;
|
let is_ondemand = spec.mode == MountMode::OnDemand;
|
||||||
|
let attached = matches!(spec.kind, MountKind::Attached { .. });
|
||||||
menu.item(&gettext("Stop Syncing…"), move || {
|
menu.item(&gettext("Stop Syncing…"), move || {
|
||||||
prompt_remove_sync_folder(&ui, id, &path, is_ondemand)
|
prompt_remove_sync_folder(&ui, id, &path, is_ondemand, attached)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1699,7 +1703,7 @@ fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
|
|||||||
pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
|
pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
MountKind::MyFiles => "folder-remote-symbolic",
|
MountKind::MyFiles => "folder-remote-symbolic",
|
||||||
MountKind::Device { .. } => "folder-symbolic",
|
MountKind::Device { .. } | MountKind::Attached { .. } => "folder-symbolic",
|
||||||
MountKind::Shared { .. } => "pdfs-people-symbolic",
|
MountKind::Shared { .. } => "pdfs-people-symbolic",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1716,6 +1720,7 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
|||||||
match &spec.kind {
|
match &spec.kind {
|
||||||
MountKind::MyFiles => parts.push(gettext("My files")),
|
MountKind::MyFiles => parts.push(gettext("My files")),
|
||||||
MountKind::Shared { .. } => parts.push(gettext("Shared folder")),
|
MountKind::Shared { .. } => parts.push(gettext("Shared folder")),
|
||||||
|
MountKind::Attached { .. } => parts.push(gettext("My files mirror")),
|
||||||
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
|
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
|
||||||
(Some(MountMode::OnDemand), _) => parts.push(gettext("Going online only")),
|
(Some(MountMode::OnDemand), _) => parts.push(gettext("Going online only")),
|
||||||
(Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")),
|
(Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")),
|
||||||
@@ -1725,7 +1730,10 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
|||||||
(Some(MountMode::Unknown) | None, _) => parts.push(folder_mode_label(false)),
|
(Some(MountMode::Unknown) | None, _) => parts.push(folder_mode_label(false)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if matches!(spec.kind, MountKind::Device { .. }) {
|
if matches!(
|
||||||
|
spec.kind,
|
||||||
|
MountKind::Device { .. } | MountKind::Attached { .. }
|
||||||
|
) {
|
||||||
parts.push(match &spec.progress {
|
parts.push(match &spec.progress {
|
||||||
Some(p) => sync_progress_label(p),
|
Some(p) => sync_progress_label(p),
|
||||||
None if spec.paused => pgettext("state", "paused"),
|
None if spec.paused => pgettext("state", "paused"),
|
||||||
@@ -1737,8 +1745,10 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
|||||||
}
|
}
|
||||||
// Only the locations that are supposed to have a session report its absence:
|
// Only the locations that are supposed to have a session report its absence:
|
||||||
// a mirror folder never has one.
|
// a mirror folder never has one.
|
||||||
let expects_session = !matches!(spec.kind, MountKind::Device { .. })
|
let expects_session = !matches!(
|
||||||
|| spec.mode == MountMode::OnDemand
|
spec.kind,
|
||||||
|
MountKind::Device { .. } | MountKind::Attached { .. }
|
||||||
|
) || spec.mode == MountMode::OnDemand
|
||||||
|| spec.pending_mode == Some(MountMode::OnDemand);
|
|| spec.pending_mode == Some(MountMode::OnDemand);
|
||||||
if expects_session && !spec.mounted {
|
if expects_session && !spec.mounted {
|
||||||
// Translators: a folder's files can't be opened right now.
|
// Translators: a folder's files can't be opened right now.
|
||||||
@@ -1769,6 +1779,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_attached_mirror_is_available_without_a_fuse_session() {
|
||||||
|
let spec = spec(
|
||||||
|
MountKind::Attached { sync_folder_id: 7 },
|
||||||
|
MountMode::Mirror,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let subtitle = location_subtitle(&spec);
|
||||||
|
assert!(subtitle.contains("My files mirror"), "{subtitle}");
|
||||||
|
assert!(!subtitle.contains("not available"), "{subtitle}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_mirror_folder_is_never_reported_as_unmounted() {
|
fn a_mirror_folder_is_never_reported_as_unmounted() {
|
||||||
// It is a plain local directory: no FUSE session is expected, so saying
|
// It is a plain local directory: no FUSE session is expected, so saying
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
Schema: **45**. Synced folders now distinguish device backups from My files attachments.
|
||||||
|
Existing folders remain device backups; older builds cannot open the upgraded database.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing
|
||||||
|
My files folder. The first sync preserves one-sided files and differing versions (#67).
|
||||||
|
Attachments stay in mirror mode, are excluded from device profiles, and cannot delete
|
||||||
|
the remote root when syncing is stopped.
|
||||||
|
|
||||||
## [3.7.0] — 2026-11-02
|
## [3.7.0] — 2026-11-02
|
||||||
|
|
||||||
Schema: **44**, unchanged.
|
Schema: **44**, unchanged.
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pdfs sync add PATH` | Back up a local folder under this computer and keep it in sync. |
|
| `pdfs sync add PATH` | Back up a local folder under this computer and keep it in sync. |
|
||||||
|
| `pdfs sync attach PATH --to REMOTE` | Merge a real local folder with an existing My files folder, keeping both versions on initial conflicts. |
|
||||||
| `pdfs sync list` | List synced folders with their IDs and state. |
|
| `pdfs sync list` | List synced folders with their IDs and state. |
|
||||||
| `pdfs sync rm ID [--delete-remote]` | Stop syncing. Local files stay; `--delete-remote` also removes the copy on Proton Drive. |
|
| `pdfs sync rm ID [--delete-remote]` | Stop syncing. Local files stay; `--delete-remote` also removes the copy on Proton Drive. |
|
||||||
| `pdfs sync mode ID mirror\|ondemand` | Switch between a full local copy (`mirror`) and online only (`ondemand`). |
|
| `pdfs sync mode ID mirror\|ondemand` | Switch between a full local copy (`mirror`) and online only (`ondemand`). |
|
||||||
|
|||||||
+28
-1
@@ -5,6 +5,7 @@ up to Proton Drive and kept in step with it. This page covers adding one, what t
|
|||||||
that should not be backed up, changes that are still waiting to upload, conflicts, and pausing.
|
that should not be backed up, changes that are still waiting to upload, conflicts, and pausing.
|
||||||
|
|
||||||
- [Add a folder](#add-a-folder)
|
- [Add a folder](#add-a-folder)
|
||||||
|
- [Attach an existing My files folder](#attach-an-existing-my-files-folder)
|
||||||
- [Synced or online only](#synced-or-online-only)
|
- [Synced or online only](#synced-or-online-only)
|
||||||
- [Move files between synced folders and My files](#move-files-between-synced-folders-and-my-files)
|
- [Move files between synced folders and My files](#move-files-between-synced-folders-and-my-files)
|
||||||
- [Stop syncing a folder](#stop-syncing-a-folder)
|
- [Stop syncing a folder](#stop-syncing-a-folder)
|
||||||
@@ -74,7 +75,8 @@ again when they move into a synced one.
|
|||||||
|
|
||||||
**Stop Syncing** in the app, or `pdfs sync rm ID`, keeps both the local files and the copy on
|
**Stop Syncing** in the app, or `pdfs sync rm ID`, keeps both the local files and the copy on
|
||||||
Proton Drive. To delete the copy on Proton Drive as well, tick **Also delete from Proton Drive**,
|
Proton Drive. To delete the copy on Proton Drive as well, tick **Also delete from Proton Drive**,
|
||||||
or add `--delete-remote`.
|
or add `--delete-remote`. Attached My files folders always keep the cloud copy;
|
||||||
|
`--delete-remote` is refused for them.
|
||||||
|
|
||||||
## Sync a folder from scratch
|
## Sync a folder from scratch
|
||||||
|
|
||||||
@@ -169,3 +171,28 @@ The version you do not keep goes to the Trash, so a wrong choice can be undone.
|
|||||||
`pdfs sync resume` continues at once.
|
`pdfs sync resume` continues at once.
|
||||||
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
||||||
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
||||||
|
|
||||||
|
## Attach an existing My files folder
|
||||||
|
|
||||||
|
Run `pdfs sync attach /path/to/local --to Work` to merge a real local directory
|
||||||
|
with the existing `Work` folder in My files. `--to` also accepts an absolute path
|
||||||
|
inside the My files mount. Both folders must already exist.
|
||||||
|
|
||||||
|
The local directory stays a normal mirrored directory. The first pass copies files
|
||||||
|
found on only one side, adopts identical files, and keeps a conflict copy when
|
||||||
|
versions differ; it does not propagate deletions. Later passes use ordinary
|
||||||
|
bidirectional sync, including deletions and the existing wipe guards.
|
||||||
|
|
||||||
|
The My files root, shared folders, overlapping sync roots, and local paths inside
|
||||||
|
or containing the cloud mount are refused. Use `pdfs sync list` to find the new
|
||||||
|
folder's ID and monitor its state. `pdfs sync rm ID` stops syncing and keeps both
|
||||||
|
copies. `--delete-remote` is refused for attached folders.
|
||||||
|
|
||||||
|
Attached folders support **mirror** mode only. Use the main My files mount for online-only
|
||||||
|
access. They are listed separately from device backups and are not included in the device
|
||||||
|
profile used to restore another computer.
|
||||||
|
|
||||||
|
On the first sync, the remote version keeps the original name when both versions differ.
|
||||||
|
The local version is preserved as a conflict copy next to it and is uploaded too. If the
|
||||||
|
remote root is trashed or moved outside My files, syncing stops with an error and local
|
||||||
|
files are kept.
|
||||||
@@ -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.6.0
|
pkgver = 3.7.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
install = proton-drive.install
|
install = proton-drive.install
|
||||||
@@ -22,21 +22,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.6.0
|
provides = proton-drive-for-linux=3.7.0
|
||||||
provides = proton-drive-linux=3.6.0
|
provides = proton-drive-linux=3.7.0
|
||||||
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.6.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.6.0/proton-drive-linux-3.6.0-x86_64.tar.gz
|
source = proton-drive-linux-3.7.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.7.0/proton-drive-linux-3.7.0-x86_64.tar.gz
|
||||||
source = io.narl.proton-drive-linux-3.6.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.desktop
|
source = io.narl.proton-drive-linux-3.7.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux.desktop
|
||||||
source = io.narl.proton-drive-linux-tray-3.6.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
source = io.narl.proton-drive-linux-tray-3.7.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||||
source = io.narl.proton-drive-linux-3.6.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.svg
|
source = io.narl.proton-drive-linux-3.7.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux.svg
|
||||||
source = proton-drive-3.6.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/proton-drive.service
|
source = proton-drive-3.7.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/proton-drive.service
|
||||||
source = LICENSE-3.6.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/LICENSE
|
source = LICENSE-3.7.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/LICENSE
|
||||||
sha256sums = 8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7
|
sha256sums = 3f0d16c19b10b30e3f181e3c7fb439f0a8c38980e8ab7a4a96ae6596b349787c
|
||||||
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.6.0
|
pkgver=3.7.0
|
||||||
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')
|
||||||
@@ -36,7 +36,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=('8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7'
|
sha256sums=('3f0d16c19b10b30e3f181e3c7fb439f0a8c38980e8ab7a4a96ae6596b349787c'
|
||||||
'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.6.0.r0.g2720191
|
pkgver = 3.7.0.r0.g7ab14bb
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
install = proton-drive.install
|
install = proton-drive.install
|
||||||
@@ -25,8 +25,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.6.0.r0.g2720191
|
provides = proton-drive-for-linux=3.7.0.r0.g7ab14bb
|
||||||
provides = proton-drive-linux=3.6.0.r0.g2720191
|
provides = proton-drive-linux=3.7.0.r0.g7ab14bb
|
||||||
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.6.0.r0.g2720191
|
pkgver=3.7.0.r0.g7ab14bb
|
||||||
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.6.0
|
pkgver = 3.7.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
install = proton-drive.install
|
install = proton-drive.install
|
||||||
@@ -24,13 +24,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.6.0
|
provides = proton-drive-linux=3.7.0
|
||||||
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.6.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.6.0.tar.gz
|
source = proton-drive-for-linux-3.7.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.7.0.tar.gz
|
||||||
sha256sums = d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d
|
sha256sums = 542c534976e335cde6658de127e1eefffade9057d5332fd33457bc371c7a2737
|
||||||
|
|
||||||
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.6.0
|
pkgver=3.7.0
|
||||||
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')
|
||||||
@@ -26,7 +26,7 @@ options=('!lto')
|
|||||||
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
||||||
install=proton-drive.install
|
install=proton-drive.install
|
||||||
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=('d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d')
|
sha256sums=('542c534976e335cde6658de127e1eefffade9057d5332fd33457bc371c7a2737')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user