mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07d4ee42d9 | ||
|
|
0ac5e2e4fe | ||
|
|
7eb6aa0640 | ||
|
|
c0d211238b | ||
|
|
07c9b675d3 | ||
|
|
f82343b844 | ||
|
|
3e3dd7db41 | ||
|
|
d09e10dc7d | ||
|
|
8d0351124c | ||
|
|
a888e0bb28 | ||
|
|
c7fc85e943 | ||
|
|
5bfae0fee1 | ||
|
|
edd62c5f7a | ||
|
|
5c1530b4b9 | ||
|
|
ce324510de |
No files matched your search
Generated
+22
-22
@@ -166,7 +166,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -177,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1254,7 +1254,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1433,7 +1433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1896,7 +1896,7 @@ dependencies = [
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps 9.0.0",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3100,7 +3100,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "3.1.0"
|
||||
version = "3.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3490,7 +3490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "3.1.0"
|
||||
version = "3.1.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3516,7 +3516,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "3.1.0"
|
||||
version = "3.1.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "3.1.0"
|
||||
version = "3.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3778,9 +3778,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bde475139afb5fc2649621e1d283e243f9df2bb412f08802cdf3f4ffaf97c44"
|
||||
checksum = "014165ed0f2d3847a4b8956ca94a82ba16c82f2d5c7fa139c3541c15150dba21"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3805,9 +3805,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf32d0a03f7d13bd9bdcc8c9503e35e9df9318ca9f2a100dcf06cf50cb7ac8c"
|
||||
checksum = "9e16bf5592b7947708dd754defeaee7a58e9fa35988a6ea345b5b9a93765fd81"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"async-trait",
|
||||
@@ -3926,7 +3926,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4374,7 +4374,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4431,7 +4431,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4764,7 +4764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4932,10 +4932,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5266,7 +5266,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5590,7 +5590,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "3.1.0"
|
||||
version = "3.1.2"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.8.0" }
|
||||
proton-drive-rs = { version = "0.8.0" }
|
||||
proton-sdk = { version = "0.8.1" }
|
||||
proton-drive-rs = { version = "0.8.1" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
|
||||
@@ -628,6 +628,15 @@ enum DeviceCmd {
|
||||
#[arg(long, conflicts_with = "uid")]
|
||||
clear: bool,
|
||||
},
|
||||
/// Move another computer's folders into this computer's backup and sync
|
||||
/// them here. The other computer stays registered, without those folders.
|
||||
Migrate {
|
||||
/// Device uid (from `devices list`).
|
||||
uid: String,
|
||||
/// Skip the confirmation and accept every proposed local path.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -967,6 +976,24 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
|
||||
let uid = if clear { None } else { uid };
|
||||
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
|
||||
}
|
||||
DeviceCmd::Migrate { uid, yes } => {
|
||||
if !yes {
|
||||
println!(
|
||||
"Every folder that device backs up moves into this computer's backup.\n\
|
||||
The device stays registered, without those folders. Stop Proton Drive\n\
|
||||
on that computer first, or it sees its folders disappear."
|
||||
);
|
||||
confirm("Move them here?")?;
|
||||
}
|
||||
let response = control_request(CtlRequest::MigrateDevice { uid })?;
|
||||
// A script gets the one reply and runs `sync restore` itself.
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
ok_or_bail(response)?;
|
||||
// The folders are this device's now, so the plain restore offers them.
|
||||
cmd_sync_restore(yes, None)?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -109,7 +109,10 @@ fn client_config() -> ProtonClientConfiguration {
|
||||
let (app_version, user_agent) = match AppDirs::new() {
|
||||
Ok(dirs) => {
|
||||
let config = dirs.load_config();
|
||||
(config.app_version, config.user_agent)
|
||||
(
|
||||
config.resolved_app_version().to_string(),
|
||||
config.resolved_user_agent().to_string(),
|
||||
)
|
||||
}
|
||||
Err(_) => (APP_VERSION.to_string(), USER_AGENT.to_string()),
|
||||
};
|
||||
|
||||
+106
-34
@@ -3,7 +3,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::menu::PromptConfig;
|
||||
@@ -12,8 +12,19 @@ use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
|
||||
|
||||
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
||||
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
|
||||
pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
||||
pub const USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
||||
/// Follows the release so Proton can tell releases apart and retire old ones
|
||||
/// without retiring every one.
|
||||
pub const APP_VERSION: &str = concat!(
|
||||
"external-drive-linux@",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"-stable"
|
||||
);
|
||||
pub const USER_AGENT: &str = concat!("proton-drive-linux/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// The fixed identity every release up to 3.1.0 wrote into `config.json`. Read
|
||||
/// back as "unset" so those configs follow the release instead of pinning it.
|
||||
const LEGACY_APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
||||
const LEGACY_USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
||||
|
||||
/// Keyring service name; one entry per credential kind keyed by username.
|
||||
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
||||
@@ -76,10 +87,24 @@ fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode
|
||||
}
|
||||
|
||||
/// Configuration structure allowing the user to customize client identification headers.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct AppConfig {
|
||||
pub app_version: String,
|
||||
pub user_agent: String,
|
||||
/// Override for [`APP_VERSION`]. `None` means "use the release's own";
|
||||
/// the old fixed default reads as `None`, so it leaves the file on the
|
||||
/// next save.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "identity_override",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub app_version: Option<String>,
|
||||
/// Override for [`USER_AGENT`], handled like `app_version`.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "identity_override",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub user_agent: Option<String>,
|
||||
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
|
||||
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
|
||||
/// writes an explicit value here. Defaulted for configs predating the field.
|
||||
@@ -216,31 +241,28 @@ impl FileSort {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
app_version: APP_VERSION.to_string(),
|
||||
user_agent: USER_AGENT.to_string(),
|
||||
cache_budget: None,
|
||||
upload_limit: None,
|
||||
download_limit: None,
|
||||
mountpoint: None,
|
||||
ignore_patterns: None,
|
||||
device_uid: None,
|
||||
conflict_sweep: None,
|
||||
open_with: None,
|
||||
prompt: None,
|
||||
proton_theme: None,
|
||||
files_view: FilesView::default(),
|
||||
tray_hidden: false,
|
||||
language: None,
|
||||
online_map: false,
|
||||
local_first: None,
|
||||
}
|
||||
}
|
||||
/// Reads an `app_version` or `user_agent` override, dropping the fixed
|
||||
/// identity older releases stored as the default.
|
||||
fn identity_override<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<String>, D::Error> {
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
Ok(value.filter(|v| v != LEGACY_APP_VERSION && v != LEGACY_USER_AGENT))
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// The `x-pm-appversion` sent to Proton: the user's override, or
|
||||
/// [`APP_VERSION`].
|
||||
pub fn resolved_app_version(&self) -> &str {
|
||||
self.app_version.as_deref().unwrap_or(APP_VERSION)
|
||||
}
|
||||
|
||||
/// The `User-Agent` sent to Proton: the user's override, or
|
||||
/// [`USER_AGENT`].
|
||||
pub fn resolved_user_agent(&self) -> &str {
|
||||
self.user_agent.as_deref().unwrap_or(USER_AGENT)
|
||||
}
|
||||
|
||||
/// The effective cache budget in bytes: the user's explicit choice, or
|
||||
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
|
||||
pub fn resolved_cache_budget(&self) -> u64 {
|
||||
@@ -571,8 +593,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_app_config_serialization() {
|
||||
let config = AppConfig {
|
||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
||||
user_agent: "test-agent/1.0".to_string(),
|
||||
app_version: Some("external-drive-test-client@1.0.0".to_string()),
|
||||
user_agent: Some("test-agent/1.0".to_string()),
|
||||
cache_budget: Some(1234),
|
||||
upload_limit: Some(500_000),
|
||||
download_limit: None,
|
||||
@@ -596,8 +618,11 @@ mod tests {
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
||||
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
||||
assert_eq!(
|
||||
decoded.resolved_app_version(),
|
||||
"external-drive-test-client@1.0.0"
|
||||
);
|
||||
assert_eq!(decoded.resolved_user_agent(), "test-agent/1.0");
|
||||
assert_eq!(decoded.proton_theme, Some(true));
|
||||
assert!(decoded.tray_hidden);
|
||||
assert!(decoded.online_map);
|
||||
@@ -628,8 +653,55 @@ mod tests {
|
||||
#[test]
|
||||
fn test_default_app_config() {
|
||||
let default_config = AppConfig::default();
|
||||
assert_eq!(default_config.app_version, APP_VERSION);
|
||||
assert_eq!(default_config.user_agent, USER_AGENT);
|
||||
assert_eq!(default_config.resolved_app_version(), APP_VERSION);
|
||||
assert_eq!(default_config.resolved_user_agent(), USER_AGENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_client_identity_follows_the_release() {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
assert_eq!(
|
||||
APP_VERSION,
|
||||
format!("external-drive-linux@{version}-stable")
|
||||
);
|
||||
assert_eq!(USER_AGENT, format!("proton-drive-linux/{version}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_config_with_the_fixed_identity_follows_the_release() {
|
||||
// Every config written up to 3.1.0 holds these two values, so they must
|
||||
// not pin the identity once Proton retires 0.1.0.
|
||||
let json = format!(
|
||||
r#"{{"app_version":"{LEGACY_APP_VERSION}","user_agent":"{LEGACY_USER_AGENT}","tray_hidden":true}}"#
|
||||
);
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||
assert!(decoded.tray_hidden, "the rest of the config survives");
|
||||
|
||||
let saved = serde_json::to_string(&decoded).unwrap();
|
||||
assert!(!saved.contains("app_version"), "{saved}");
|
||||
assert!(!saved.contains("user_agent"), "{saved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_set_client_identity_is_kept() {
|
||||
let json = r#"{"app_version":"external-drive-linux@9.9.9-beta","user_agent":"custom/1"}"#;
|
||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
decoded.resolved_app_version(),
|
||||
"external-drive-linux@9.9.9-beta"
|
||||
);
|
||||
assert_eq!(decoded.resolved_user_agent(), "custom/1");
|
||||
let saved = serde_json::to_string(&decoded).unwrap();
|
||||
assert!(saved.contains("external-drive-linux@9.9.9-beta"), "{saved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_without_a_client_identity_loads() {
|
||||
let decoded: AppConfig = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -446,6 +446,13 @@ pub enum Request {
|
||||
/// of registering a duplicate. `uid: None` clears the pin.
|
||||
/// Replies with [`Response::Ok`].
|
||||
AdoptDevice { uid: Option<String> },
|
||||
/// Move every folder another device backs up into this machine's own
|
||||
/// device, as server-side moves on the main volume. The other device stays
|
||||
/// registered, without those folders. Sync folders here that already track
|
||||
/// one of them follow it to its new share. Nothing is downloaded: the moved
|
||||
/// folders are then offered by [`Request::ListRestorableFolders`].
|
||||
/// Replies with [`Response::Ok`].
|
||||
MigrateDevice { uid: String },
|
||||
|
||||
// ---- device folder sync ----------------------------------
|
||||
/// Add a local folder to this machine's device, uploading its tree and
|
||||
@@ -3082,6 +3089,9 @@ mod tests {
|
||||
uid: Some("dev-1".into()),
|
||||
},
|
||||
Request::AdoptDevice { uid: None },
|
||||
Request::MigrateDevice {
|
||||
uid: "dev-2".into(),
|
||||
},
|
||||
Request::ListRestorableFolders,
|
||||
Request::RestoreSyncFolders {
|
||||
items: vec![RestoreItem {
|
||||
|
||||
@@ -139,6 +139,17 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-point a synced folder at the share that now holds its remote folder,
|
||||
/// after the folder moved to another device without changing its uid.
|
||||
pub fn sync_folder_set_share(&self, id: i64, share_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder SET remote_share_id = ?2 WHERE id = ?1",
|
||||
params![id, share_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
|
||||
/// is safe to switch.
|
||||
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
|
||||
|
||||
@@ -4940,6 +4940,28 @@ fn pending_mode_is_queued_until_the_mode_is_reached() {
|
||||
assert_eq!(folder.pending_mode, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_sync_folder_points_at_its_new_share() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let id = db
|
||||
.sync_folder_add("/tmp/pdfs-moved-share", "vol~folder", "windows-share")
|
||||
.unwrap();
|
||||
let other = db
|
||||
.sync_folder_add("/tmp/pdfs-unmoved-share", "vol~other", "windows-share")
|
||||
.unwrap();
|
||||
|
||||
db.sync_folder_set_share(id, "linux-share").unwrap();
|
||||
|
||||
let folder = db.sync_folder_get(id).unwrap().unwrap();
|
||||
assert_eq!(folder.remote_share_id, "linux-share");
|
||||
// A move keeps the uid, so nothing but the share changes.
|
||||
assert_eq!(folder.remote_uid, "vol~folder");
|
||||
assert_eq!(
|
||||
db.sync_folder_get(other).unwrap().unwrap().remote_share_id,
|
||||
"windows-share"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_commit_clears_only_the_intent_it_satisfies() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
|
||||
@@ -369,7 +369,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||
RefreshScope::Photos { .. } => &[],
|
||||
},
|
||||
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||
R::AddSyncFolder { .. }
|
||||
| R::RemoveSyncFolder { .. }
|
||||
| R::SetSyncFolderMode { .. }
|
||||
@@ -1297,6 +1297,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(message) => CtlResponse::Ok { message },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::MigrateDevice { uid }) => match core.migrate_device(&uid) {
|
||||
Ok(message) => CtlResponse::Ok { message },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::AddSyncFolder { local_path }) => {
|
||||
// Registering the device and uploading a folder tree far outlasts the
|
||||
// socket read timeout, so ack immediately and work on a background
|
||||
|
||||
@@ -19,6 +19,7 @@ use pdfs_core::control::{
|
||||
};
|
||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||
use pdfs_core::mounts::MountMode;
|
||||
use pdfs_core::profile::PROFILE_DIR_NAME;
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
||||
use proton_drive_rs::{DeviceType, Node};
|
||||
@@ -114,6 +115,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
||||
.map_err(|e| CoreError::internal(format!("write config: {e}")))
|
||||
}
|
||||
|
||||
/// The name a folder moved in from `device` takes when `name` is already
|
||||
/// taken under this device's root, or `None` when it can keep its own.
|
||||
fn migrated_name(name: &str, device: &str, taken: &[String]) -> Option<String> {
|
||||
let free = |candidate: &str| !taken.iter().any(|t| t == candidate);
|
||||
if free(name) {
|
||||
return None;
|
||||
}
|
||||
let base = format!("{name} ({device})");
|
||||
if free(&base) {
|
||||
return Some(base);
|
||||
}
|
||||
(2..)
|
||||
.map(|n| format!("{base} {n}"))
|
||||
.find(|candidate| free(candidate))
|
||||
}
|
||||
|
||||
impl Core {
|
||||
// ---- devices ----------------------------------------------------------
|
||||
|
||||
@@ -260,6 +277,107 @@ impl Core {
|
||||
})
|
||||
}
|
||||
|
||||
/// Move every folder another device backs up into this machine's own
|
||||
/// device, so this machine carries on backing them up as itself. The other
|
||||
/// device stays registered, without those folders.
|
||||
///
|
||||
/// Device roots all live on the main volume, so each folder is one
|
||||
/// server-side move: nothing is downloaded or uploaded, and no quota is
|
||||
/// used twice. A folder whose name is taken here keeps its content under
|
||||
/// "<name> (<device>)". A failed folder does not stop the others, and a
|
||||
/// second run picks up whatever is still left on the other device.
|
||||
///
|
||||
/// Nothing is synced to this disk here. The moved folders are now this
|
||||
/// device's, so the ordinary restore offers them. Only folders that were
|
||||
/// already synced here from the other device are touched: their uid
|
||||
/// survives the move but their share does not.
|
||||
pub(crate) fn migrate_device(&self, uid: &str) -> CoreResult<String> {
|
||||
let source = self.restore_source(Some(uid))?;
|
||||
let target = self.ensure_device()?;
|
||||
if source.uid == target.uid {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is this computer's own device",
|
||||
source.name
|
||||
)));
|
||||
}
|
||||
let bad_root = |d: &StoredDevice| {
|
||||
parse_uid(&d.root_uid)
|
||||
.ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", d.root_uid)))
|
||||
};
|
||||
let (source_root, target_root) = (bad_root(&source)?, bad_root(&target)?);
|
||||
if source_root.volume_id != target_root.volume_id {
|
||||
return Err(CoreError::invalid(
|
||||
"the two devices are on different Proton Drive volumes; copy it instead",
|
||||
));
|
||||
}
|
||||
|
||||
let mut taken: Vec<String> = self
|
||||
.device_root_nodes(&target_root)?
|
||||
.into_iter()
|
||||
.filter(|n| !n.trashed)
|
||||
.map(|n| n.name)
|
||||
.collect();
|
||||
let synced = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
|
||||
let mut moved = 0usize;
|
||||
let mut renamed: Vec<String> = Vec::new();
|
||||
let mut left: Vec<String> = Vec::new();
|
||||
let mut failed: Vec<String> = Vec::new();
|
||||
for node in self.device_root_nodes(&source_root)? {
|
||||
if node.trashed || node.name == PROFILE_DIR_NAME {
|
||||
continue;
|
||||
}
|
||||
// A device root only ever holds folders (B68); anything else was
|
||||
// put there by hand and is left for the user to deal with.
|
||||
if !node.is_folder() {
|
||||
left.push(node.name);
|
||||
continue;
|
||||
}
|
||||
let new_name = migrated_name(&node.name, &source.name, &taken);
|
||||
if let Err(e) = self.move_rename_remote(&node.uid, &target_root, new_name.as_deref()) {
|
||||
warn!(name = %node.name, error = %e, "migrate: could not move folder");
|
||||
failed.push(format!("{}: {e}", node.name));
|
||||
continue;
|
||||
}
|
||||
let landed = new_name.clone().unwrap_or_else(|| node.name.clone());
|
||||
if new_name.is_some() {
|
||||
renamed.push(format!("{} -> {landed}", node.name));
|
||||
}
|
||||
taken.push(landed);
|
||||
moved += 1;
|
||||
|
||||
let uid = node.uid.to_string();
|
||||
for folder in synced.iter().filter(|f| f.remote_uid == uid) {
|
||||
if let Err(e) = self.db.sync_folder_set_share(folder.id, &target.share_id) {
|
||||
warn!(id = folder.id, error = ?e, "migrate: could not re-point sync folder");
|
||||
}
|
||||
self.sync_now(Some(folder.id));
|
||||
}
|
||||
}
|
||||
self.invalidate_children_of(&source_root);
|
||||
self.invalidate_children_of(&target_root);
|
||||
|
||||
let mut message = format!("moved {moved} folder(s) from {}", source.name);
|
||||
if !renamed.is_empty() {
|
||||
message.push_str(&format!("; renamed: {}", renamed.join(", ")));
|
||||
}
|
||||
if !left.is_empty() {
|
||||
message.push_str(&format!("; left behind: {}", left.join(", ")));
|
||||
}
|
||||
if !failed.is_empty() {
|
||||
message.push_str(&format!("; failed: {}", failed.join(", ")));
|
||||
}
|
||||
info!(from = %source.name, to = %target.name, moved, failed = failed.len(),
|
||||
"device migrated");
|
||||
if moved == 0 && !failed.is_empty() {
|
||||
return Err(CoreError::conflict(message));
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
// ---- device folder sync -------------------------
|
||||
|
||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||
@@ -360,21 +478,25 @@ impl Core {
|
||||
root_uid: &NodeUid,
|
||||
name: &str,
|
||||
) -> CoreResult<Option<NodeUid>> {
|
||||
Ok(self
|
||||
.device_root_nodes(root_uid)?
|
||||
.into_iter()
|
||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
||||
.map(|n| n.uid))
|
||||
}
|
||||
|
||||
/// Everything directly under a device root, trashed nodes included.
|
||||
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
self.rt
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
||||
.map(|n| n.uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))
|
||||
}
|
||||
|
||||
/// Add a local folder to this device's sync set: register the device if
|
||||
@@ -842,6 +964,23 @@ impl Core {
|
||||
if folder.state != "idle" {
|
||||
return Err(SwitchBlocked::NotNow);
|
||||
}
|
||||
// `idle` only says the last pass applied everything it tried.
|
||||
// It never tries an ignored path, a symlink or a folder it
|
||||
// cannot read, it defers a file open for writing, and a file can
|
||||
// change after it ends. The eviction below deletes all of those
|
||||
// for good, so it waits until nothing here is missing from Drive
|
||||
// (B198).
|
||||
let baseline = self
|
||||
.db
|
||||
.sync_entries(id)
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
|
||||
if let Some(reason) =
|
||||
sync::mirror_tree_unsynced(&local, &baseline, &self.ignore_rules(&local))
|
||||
{
|
||||
return Err(SwitchBlocked::Failed(format!(
|
||||
"going online-only would delete what is only on this computer: {reason}"
|
||||
)));
|
||||
}
|
||||
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
||||
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
||||
})?;
|
||||
@@ -1107,10 +1246,38 @@ mod tests {
|
||||
use pdfs_core::db::StoredSyncFolder;
|
||||
|
||||
use super::{
|
||||
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
|
||||
ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches,
|
||||
restore_snapshot_matches, select_mode_transition,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn migrated_folder_keeps_its_name_when_it_is_free() {
|
||||
let taken = vec!["Pictures".to_string()];
|
||||
assert_eq!(migrated_name("Documents", "DESKTOP-1", &taken), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_folder_is_named_after_its_device_on_a_clash() {
|
||||
let taken = vec!["Documents".to_string()];
|
||||
assert_eq!(
|
||||
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
|
||||
Some("Documents (DESKTOP-1)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_folder_is_numbered_when_the_device_name_is_taken_too() {
|
||||
let taken = vec![
|
||||
"Documents".to_string(),
|
||||
"Documents (DESKTOP-1)".to_string(),
|
||||
"Documents (DESKTOP-1) 2".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
|
||||
Some("Documents (DESKTOP-1) 3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_mode_cannot_select_a_destructive_transition() {
|
||||
let mut unmounts = 0;
|
||||
|
||||
@@ -744,6 +744,9 @@ struct Core {
|
||||
/// uploading that same tree — the engine would upload files as they vanish
|
||||
/// and then walk the FUSE mount as if it were local.
|
||||
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
|
||||
/// Per-sync-folder paths the last pass could not read, so the activity feed
|
||||
/// says so when the set changes rather than on every poll (B196).
|
||||
sync_unscanned: Arc<Mutex<HashMap<i64, Vec<String>>>>,
|
||||
/// Nodes this daemon changed on the remote itself, and how many echoes of
|
||||
/// those changes the event feed still owes us.
|
||||
///
|
||||
|
||||
@@ -475,6 +475,7 @@ pub(crate) fn mount_with(
|
||||
sync_tx,
|
||||
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
|
||||
states: Arc::new(StateRegistry::default()),
|
||||
};
|
||||
// Before anything can queue work against it: the drain thread below reaches
|
||||
|
||||
+542
-99
@@ -59,8 +59,9 @@ pub(crate) use planner::base_name;
|
||||
#[cfg(test)]
|
||||
use planner::conflict_path;
|
||||
use planner::{
|
||||
FilePlan, classification_order, conflict_path_with_suffix, filter_baseline, guard_local_wipe,
|
||||
join_rel, parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size,
|
||||
FilePlan, Unscanned, classification_order, conflict_path_with_suffix, filter_baseline,
|
||||
guard_local_wipe, join_rel, local_deletions, parent_rel, plan_file, rel_to_path, remote_sig,
|
||||
tracked_baseline, unchanged_remote_size,
|
||||
};
|
||||
/// What a [`Pending`] op did, so the reconcile can update shared state on the
|
||||
/// engine thread (never inside a task).
|
||||
@@ -187,6 +188,7 @@ impl Core {
|
||||
"idle"
|
||||
};
|
||||
let _ = self.db.sync_folder_set_state(folder.id, state, now_secs());
|
||||
self.report_unscanned(folder.id, name, &outcome.unscanned);
|
||||
// Summarise a pass that actually moved something. A poll over an
|
||||
// unchanged folder does nothing and says nothing — otherwise the
|
||||
// feed would fill with "synced, 0 changes" every two minutes.
|
||||
@@ -243,6 +245,37 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the activity feed what a pass over this folder had to leave out
|
||||
/// because it cannot be read, once each time that changes. Every other path
|
||||
/// in the folder still synced, so this does not make the folder's state an
|
||||
/// error; but a folder the user has no permission for is never going to
|
||||
/// reach Drive, and they should hear that once rather than not at all
|
||||
/// (B196).
|
||||
fn report_unscanned(&self, folder_id: i64, name: &str, unscanned: &[String]) {
|
||||
let previous = {
|
||||
let mut last = self.sync_unscanned.lock();
|
||||
if unscanned.is_empty() {
|
||||
last.remove(&folder_id)
|
||||
} else {
|
||||
last.insert(folder_id, unscanned.to_vec())
|
||||
}
|
||||
};
|
||||
if unscanned.is_empty() || previous.as_deref() == Some(unscanned) {
|
||||
return;
|
||||
}
|
||||
const SHOWN: usize = 3;
|
||||
let mut detail = unscanned[..unscanned.len().min(SHOWN)].join(", ");
|
||||
if unscanned.len() > SHOWN {
|
||||
detail.push_str(&format!(" and {} more", unscanned.len() - SHOWN));
|
||||
}
|
||||
self.log_activity(
|
||||
ActivityKind::Sync,
|
||||
format!("{} item(s) in {name} cannot be synced", unscanned.len()),
|
||||
detail,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Compile the ignore rules for a synced folder: the global patterns from
|
||||
/// the config, plus any `.pdfsignore` at `local_root`.
|
||||
///
|
||||
@@ -250,7 +283,7 @@ impl Core {
|
||||
/// than to "ignore nothing" — the defaults exclude build and VCS trees, and
|
||||
/// silently uploading those because a config read failed is the outcome this
|
||||
/// feature exists to prevent.
|
||||
fn ignore_rules(&self, local_root: &Path) -> IgnoreRules {
|
||||
pub(crate) fn ignore_rules(&self, local_root: &Path) -> IgnoreRules {
|
||||
let globals = match pdfs_core::config::AppDirs::new() {
|
||||
Ok(dirs) => dirs.load_config().resolved_ignore_patterns(),
|
||||
Err(e) => {
|
||||
@@ -313,15 +346,23 @@ impl Core {
|
||||
self.progress_scan_total(folder_id, baseline.len());
|
||||
|
||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||
let mut unscanned = Unscanned::default();
|
||||
let writing = open_for_write_set(local_root);
|
||||
self.walk_local(
|
||||
folder_id, local_root, local_root, rules, &writing, &mut local,
|
||||
folder_id,
|
||||
local_root,
|
||||
rules,
|
||||
&writing,
|
||||
&mut local,
|
||||
&mut unscanned,
|
||||
)?;
|
||||
// The guard compares against the paths this pass could still see: an
|
||||
// ignored path is absent from `local` by rule, not by loss, and counting
|
||||
// those as missing would trip the guard on every pass — wedging the
|
||||
// folder's sync permanently — the moment a rule covers the whole tree.
|
||||
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?;
|
||||
// ignored or unreadable path is absent from `local` by rule or by
|
||||
// failure, not by loss, and counting those as missing would trip the
|
||||
// guard on every pass — wedging the folder's sync permanently — the
|
||||
// moment a rule covers the whole tree.
|
||||
let tracked = tracked_baseline(&baseline, rules, &unscanned);
|
||||
guard_local_wipe(&tracked, &local)?;
|
||||
|
||||
// The remote folder uids come from the baseline instead of a walk: every
|
||||
// directory this device has synced recorded its uid there when it was
|
||||
@@ -391,6 +432,7 @@ impl Core {
|
||||
|
||||
let mut outcome = Outcome {
|
||||
deferred: local.values().filter(|item| item.open_for_write).count(),
|
||||
unscanned: unscanned.report(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut order: Vec<&String> = local.keys().collect();
|
||||
@@ -515,13 +557,8 @@ impl Core {
|
||||
// showing the folder's remote contents — otherwise everything they deleted
|
||||
// comes back the moment the switch lands. Shallowest first, skipping anything
|
||||
// under a folder already trashed (its children went with it).
|
||||
let mut missing: Vec<&String> = baseline
|
||||
.keys()
|
||||
.filter(|rel| !local.contains_key(*rel))
|
||||
.collect();
|
||||
missing.sort_by_key(|p| p.matches('/').count());
|
||||
let mut trashed: Vec<String> = Vec::new();
|
||||
for rel in missing {
|
||||
for rel in &local_deletions(&tracked, &local) {
|
||||
if trashed
|
||||
.iter()
|
||||
.any(|dir| rel.starts_with(&format!("{dir}/")))
|
||||
@@ -586,12 +623,18 @@ impl Core {
|
||||
self.progress_scan_total(folder_id, baseline.len() * 2);
|
||||
|
||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||
let mut unscanned = Unscanned::default();
|
||||
let writing = open_for_write_set(local_root);
|
||||
self.walk_local(
|
||||
folder_id, local_root, local_root, rules, &writing, &mut local,
|
||||
folder_id,
|
||||
local_root,
|
||||
rules,
|
||||
&writing,
|
||||
&mut local,
|
||||
&mut unscanned,
|
||||
)?;
|
||||
// See `push_pass`: the guard must not count rule-excluded paths as lost.
|
||||
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?;
|
||||
// See `push_pass`: the guard must not count excluded paths as lost.
|
||||
guard_local_wipe(&tracked_baseline(&baseline, rules, &unscanned), &local)?;
|
||||
|
||||
let mut remote: HashMap<String, RemoteItem> = HashMap::new();
|
||||
let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new();
|
||||
@@ -605,9 +648,15 @@ impl Core {
|
||||
&baseline,
|
||||
)?;
|
||||
|
||||
let order = classification_order(&local, &remote, &baseline, rules);
|
||||
let mut order = classification_order(&local, &remote, &baseline, rules);
|
||||
// A path the scan could not read is no more a local deletion than an
|
||||
// ignored one, and a remote path under it has nowhere to be written.
|
||||
order.retain(|rel| !unscanned.covers(rel));
|
||||
|
||||
let mut outcome = Outcome::default();
|
||||
let mut outcome = Outcome {
|
||||
unscanned: unscanned.report(),
|
||||
..Default::default()
|
||||
};
|
||||
// Folders to delete, collected here and removed deepest-first at the end
|
||||
// so a parent is never removed before its children.
|
||||
let mut delete_local_dirs: Vec<String> = Vec::new();
|
||||
@@ -873,19 +922,19 @@ impl Core {
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Recursively walk a local directory into `out`, keyed by `/`-joined relative
|
||||
/// path. Symlinks and other special files are skipped. Reports each entry to the
|
||||
/// pass's scan progress.
|
||||
/// Recursively walk a local tree into `out`, keyed by `/`-joined relative
|
||||
/// path. Symlinks and other special files are skipped, and paths that cannot
|
||||
/// be read go to `unscanned`. Reports each entry to the pass's scan progress.
|
||||
fn walk_local(
|
||||
&self,
|
||||
folder_id: i64,
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
rules: &IgnoreRules,
|
||||
writing: &HashSet<PathBuf>,
|
||||
out: &mut HashMap<String, LocalItem>,
|
||||
unscanned: &mut Unscanned,
|
||||
) -> Result<(), String> {
|
||||
walk_local_tree(root, dir, rules, writing, out, &mut |rel| {
|
||||
walk_local_tree(root, rules, writing, out, unscanned, &mut |rel| {
|
||||
self.progress_scanned(folder_id, base_name(rel))
|
||||
})
|
||||
}
|
||||
@@ -1582,51 +1631,120 @@ fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the
|
||||
/// local tree under `dir` into `out`, reporting each entry to `scanned`.
|
||||
/// local tree under `root` into `out`, reporting each entry to `scanned`.
|
||||
///
|
||||
/// **Every failure here is fatal to the pass, deliberately.** A `read_dir` that
|
||||
/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of
|
||||
/// them would, if skipped, leave `out` describing a *smaller* tree than the one
|
||||
/// on disk. Reconciliation reads a path present in the baseline and absent from
|
||||
/// the scan as "the user deleted this", and propagates that to Drive. So an
|
||||
/// unreadable subdirectory would trash its remote counterpart, and the deeper
|
||||
/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the
|
||||
/// whole pass keeps the previous baseline and leaves the remote alone; the next
|
||||
/// pass retries once the transient condition clears.
|
||||
/// **A path the scan cannot read is left out and recorded in `unscanned`, never
|
||||
/// silently dropped.** Every pass drops what `unscanned` covers from
|
||||
/// classification, so a baseline row under it is neither a local deletion nor a
|
||||
/// download target (`docs/BUGS.md` B55). That covers a subfolder the pass has no
|
||||
/// permission for, an entry that vanished between the listing and its `stat`,
|
||||
/// and a name that is not UTF-8. Failing the whole pass on any of them, as this
|
||||
/// did before, left one root-owned folder stopping everything else in the tree
|
||||
/// from syncing, on every pass, for good (B196).
|
||||
///
|
||||
/// The root is the exception, and so is any other I/O error: an absent or
|
||||
/// unreadable root fails the pass, since leaving the root out would leave
|
||||
/// nothing in, and an error this cannot name a cause for is not one to guess
|
||||
/// about.
|
||||
///
|
||||
/// Split out from the method so exactly that can be tested against a real tree
|
||||
/// with a real unreadable directory in it, which needs no `Core` at all.
|
||||
fn walk_local_tree(
|
||||
root: &Path,
|
||||
rules: &IgnoreRules,
|
||||
writing: &HashSet<PathBuf>,
|
||||
out: &mut HashMap<String, LocalItem>,
|
||||
unscanned: &mut Unscanned,
|
||||
scanned: &mut dyn FnMut(&str),
|
||||
) -> Result<(), String> {
|
||||
walk_local_dir(root, root, rules, writing, out, unscanned, scanned).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Why [`walk_local_dir`] could not finish a folder.
|
||||
enum ScanError {
|
||||
/// An I/O error on `what`, which the caller may leave out of the pass.
|
||||
Io { what: String, error: std::io::Error },
|
||||
/// Anything else, which fails the pass.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ScanError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ScanError::Io { what, error } => write!(f, "{what}: {error}"),
|
||||
ScanError::Failed(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave `rel` out of the pass for `failure`, or hand it back when it is one
|
||||
/// that fails the pass. Permission denied is reported to the user; an entry
|
||||
/// that vanished mid-scan is not, since the next pass sees it settled.
|
||||
fn leave_out(rel: &str, failure: ScanError, unscanned: &mut Unscanned) -> Result<(), ScanError> {
|
||||
let ScanError::Io { what, error } = &failure else {
|
||||
return Err(failure);
|
||||
};
|
||||
match error.kind() {
|
||||
std::io::ErrorKind::PermissionDenied => {
|
||||
warn!(%what, %error, "sync: cannot read; leaving it out of the pass");
|
||||
unscanned.unreadable(rel.to_string(), &error.to_string());
|
||||
Ok(())
|
||||
}
|
||||
std::io::ErrorKind::NotFound => {
|
||||
debug!(%what, "sync: gone mid-scan; leaving it out of the pass");
|
||||
unscanned.vanished(rel.to_string());
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(failure),
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_local_dir(
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
rules: &IgnoreRules,
|
||||
writing: &HashSet<PathBuf>,
|
||||
out: &mut HashMap<String, LocalItem>,
|
||||
unscanned: &mut Unscanned,
|
||||
scanned: &mut dyn FnMut(&str),
|
||||
) -> Result<(), String> {
|
||||
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
||||
) -> Result<(), ScanError> {
|
||||
let io = |what: String| move |error| ScanError::Io { what, error };
|
||||
let entries = std::fs::read_dir(dir).map_err(io(format!("read {}", dir.display())))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
|
||||
let entry = entry.map_err(io(format!("read entry in {}", dir.display())))?;
|
||||
let path = entry.path();
|
||||
let meta = std::fs::symlink_metadata(&path)
|
||||
.map_err(|e| format!("stat {}: {e}", path.display()))?;
|
||||
let stripped = path.strip_prefix(root).map_err(|e| {
|
||||
format!(
|
||||
ScanError::Failed(format!(
|
||||
"local path {} escaped sync root {}: {e}",
|
||||
path.display(),
|
||||
root.display()
|
||||
)
|
||||
})?;
|
||||
let rel = stripped.to_str().ok_or_else(|| {
|
||||
format!(
|
||||
"local path {} is not valid UTF-8; refusing a destructive sync pass",
|
||||
path.display()
|
||||
)
|
||||
))
|
||||
})?;
|
||||
let Some(rel) = stripped.to_str() else {
|
||||
// Nothing can be keyed by this name: not the baseline, which only
|
||||
// ever held paths this scan produced, and not Drive, whose names are
|
||||
// UTF-8. Leaving it out cannot read as a deletion of anything.
|
||||
warn!(path = %path.display(), "sync: name is not valid UTF-8; leaving it out of the pass");
|
||||
unscanned.unreadable(
|
||||
stripped.to_string_lossy().into_owned(),
|
||||
"name is not valid UTF-8",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
// Ignore our own in-flight download temp files.
|
||||
if rel.contains(".pdfs-tmp-") {
|
||||
continue;
|
||||
}
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
// Gone since the listing, or in a folder that lists but does not
|
||||
// allow `stat`. Either way this pass cannot say what it is.
|
||||
Err(error) => {
|
||||
let what = format!("stat {}", path.display());
|
||||
leave_out(rel, ScanError::Io { what, error }, unscanned)?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Ignored paths are dropped here as well as from the classification
|
||||
// union — not for correctness (the union filter is what makes this
|
||||
// safe) but so an ignored `node_modules/` is never descended into or
|
||||
@@ -1646,7 +1764,15 @@ fn walk_local_tree(
|
||||
},
|
||||
);
|
||||
scanned(rel);
|
||||
walk_local_tree(root, &path, rules, writing, out, scanned)?;
|
||||
if let Err(failure) =
|
||||
walk_local_dir(root, &path, rules, writing, out, unscanned, scanned)
|
||||
{
|
||||
leave_out(rel, failure, unscanned)?;
|
||||
// What the folder did list before failing goes with it: a
|
||||
// half-read folder is as unknown as an unread one.
|
||||
let prefix = format!("{rel}/");
|
||||
out.retain(|k, _| k != rel && !k.starts_with(&prefix));
|
||||
}
|
||||
} else if meta.is_file() {
|
||||
// Check whether any process holds this file open for writing.
|
||||
// The canonical path is used because /proc/*/fd links resolve
|
||||
@@ -1684,7 +1810,7 @@ pub(crate) fn mirror_subtree_unsynced(
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
) -> Option<String> {
|
||||
let mut seen = HashSet::new();
|
||||
if let Some(reason) = unsynced_local(root, rel, baseline, &mut seen) {
|
||||
if let Some(reason) = unsynced_local(root, rel, baseline, None, &mut seen) {
|
||||
return Some(reason);
|
||||
}
|
||||
let prefix = format!("{rel}/");
|
||||
@@ -1695,45 +1821,93 @@ pub(crate) fn mirror_subtree_unsynced(
|
||||
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
||||
}
|
||||
|
||||
/// [`mirror_subtree_unsynced`] for a whole mirror folder: why its local copy
|
||||
/// holds something Proton Drive does not, or `None` when evicting it loses
|
||||
/// nothing. The switch to on-demand asks this before it empties the folder.
|
||||
///
|
||||
/// A push pass that ends `idle` is not enough on its own. It never uploads an
|
||||
/// ignored path, a symlink or a folder it cannot read, it defers a file open
|
||||
/// for writing, and a file can change after it finishes; the eviction deleted
|
||||
/// all of those for good (B198).
|
||||
///
|
||||
/// A tracked path deleted here but still in the baseline counts, since the
|
||||
/// mount would show it again. An ignored one does not: ignoring never carries
|
||||
/// a deletion to Drive, so its row stays in the baseline for good, and holding
|
||||
/// the switch on it would hold it forever.
|
||||
pub(crate) fn mirror_tree_unsynced(
|
||||
root: &Path,
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
rules: &IgnoreRules,
|
||||
) -> Option<String> {
|
||||
let mut seen = HashSet::new();
|
||||
if let Some(reason) = unsynced_local(root, "", baseline, Some(rules), &mut seen) {
|
||||
return Some(reason);
|
||||
}
|
||||
let mut missing: Vec<String> = filter_baseline(baseline, rules)
|
||||
.into_keys()
|
||||
.filter(|r| !seen.contains(r.as_str()))
|
||||
.collect();
|
||||
missing.sort_unstable();
|
||||
missing
|
||||
.first()
|
||||
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
||||
}
|
||||
|
||||
/// The walk behind both checks. `rules`, when given, only names an ignored
|
||||
/// path as such in the reason; it is still something only this disk has.
|
||||
fn unsynced_local(
|
||||
root: &Path,
|
||||
rel: &str,
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
rules: Option<&IgnoreRules>,
|
||||
seen: &mut HashSet<String>,
|
||||
) -> Option<String> {
|
||||
let path = root.join(rel_to_path(rel));
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
let path = if rel.is_empty() {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
root.join(rel_to_path(rel))
|
||||
};
|
||||
let Some(base) = baseline.get(rel) else {
|
||||
return Some(format!("{rel} is not on Proton Drive yet"));
|
||||
};
|
||||
seen.insert(rel.to_string());
|
||||
if meta.is_file() {
|
||||
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base)))
|
||||
.then(|| format!("{rel} has changes that are not on Proton Drive yet"));
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
return Some(format!("{rel} is not a regular file or folder"));
|
||||
// The folder itself has no baseline row; only what is in it does.
|
||||
if !rel.is_empty() {
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
};
|
||||
let Some(base) = baseline.get(rel) else {
|
||||
if rules.is_some_and(|rules| rules.is_ignored(rel, meta.is_dir())) {
|
||||
return Some(format!(
|
||||
"{rel} is excluded from sync, so it is only on this computer"
|
||||
));
|
||||
}
|
||||
return Some(format!("{rel} is not on Proton Drive yet"));
|
||||
};
|
||||
seen.insert(rel.to_string());
|
||||
if meta.is_file() {
|
||||
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base)))
|
||||
.then(|| format!("{rel} has changes that are not on Proton Drive yet"));
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
return Some(format!("{rel} is not a regular file or folder"));
|
||||
}
|
||||
}
|
||||
let shown = if rel.is_empty() { "the folder" } else { rel };
|
||||
let entries = match std::fs::read_dir(&path) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
Err(e) => return Some(format!("cannot read {shown}: {e}")),
|
||||
};
|
||||
for entry in entries {
|
||||
let name = match entry {
|
||||
Ok(entry) => entry.file_name(),
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
Err(e) => return Some(format!("cannot read {shown}: {e}")),
|
||||
};
|
||||
let Some(name) = name.to_str() else {
|
||||
return Some(format!("{rel} holds a name that is not valid UTF-8"));
|
||||
return Some(format!("{shown} holds a name that is not valid UTF-8"));
|
||||
};
|
||||
// A download the engine left half done; it is the engine's to clean up.
|
||||
if name.contains(".pdfs-tmp-") {
|
||||
continue;
|
||||
}
|
||||
if let Some(reason) = unsynced_local(root, &format!("{rel}/{name}"), baseline, seen) {
|
||||
if let Some(reason) = unsynced_local(root, &join_rel(rel, name), baseline, rules, seen) {
|
||||
return Some(reason);
|
||||
}
|
||||
}
|
||||
@@ -2154,7 +2328,15 @@ mod tests {
|
||||
let writing = HashSet::new();
|
||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||
let mut progress = no_progress();
|
||||
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
||||
walk_local_tree(
|
||||
&root,
|
||||
&rules,
|
||||
&writing,
|
||||
&mut local,
|
||||
&mut Unscanned::default(),
|
||||
&mut progress,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(local.is_empty(), "the replacement root is genuinely empty");
|
||||
|
||||
for size in 1..=3 {
|
||||
@@ -2170,7 +2352,15 @@ mod tests {
|
||||
// remounted folder resumes rather than staying wedged.
|
||||
std::fs::write(root.join("file0.txt"), b"back").unwrap();
|
||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
||||
walk_local_tree(
|
||||
&root,
|
||||
&rules,
|
||||
&writing,
|
||||
&mut local,
|
||||
&mut Unscanned::default(),
|
||||
&mut progress,
|
||||
)
|
||||
.unwrap();
|
||||
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
|
||||
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
|
||||
|
||||
@@ -2475,33 +2665,176 @@ mod tests {
|
||||
|_: &str| {}
|
||||
}
|
||||
|
||||
/// A directory the pass cannot read must fail the pass. Omitting it would
|
||||
/// hand reconciliation a baseline-minus-subtree, which it reads as the user
|
||||
/// having deleted that subtree and propagates to Drive (B55).
|
||||
/// Scan `root` with no rules, returning what it found and left out.
|
||||
fn scan(root: &Path) -> Result<(HashMap<String, LocalItem>, Unscanned), String> {
|
||||
let mut out = HashMap::new();
|
||||
let mut unscanned = Unscanned::default();
|
||||
let mut progress = no_progress();
|
||||
walk_local_tree(
|
||||
root,
|
||||
&rules_for(&[]),
|
||||
&HashSet::new(),
|
||||
&mut out,
|
||||
&mut unscanned,
|
||||
&mut progress,
|
||||
)?;
|
||||
Ok((out, unscanned))
|
||||
}
|
||||
|
||||
fn sorted_keys<V>(map: &HashMap<String, V>) -> Vec<&str> {
|
||||
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
keys
|
||||
}
|
||||
|
||||
/// B196, the report in #33: a folder the user may not read, such as a
|
||||
/// container's root-owned cache, is left out of the pass and named, and
|
||||
/// everything around it still scans. It used to fail the whole pass, so
|
||||
/// nothing else in the tree ever synced again.
|
||||
#[test]
|
||||
fn an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it() {
|
||||
fn an_unreadable_subdirectory_is_left_out_and_the_rest_scans() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let root = sync_test_dir("scan-eacces");
|
||||
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||
let locked = root.join("locked");
|
||||
std::fs::create_dir_all(root.join("docker/cache")).unwrap();
|
||||
std::fs::write(root.join("docker/compose.yml"), b"c").unwrap();
|
||||
let locked = root.join("docker/cache/locked");
|
||||
std::fs::create_dir(&locked).unwrap();
|
||||
std::fs::write(locked.join("inner.txt"), b"b").unwrap();
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
|
||||
let rules = rules_for(&[]);
|
||||
let writing = HashSet::new();
|
||||
let mut out = HashMap::new();
|
||||
let mut progress = no_progress();
|
||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||
let result = scan(&root);
|
||||
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
|
||||
let error = result.expect_err("an unreadable directory must abort the pass");
|
||||
assert!(
|
||||
error.contains("locked"),
|
||||
"the error should name the subtree it could not read: {error}"
|
||||
let (out, unscanned) = result.expect("one unreadable folder must not fail the pass");
|
||||
assert_eq!(
|
||||
sorted_keys(&out),
|
||||
vec!["docker", "docker/cache", "docker/compose.yml", "keep.txt"]
|
||||
);
|
||||
assert!(unscanned.covers("docker/cache/locked"));
|
||||
assert!(unscanned.covers("docker/cache/locked/inner.txt"));
|
||||
assert!(!unscanned.covers("docker/cache"));
|
||||
let report = unscanned.report();
|
||||
assert_eq!(report.len(), 1);
|
||||
assert!(report[0].starts_with("docker/cache/locked ("), "{report:?}");
|
||||
}
|
||||
|
||||
/// A folder with read but no search permission lists its names but cannot
|
||||
/// `stat` them. Whatever it did list must go with it: a half-read folder
|
||||
/// is as unknown as an unread one.
|
||||
#[test]
|
||||
fn a_listable_but_unsearchable_folder_is_left_out_whole() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let root = sync_test_dir("scan-no-search");
|
||||
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||
let half = root.join("half");
|
||||
std::fs::create_dir(&half).unwrap();
|
||||
std::fs::write(half.join("one.txt"), b"1").unwrap();
|
||||
std::fs::write(half.join("two.txt"), b"2").unwrap();
|
||||
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o400)).unwrap();
|
||||
|
||||
let result = scan(&root);
|
||||
|
||||
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
|
||||
let (out, unscanned) = result.unwrap();
|
||||
// Each entry fails its own `stat` and is left out by name.
|
||||
assert_eq!(sorted_keys(&out), vec!["half", "keep.txt"]);
|
||||
assert!(unscanned.covers("half/one.txt"));
|
||||
assert!(unscanned.covers("half/two.txt"));
|
||||
}
|
||||
|
||||
/// The other half of B196: what was left out is not a deletion, and not a
|
||||
/// download target either. Its baseline rows leave the wipe guard's set
|
||||
/// and a push pass's deletions, and its paths leave classification.
|
||||
#[test]
|
||||
fn a_path_the_scan_left_out_is_never_a_local_deletion() {
|
||||
let mut baseline = baseline_with("keep.txt", 1, 1);
|
||||
baseline.extend(baseline_with("locked", 0, 0));
|
||||
baseline.extend(baseline_with("locked/inner.txt", 1, 1));
|
||||
baseline.extend(baseline_with("gone.txt", 1, 1));
|
||||
let local: HashMap<String, LocalItem> = [("keep.txt".to_string(), local_item(false))]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut unscanned = Unscanned::default();
|
||||
unscanned.unreadable("locked".to_string(), "Permission denied");
|
||||
let rules = rules_for(&[]);
|
||||
|
||||
let tracked = tracked_baseline(&baseline, &rules, &unscanned);
|
||||
assert_eq!(sorted_keys(&tracked), vec!["gone.txt", "keep.txt"]);
|
||||
assert_eq!(local_deletions(&tracked, &local), vec!["gone.txt"]);
|
||||
|
||||
// Every tracked path but one is gone, and the guard still lets the
|
||||
// pass run: one survivor proves the tree is there.
|
||||
assert!(guard_local_wipe(&tracked, &local).is_ok());
|
||||
// With everything readable left out, nothing is tracked, so nothing
|
||||
// can be lost and the guard has nothing to object to.
|
||||
let mut all = Unscanned::default();
|
||||
all.unreadable("keep.txt".to_string(), "Permission denied");
|
||||
all.unreadable("locked".to_string(), "Permission denied");
|
||||
all.vanished("gone.txt".to_string());
|
||||
assert!(tracked_baseline(&baseline, &rules, &all).is_empty());
|
||||
|
||||
let remote: HashMap<String, RemoteItem> = HashMap::new();
|
||||
let mut order = classification_order(&local, &remote, &baseline, &rules);
|
||||
order.retain(|rel| !unscanned.covers(rel));
|
||||
order.sort_unstable();
|
||||
assert_eq!(order, vec!["gone.txt", "keep.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings() {
|
||||
let mut unscanned = Unscanned::default();
|
||||
unscanned.unreadable("a/locked".to_string(), "Permission denied");
|
||||
unscanned.vanished("b.txt".to_string());
|
||||
|
||||
assert!(unscanned.covers("a/locked"));
|
||||
assert!(unscanned.covers("a/locked/x/y.txt"));
|
||||
assert!(unscanned.covers("b.txt"));
|
||||
assert!(!unscanned.covers("a"));
|
||||
assert!(!unscanned.covers("a/locked2"));
|
||||
assert!(!unscanned.covers("b.txt.bak"));
|
||||
// A file that only vanished mid-scan is not worth telling anyone about.
|
||||
assert_eq!(unscanned.report(), vec!["a/locked (Permission denied)"]);
|
||||
}
|
||||
|
||||
/// B197: a push pass read its deletions from the raw baseline, so a path
|
||||
/// synced before it became ignored, which the walk skips, was trashed on
|
||||
/// Drive, and the switch the push pass ran for then evicted the local copy.
|
||||
#[test]
|
||||
fn a_push_pass_never_reads_an_ignored_path_as_deleted() {
|
||||
let mut baseline = baseline_with("notes.md", 1, 1);
|
||||
baseline.extend(baseline_with("node_modules", 0, 0));
|
||||
baseline.extend(baseline_with("node_modules/x/index.js", 1, 1));
|
||||
baseline.extend(baseline_with("old.md", 1, 1));
|
||||
let local: HashMap<String, LocalItem> = [("notes.md".to_string(), local_item(false))]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let rules = rules_for(&["node_modules/"]);
|
||||
|
||||
let tracked = tracked_baseline(&baseline, &rules, &Unscanned::default());
|
||||
|
||||
assert_eq!(local_deletions(&tracked, &local), vec!["old.md"]);
|
||||
}
|
||||
|
||||
/// Deletions come out shallowest first, so a trashed folder can take its
|
||||
/// children with it.
|
||||
#[test]
|
||||
fn local_deletions_come_out_shallowest_first() {
|
||||
let mut baseline = baseline_with("d/e/f.txt", 1, 1);
|
||||
baseline.extend(baseline_with("d", 0, 0));
|
||||
baseline.extend(baseline_with("d/e", 0, 0));
|
||||
let local: HashMap<String, LocalItem> = HashMap::new();
|
||||
let tracked = tracked_baseline(&baseline, &rules_for(&[]), &Unscanned::default());
|
||||
|
||||
assert_eq!(
|
||||
local_deletions(&tracked, &local),
|
||||
vec!["d", "d/e", "d/e/f.txt"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2593,6 +2926,94 @@ mod tests {
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
||||
// ---- B198: going online-only never evicts what Drive does not have -----
|
||||
|
||||
#[test]
|
||||
fn a_fully_synced_mirror_folder_may_go_online_only() {
|
||||
let (root, baseline) = synced_subtree("evict-synced");
|
||||
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
||||
/// The case that lost data: `.git/` is ignored by default, so no pass ever
|
||||
/// uploads it, and the eviction deleted it with the rest.
|
||||
#[test]
|
||||
fn an_ignored_folder_only_this_disk_has_holds_the_switch_back() {
|
||||
let (root, baseline) = synced_subtree("evict-ignored");
|
||||
std::fs::create_dir(root.join(".git")).unwrap();
|
||||
std::fs::write(root.join(".git/HEAD"), b"ref").unwrap();
|
||||
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[".git/"]));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
let reason = reason.expect("an ignored folder is only on this disk");
|
||||
assert!(
|
||||
reason.contains(".git") && reason.contains("excluded"),
|
||||
"{reason}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An ignored row stays in the baseline for good once its file is gone, so
|
||||
/// counting it as a deletion not carried up would hold the switch forever.
|
||||
#[test]
|
||||
fn a_deleted_ignored_path_does_not_hold_the_switch_back() {
|
||||
let (root, mut baseline) = synced_subtree("evict-ignored-gone");
|
||||
baseline.insert(
|
||||
"build/out.o".to_string(),
|
||||
StoredSyncEntry {
|
||||
rel_path: "build/out.o".to_string(),
|
||||
..baseline["dir/a.txt"].clone()
|
||||
},
|
||||
);
|
||||
let rules = rules_for(&["build/"]);
|
||||
let ignored = mirror_tree_unsynced(&root, &baseline, &rules);
|
||||
let tracked = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(ignored, None);
|
||||
assert!(tracked.unwrap().contains("build/out.o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_after_the_last_pass_holds_the_switch_back() {
|
||||
let (root, baseline) = synced_subtree("evict-edit");
|
||||
std::fs::write(root.join("dir/sub/b.txt"), b"edited since").unwrap();
|
||||
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("dir/sub/b.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_file_at_the_root_holds_the_switch_back() {
|
||||
let (root, baseline) = synced_subtree("evict-new");
|
||||
std::fs::write(root.join("new.txt"), b"n").unwrap();
|
||||
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("new.txt"));
|
||||
}
|
||||
|
||||
/// The folder B196 now leaves out of the pass instead of failing it is,
|
||||
/// by the same token, something the eviction must not delete.
|
||||
#[test]
|
||||
fn an_unreadable_folder_holds_the_switch_back() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let (root, baseline) = synced_subtree("evict-eacces");
|
||||
let locked = root.join("dir/sub");
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("dir/sub"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symlink_holds_the_switch_back() {
|
||||
let (root, baseline) = synced_subtree("evict-symlink");
|
||||
std::os::unix::fs::symlink("dir/a.txt", root.join("link")).unwrap();
|
||||
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("link"));
|
||||
}
|
||||
|
||||
/// The same rule one level up: if the sync root itself cannot be read, the
|
||||
/// pass sees nothing, and "nothing" must never mean "delete everything".
|
||||
#[test]
|
||||
@@ -2607,7 +3028,14 @@ mod tests {
|
||||
let writing = HashSet::new();
|
||||
let mut out = HashMap::new();
|
||||
let mut progress = no_progress();
|
||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||
let result = walk_local_tree(
|
||||
&root,
|
||||
&rules,
|
||||
&writing,
|
||||
&mut out,
|
||||
&mut Unscanned::default(),
|
||||
&mut progress,
|
||||
);
|
||||
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
@@ -2628,30 +3056,40 @@ mod tests {
|
||||
let writing = HashSet::new();
|
||||
let mut out = HashMap::new();
|
||||
let mut progress = no_progress();
|
||||
assert!(walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress).is_err());
|
||||
assert!(
|
||||
walk_local_tree(
|
||||
&root,
|
||||
&rules,
|
||||
&writing,
|
||||
&mut out,
|
||||
&mut Unscanned::default(),
|
||||
&mut progress
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
/// A name that is not valid UTF-8 cannot be keyed into the scan, so it
|
||||
/// cannot be compared against the baseline either. Refusing the pass is the
|
||||
/// only safe reading: skipping it would classify the file as deleted.
|
||||
/// A name that is not valid UTF-8 cannot be keyed into the scan, but it
|
||||
/// cannot be in the baseline or on Drive either, so leaving it out reads as
|
||||
/// the deletion of nothing. It used to fail the pass (B196).
|
||||
#[test]
|
||||
fn a_non_utf8_name_fails_the_scan() {
|
||||
fn a_non_utf8_name_is_left_out_and_reported() {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
|
||||
let root = sync_test_dir("scan-non-utf8");
|
||||
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
|
||||
std::fs::write(root.join("good.txt"), b"y").unwrap();
|
||||
|
||||
let rules = rules_for(&[]);
|
||||
let writing = HashSet::new();
|
||||
let mut out = HashMap::new();
|
||||
let mut progress = no_progress();
|
||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||
let result = scan(&root);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
|
||||
let error = result.expect_err("a name the scan cannot key must abort the pass");
|
||||
assert!(error.contains("UTF-8"), "{error}");
|
||||
let (out, unscanned) = result.unwrap();
|
||||
assert_eq!(sorted_keys(&out), vec!["good.txt"]);
|
||||
let report = unscanned.report();
|
||||
assert_eq!(report.len(), 1);
|
||||
assert!(report[0].contains("UTF-8"), "{report:?}");
|
||||
}
|
||||
|
||||
/// The complement: a scan that *can* read everything reports everything, so
|
||||
@@ -2670,9 +3108,14 @@ mod tests {
|
||||
let writing = HashSet::new();
|
||||
let mut out = HashMap::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| {
|
||||
seen.push(rel.to_string())
|
||||
});
|
||||
let result = walk_local_tree(
|
||||
&root,
|
||||
&rules,
|
||||
&writing,
|
||||
&mut out,
|
||||
&mut Unscanned::default(),
|
||||
&mut |rel| seen.push(rel.to_string()),
|
||||
);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
|
||||
result.unwrap();
|
||||
|
||||
@@ -98,6 +98,11 @@ pub(super) struct Outcome {
|
||||
pub(super) errors: usize,
|
||||
/// Files skipped because another process held them open for writing.
|
||||
pub(super) deferred: usize,
|
||||
/// Paths the local scan could not read, left out of the pass. Not part of
|
||||
/// [`is_empty`](Self::is_empty) or the summary: an unreadable folder stays
|
||||
/// unreadable from one poll to the next, so the pass reports it once, when
|
||||
/// the set changes, rather than on every poll.
|
||||
pub(super) unscanned: Vec<String>,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
|
||||
@@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The paths a local scan left out because it could not read them: a folder
|
||||
/// it has no permission for, an entry that vanished between the listing and
|
||||
/// its `stat`, a name that is not UTF-8.
|
||||
///
|
||||
/// A left-out path is not a deleted one. A pass drops everything an entry
|
||||
/// covers from classification, exactly as it drops ignored paths, so a
|
||||
/// baseline row under an unreadable folder is neither read as a local deletion
|
||||
/// nor answered with a download into a folder the pass cannot write. Failing
|
||||
/// the whole pass instead is what B55 did, and it left one root-owned folder
|
||||
/// stopping every other file in the tree from syncing (B196).
|
||||
#[derive(Default)]
|
||||
pub(super) struct Unscanned {
|
||||
covered: HashSet<String>,
|
||||
/// The subset worth telling the user about, with why: an entry that only
|
||||
/// vanished mid-scan is not, since the next pass sees it settled.
|
||||
unreadable: Vec<String>,
|
||||
}
|
||||
|
||||
impl Unscanned {
|
||||
/// Leave out `rel`, which vanished while the scan looked at it.
|
||||
pub(super) fn vanished(&mut self, rel: String) {
|
||||
self.covered.insert(rel);
|
||||
}
|
||||
|
||||
/// Leave out `rel`, which cannot be read for `reason`.
|
||||
pub(super) fn unreadable(&mut self, rel: String, reason: &str) {
|
||||
self.unreadable.push(format!("{rel} ({reason})"));
|
||||
self.covered.insert(rel);
|
||||
}
|
||||
|
||||
/// Whether `rel` is a left-out path or lies under one.
|
||||
pub(super) fn covers(&self, rel: &str) -> bool {
|
||||
if self.covered.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut at = rel;
|
||||
loop {
|
||||
if self.covered.contains(at) {
|
||||
return true;
|
||||
}
|
||||
match at.rfind('/') {
|
||||
Some(i) => at = &at[..i],
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The unreadable paths with their reasons, sorted, for the activity feed.
|
||||
pub(super) fn report(&self) -> Vec<String> {
|
||||
let mut lines = self.unreadable.clone();
|
||||
lines.sort_unstable();
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// The baseline paths a pass can see: [`filter_baseline`], minus everything
|
||||
/// under a path the scan could not read. This is the set [`guard_local_wipe`]
|
||||
/// checks and the set a push pass reads deletions from, since a path outside
|
||||
/// it is absent from the local walk by rule or by failure, never by loss.
|
||||
pub(super) fn tracked_baseline<'a, B>(
|
||||
baseline: &'a HashMap<String, B>,
|
||||
rules: &IgnoreRules,
|
||||
unscanned: &Unscanned,
|
||||
) -> HashMap<String, &'a B> {
|
||||
let mut tracked = filter_baseline(baseline, rules);
|
||||
tracked.retain(|rel, _| !unscanned.covers(rel));
|
||||
tracked
|
||||
}
|
||||
|
||||
/// The baseline paths a push pass has to trash on Drive: tracked, and gone
|
||||
/// from the local walk. Shallowest first, so a trashed folder takes its
|
||||
/// children with it.
|
||||
///
|
||||
/// Built from [`tracked_baseline`] rather than from the raw baseline. Reading
|
||||
/// it raw trashed every synced path that later became ignored, because the walk
|
||||
/// skips those, and the switch to on-demand that the push pass runs for then
|
||||
/// deleted the local copy too (B197).
|
||||
pub(super) fn local_deletions<B, L>(
|
||||
tracked: &HashMap<String, &B>,
|
||||
local: &HashMap<String, L>,
|
||||
) -> Vec<String> {
|
||||
let mut missing: Vec<String> = tracked
|
||||
.keys()
|
||||
.filter(|rel| !local.contains_key(*rel))
|
||||
.cloned()
|
||||
.collect();
|
||||
missing.sort_by_key(|p| p.matches('/').count());
|
||||
missing
|
||||
}
|
||||
|
||||
/// Refuse to run a pass whose local side has vanished in its entirety.
|
||||
///
|
||||
/// When every baseline path is absent locally, the likely cause is an unavailable
|
||||
|
||||
@@ -202,12 +202,20 @@ impl Ui {
|
||||
}
|
||||
|
||||
fn main() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
let verify = std::env::args().nth(1).as_deref() == Some(VERIFY_ARG);
|
||||
let logs = tracing_subscriber::fmt().with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
);
|
||||
// The verification window's stdout carries its token back to the app.
|
||||
if verify {
|
||||
logs.with_writer(std::io::stderr).init();
|
||||
} else {
|
||||
logs.init();
|
||||
}
|
||||
i18n::init();
|
||||
if verify {
|
||||
return run_verification_window();
|
||||
}
|
||||
|
||||
// The tray launches the app with arguments ("--page locations",
|
||||
// "--confirm-stop"), which a running instance must receive too.
|
||||
@@ -303,6 +311,11 @@ fn build_window(app: &adw::Application) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// On a fresh install the daemon has not run yet to make these, and the
|
||||
// Takeout page keeps its staged archives in the state dir (B194).
|
||||
if let Err(e) = dirs.ensure() {
|
||||
tracing::warn!("cannot create the app's directories: {e}");
|
||||
}
|
||||
|
||||
let stack = adw::ViewStack::new();
|
||||
let (login_page, login_widgets) = build_login_page();
|
||||
@@ -1210,7 +1223,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
let dialog = adw::AboutDialog::builder()
|
||||
.application_name(gettext("Proton Drive for Linux"))
|
||||
.application_icon("io.narl.proton-drive-linux")
|
||||
.version(pdfs_core::config::APP_VERSION)
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.developer_name("Nils Pukropp")
|
||||
.website("https://proton-drive.narl.io")
|
||||
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
||||
@@ -1350,8 +1363,8 @@ fn debug_info(ui: &Rc<Ui>) -> String {
|
||||
format!(
|
||||
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
|
||||
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
|
||||
pdfs_core::config::APP_VERSION,
|
||||
pdfs_core::config::USER_AGENT,
|
||||
config.resolved_app_version(),
|
||||
config.resolved_user_agent(),
|
||||
adw::major_version(),
|
||||
adw::minor_version(),
|
||||
adw::micro_version(),
|
||||
|
||||
@@ -439,6 +439,10 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
|
||||
menu.item(&gettext("Restore to This Computer…"), move || {
|
||||
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
|
||||
});
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Move to This Computer…"), move || {
|
||||
prompt_migrate_device(&ui_c, &uid_c, &name_c)
|
||||
});
|
||||
menu.section();
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Rename…"), move || {
|
||||
@@ -1096,6 +1100,67 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Ask before moving another computer's folders into this computer's backup,
|
||||
/// then offer them for syncing here.
|
||||
///
|
||||
/// Destructive-looking on purpose: the other computer keeps its device but
|
||||
/// loses its folders, and a copy of Proton Drive still running there would
|
||||
/// watch them vanish.
|
||||
pub(crate) fn prompt_migrate_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Move Backup to This Computer?"))
|
||||
// Translators: {name} is the name of the other computer whose folders move to this one.
|
||||
.body(gettext_f(
|
||||
"The folders backed up by “{name}” move into this computer's backup in Proton Drive, and you choose where they go on this computer. “{name}” stays in your account, without those folders.\n\nStop Proton Drive on “{name}” first. Otherwise it sees its folders disappear.",
|
||||
&[("name", name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("move", &gettext("Move Here"));
|
||||
dialog.set_response_appearance("move", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui = ui.clone();
|
||||
let uid = uid.to_string();
|
||||
// Translators: {name} is the name of the computer whose folders moved to this one.
|
||||
let done = gettext_f("Moved the backup of “{name}”", &[("name", name)]);
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp != "move" {
|
||||
return;
|
||||
}
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::MigrateDevice { uid: uid.clone() },
|
||||
);
|
||||
let (ui, done) = (ui.clone(), done.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
let failed = gettext("Couldn't move that backup");
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
reload_sync_pages(&ui);
|
||||
toast(&ui, &done);
|
||||
// The folders are this computer's now, so its own restore
|
||||
// picker is where the user chooses their local paths.
|
||||
prompt_restore_folders(&ui, None);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&failed,
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Run a mutation raised from Computers or Locations and reload the page on success.
|
||||
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
||||
ui.busy_begin();
|
||||
|
||||
@@ -150,15 +150,27 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
});
|
||||
|
||||
// Same shape for human verification: only fires when the API gates the
|
||||
// sign-in, and the worker is blocked on the token until it does.
|
||||
// sign-in, and the worker is blocked on the token until it does. A
|
||||
// window that never came up is told apart from one the user closed, so
|
||||
// the result below can say why the sign-in stopped.
|
||||
let ui_hv = ui.clone();
|
||||
let verification_failed = Rc::new(Cell::new(false));
|
||||
let hv_failed = verification_failed.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
||||
ui_hv
|
||||
.login
|
||||
.login_status
|
||||
.set_text(&gettext("Complete the verification to continue…"));
|
||||
prompt_human_verification(&ui_hv, &url, token_tx);
|
||||
// Anything but a token drops the sender, which the worker reads
|
||||
// as a cancelled sign-in.
|
||||
match verify_human(url).await {
|
||||
Verification::Solved(token) => {
|
||||
let _ = token_tx.send(token);
|
||||
}
|
||||
Verification::Cancelled => {}
|
||||
Verification::Failed => hv_failed.set(true),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -194,6 +206,9 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
let _ = gio::spawn_blocking(service::enable_start).await;
|
||||
refresh(&ui);
|
||||
}
|
||||
Err(_) if verification_failed.get() => ui.login.login_status.set_text(&gettext(
|
||||
"The verification page couldn't be opened, so the sign-in was cancelled. Your system may keep the web view's sandbox from starting; the troubleshooting guide explains what to do.",
|
||||
)),
|
||||
Err(e) => ui.login.login_status.set_text(&e),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The human-verification (CAPTCHA) dialog.
|
||||
//! The human-verification (CAPTCHA) window.
|
||||
//!
|
||||
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
||||
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
||||
@@ -8,10 +8,26 @@
|
||||
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
||||
//! browser because it reports completion by posting a message to its host, not
|
||||
//! by redirecting: a browser has nowhere to post it back to.
|
||||
//!
|
||||
//! The webview runs in a child `pdfs-app` started with [`VERIFY_ARG`], not in the
|
||||
//! app itself. WebKit aborts its whole process when its sandbox cannot start, as
|
||||
//! on hardened systems that keep bubblewrap from the journal socket
|
||||
//! (`docs/BUGS.md` B195); in a child that costs the verification, not the app.
|
||||
//! The URL goes to the child on stdin, so it never shows in a process list, and
|
||||
//! the token comes back on stdout.
|
||||
|
||||
use crate::*;
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
use webkit6::prelude::*;
|
||||
|
||||
/// The argument that makes `pdfs-app` the verification window instead of the app.
|
||||
pub(crate) const VERIFY_ARG: &str = "--human-verification";
|
||||
|
||||
/// Marks the token among whatever else lands on the child's stdout: GLib and
|
||||
/// WebKit write debug output there.
|
||||
const TOKEN_PREFIX: &str = "hv-token ";
|
||||
|
||||
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
||||
///
|
||||
/// The page targets its embedding host (`window.parent`), which in a webview is
|
||||
@@ -26,15 +42,100 @@ window.addEventListener('message', function (event) {
|
||||
});
|
||||
"#;
|
||||
|
||||
/// Present the challenge and hand the solved token back through `token_tx`.
|
||||
///
|
||||
/// Closing the dialog without solving drops the sender, which the waiting login
|
||||
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt.
|
||||
pub(crate) fn prompt_human_verification(
|
||||
ui: &Rc<Ui>,
|
||||
url: &str,
|
||||
token_tx: std::sync::mpsc::Sender<String>,
|
||||
) {
|
||||
/// How the verification window ended.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Verification {
|
||||
Solved(String),
|
||||
/// Closed without solving.
|
||||
Cancelled,
|
||||
/// Never came up, or died: most likely WebKit could not start its sandbox.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Show the challenge in the verification window and wait until it closes.
|
||||
pub(crate) async fn verify_human(url: String) -> Verification {
|
||||
match gio::spawn_blocking(move || run_verification_child(&url)).await {
|
||||
Ok(Ok(verification)) => verification,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!("cannot run the verification window: {e}");
|
||||
Verification::Failed
|
||||
}
|
||||
Err(_) => Verification::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_verification_child(url: &str) -> std::io::Result<Verification> {
|
||||
let mut child = Command::new(own_exe())
|
||||
.arg(VERIFY_ARG)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
// A failed write means the child is already gone; its exit status says
|
||||
// how. Dropping `stdin` closes it.
|
||||
let _ = writeln!(stdin, "{url}");
|
||||
}
|
||||
// Stops reading at the token rather than at EOF: WebKit's helper processes
|
||||
// inherit the pipe and may hold it open a moment after the child quits.
|
||||
let token = child
|
||||
.stdout
|
||||
.take()
|
||||
.and_then(|out| find_token(BufReader::new(out)));
|
||||
let status = child.wait()?;
|
||||
Ok(outcome(token, status))
|
||||
}
|
||||
|
||||
/// This very binary, so a development build opens its own window. After an
|
||||
/// upgrade replaced the file, `current_exe` names a deleted path, and the
|
||||
/// installed `pdfs-app` is the next best.
|
||||
fn own_exe() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.filter(|path| path.is_file())
|
||||
.unwrap_or_else(|| PathBuf::from("pdfs-app"))
|
||||
}
|
||||
|
||||
fn find_token(out: impl BufRead) -> Option<String> {
|
||||
out.lines()
|
||||
.map_while(Result::ok)
|
||||
.find_map(|line| line.strip_prefix(TOKEN_PREFIX).map(str::to_owned))
|
||||
}
|
||||
|
||||
/// A token wins over the exit status: the user solved the page, even if
|
||||
/// WebKit's teardown crashed after it.
|
||||
fn outcome(token: Option<String>, status: ExitStatus) -> Verification {
|
||||
match token {
|
||||
Some(token) => Verification::Solved(token),
|
||||
None if status.success() => Verification::Cancelled,
|
||||
None => {
|
||||
tracing::error!(%status, "the verification window failed");
|
||||
Verification::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run as the verification window: read the page's URL from stdin, show it, and
|
||||
/// print the token once the user has solved it. Closing the window exits
|
||||
/// without a token, which the app reads as a cancelled sign-in.
|
||||
pub(crate) fn run_verification_window() -> glib::ExitCode {
|
||||
let mut url = String::new();
|
||||
if std::io::stdin().lock().read_line(&mut url).is_err() || url.trim().is_empty() {
|
||||
tracing::error!("{VERIFY_ARG} expects the verification URL on stdin");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
let url = url.trim().to_owned();
|
||||
|
||||
// Not unique: the app that started it already holds APP_ID.
|
||||
let app = adw::Application::builder()
|
||||
.flags(gio::ApplicationFlags::NON_UNIQUE)
|
||||
.build();
|
||||
app.connect_activate(move |app| build_verification_window(app, &url));
|
||||
// GApplication would refuse VERIFY_ARG as an unknown option.
|
||||
let argv0 = std::env::args().next().unwrap_or_default();
|
||||
app.run_with_args(&[argv0])
|
||||
}
|
||||
|
||||
fn build_verification_window(app: &adw::Application, url: &str) {
|
||||
let content = webkit6::UserContentManager::new();
|
||||
content.add_script(&webkit6::UserScript::new(
|
||||
BRIDGE,
|
||||
@@ -53,24 +154,19 @@ pub(crate) fn prompt_human_verification(
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
body.append(&adw::HeaderBar::new());
|
||||
body.append(&webview);
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title(gettext("Verification"))
|
||||
.content_width(420)
|
||||
.content_height(560)
|
||||
.icon_name(APP_ID)
|
||||
.default_width(420)
|
||||
.default_height(560)
|
||||
.content(&body)
|
||||
.build();
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
body.append(&header);
|
||||
body.append(&webview);
|
||||
dialog.set_child(Some(&body));
|
||||
|
||||
// Taken on the first success *or* on close, so the token is sent at most
|
||||
// once and a cancelled dialog always drops the sender.
|
||||
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
|
||||
|
||||
let tx = token_tx.clone();
|
||||
let dlg_weak = dialog.downgrade();
|
||||
let app = app.downgrade();
|
||||
content.connect_script_message_received(Some("hv"), move |_, value| {
|
||||
let Some(token) = extract_token(&value.to_str()) else {
|
||||
// Every `postMessage` on the page reaches here, most of them the
|
||||
@@ -78,25 +174,17 @@ pub(crate) fn prompt_human_verification(
|
||||
// is simply not ours.
|
||||
return;
|
||||
};
|
||||
if let Some(tx) = tx.borrow_mut().take() {
|
||||
let _ = tx.send(token);
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
if let Err(e) = writeln!(stdout, "{TOKEN_PREFIX}{token}").and_then(|()| stdout.flush()) {
|
||||
tracing::error!("cannot hand the verification token back: {e}");
|
||||
}
|
||||
if let Some(dlg) = dlg_weak.upgrade() {
|
||||
dlg.close();
|
||||
if let Some(app) = app.upgrade() {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// Covers the close button, Escape, and the user giving up.
|
||||
dialog.connect_closed(move |_| {
|
||||
token_tx.borrow_mut().take();
|
||||
});
|
||||
|
||||
webview.load_uri(url);
|
||||
|
||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
||||
// Returns immediately; the login worker stays blocked on the channel until
|
||||
// the handler above sends, or the dialog closes and drops the sender.
|
||||
dialog.present(parent.as_ref());
|
||||
window.present();
|
||||
}
|
||||
|
||||
/// Pull the verification token out of a message posted by the verification page.
|
||||
@@ -133,7 +221,12 @@ fn extract_token(raw: &str) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_token;
|
||||
use super::{Verification, extract_token, find_token, outcome};
|
||||
use std::os::unix::process::ExitStatusExt as _;
|
||||
use std::process::ExitStatus;
|
||||
|
||||
/// The wait status of a process killed by SIGABRT.
|
||||
const ABORTED: i32 = 6;
|
||||
|
||||
#[test]
|
||||
fn a_completion_message_yields_its_token() {
|
||||
@@ -163,4 +256,39 @@ mod tests {
|
||||
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_is_found_among_other_output() {
|
||||
let out = "Gtk-Message: something\nhv-token tok-1\nmore\n";
|
||||
assert_eq!(find_token(out.as_bytes()).as_deref(), Some("tok-1"));
|
||||
assert_eq!(find_token("only chatter\n".as_bytes()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_closed_without_a_token_cancels() {
|
||||
assert_eq!(
|
||||
outcome(None, ExitStatus::from_raw(0)),
|
||||
Verification::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
/// WebKit aborts when its sandbox cannot start (B195). That must read as a
|
||||
/// failure the app explains, not as the user giving up.
|
||||
#[test]
|
||||
fn a_window_that_died_fails() {
|
||||
assert_eq!(
|
||||
outcome(None, ExitStatus::from_raw(ABORTED)),
|
||||
Verification::Failed
|
||||
);
|
||||
let exited_1 = ExitStatus::from_raw(1 << 8);
|
||||
assert_eq!(outcome(None, exited_1), Verification::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_counts_even_if_the_window_died_after_it() {
|
||||
assert_eq!(
|
||||
outcome(Some("tok-1".into()), ExitStatus::from_raw(ABORTED)),
|
||||
Verification::Solved("tok-1".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -485,6 +485,10 @@ fn main() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The socket lives in the state dir, which nothing has made yet when the app
|
||||
// starts the tray on a fresh install (B194).
|
||||
dirs.ensure().context("create the app's directories")?;
|
||||
|
||||
// Ensure only one instance of the tray runs.
|
||||
let tray_sock = dirs.tray_socket();
|
||||
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
|
||||
|
||||
+16
-5
@@ -310,8 +310,10 @@ its changes go through the queue like My files.
|
||||
A filesystem watcher (`notify`) and a remote poll every 120 s feed a debounced pass (2 s, at most
|
||||
30 s). Passes are serialized per daemon. A pass:
|
||||
|
||||
1. **Walks the local tree.** Any `readdir`, metadata or permission failure marks the scan
|
||||
incomplete, and an incomplete scan cannot delete anything.
|
||||
1. **Walks the local tree.** A path the user may not read, an entry that vanishes mid-scan and a
|
||||
name that is not UTF-8 are left out, and the pass drops everything under them, as it drops
|
||||
ignored paths, so a left-out path is never read as deleted. An unreadable root and any other
|
||||
I/O error fail the pass.
|
||||
2. **Walks the remote tree.**
|
||||
3. **Loads the baseline**, the `sync_entry` rows: both sides as of the last successful pass.
|
||||
4. **Classifies each path** by comparing local, remote and baseline, with `(mtime, size)` as the
|
||||
@@ -377,6 +379,12 @@ Restore lists the device's folders, proposes a local path from the profile, and
|
||||
adds a synced folder bound to the remote uid. The download is an ordinary pass against an empty
|
||||
baseline. The user side is in [RECOVERY.md](RECOVERY.md).
|
||||
|
||||
`Core::migrate_device` moves another device's top-level folders under this machine's device root.
|
||||
Device roots are separate shares on the main volume, so each folder is one server-side move that
|
||||
keeps its uid. A name already taken here gets the other device's name as a suffix. A synced folder
|
||||
that already tracked one of them keeps its `remote_uid` and only has its `remote_share_id`
|
||||
re-pointed. The moved folders are then offered by the ordinary restore.
|
||||
|
||||
---
|
||||
|
||||
## 8. Control protocol
|
||||
@@ -542,7 +550,7 @@ sequenceDiagram
|
||||
actor User
|
||||
participant UI as pdfs-app
|
||||
participant Auth as pdfs-core auth
|
||||
participant Web as WebKitGTK dialog
|
||||
participant Web as pdfs-app --human-verification
|
||||
participant API as Proton API
|
||||
|
||||
User->>UI: email and password
|
||||
@@ -550,9 +558,9 @@ sequenceDiagram
|
||||
Auth->>API: SRP handshake
|
||||
API-->>Auth: 9001, challenge URL
|
||||
Auth-->>UI: HumanVerificationRequired
|
||||
UI->>Web: load challenge
|
||||
UI->>Web: challenge URL (stdin)
|
||||
User->>Web: solve it
|
||||
Web-->>UI: token (postMessage)
|
||||
Web-->>UI: token (stdout)
|
||||
UI->>Auth: login_verified(token)
|
||||
Auth->>API: new SRP handshake with the token
|
||||
API-->>Auth: session
|
||||
@@ -561,6 +569,9 @@ sequenceDiagram
|
||||
- The challenge used up the first SRP handshake, so the retry starts a new one with the token
|
||||
attached.
|
||||
- The page may post the token as an object or as a JSON string; both are accepted.
|
||||
- The page runs in a WebKitGTK view inside a child `pdfs-app`. WebKit aborts its process when its
|
||||
sandbox cannot start, as on hardened systems such as secureblue (B195). In the child, that ends
|
||||
the verification, and the app says why, instead of closing the app.
|
||||
- The CLI has no web view. It fails with a message to sign in once with `pdfs-app`; the stored
|
||||
session then works for the CLI and the service.
|
||||
|
||||
|
||||
+153
-1
@@ -47,7 +47,7 @@ original wording.
|
||||
it in the index in the same change.
|
||||
|
||||
```markdown
|
||||
### B194 — What the user sees, in one line
|
||||
### B196 — What the user sees, in one line
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-06, how it was found
|
||||
@@ -100,6 +100,10 @@ a live account since.
|
||||
|
||||
| Entry | Title |
|
||||
|---|---|
|
||||
| [B198](#b198--switching-to-online-only-deletes-local-files-that-never-reached-drive) | Switching to online-only deletes local files that never reached Drive |
|
||||
| [B197](#b197--switching-to-online-only-trashes-synced-files-that-became-ignored) | Switching to online-only trashes synced files that became ignored |
|
||||
| [B196](#b196--one-unreadable-folder-stops-the-whole-synced-folder-from-syncing) | One unreadable folder stops the whole synced folder from syncing |
|
||||
| [B195](#b195--the-app-crashes-when-proton-asks-for-a-captcha-on-secureblue) | The app crashes when Proton asks for a CAPTCHA on secureblue |
|
||||
| [B193](#b193--deleting-a-file-whose-create-failed-can-trash-a-file-of-the-users-on-drive) | Deleting a file whose create failed can trash a file of the user's on Drive |
|
||||
| [B191](#b191--a-file-deleted-after-its-create-failed-and-was-written-again-can-stay-on-drive) | A file deleted after its create failed and was written again can stay on Drive |
|
||||
| [B190](#b190--a-file-saved-just-before-the-daemon-stops-can-keep-its-old-content-on-drive) | A file saved just before the daemon stops can keep its old content on Drive |
|
||||
@@ -214,6 +218,7 @@ a live account since.
|
||||
|
||||
| Entry | Title |
|
||||
|---|---|
|
||||
| [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives |
|
||||
| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later |
|
||||
| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English |
|
||||
| [B180](#b180--a-stop-waits-10-s-for-a-drain-worker-that-slept-through-it) | A stop waits 10 s for a drain worker that slept through it |
|
||||
@@ -296,6 +301,149 @@ a live account since.
|
||||
|
||||
Newest first.
|
||||
|
||||
### B198 — Switching to online-only deletes local files that never reached Drive
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-10-08, reading the switch while fixing B196.
|
||||
**Where:** `crates/pdfs-fuse/src/devices.rs`, `Core::apply_sync_folder_mode`;
|
||||
`crates/pdfs-fuse/src/sync.rs`, `mirror_tree_unsynced`
|
||||
|
||||
**Repro:** Mirror a project folder with a `.git/` in it, then switch it to **Online only**.
|
||||
`.git/` is gone from the disk and was never on Drive.
|
||||
|
||||
**Cause.** The switch evicts the whole local tree once the folder is `idle`. `idle` only says the
|
||||
last pass applied everything it tried, and a pass never tries an ignored path (`.git/` is ignored
|
||||
by default), a symlink, or a folder it cannot read (B196). It also defers a file that is open for
|
||||
writing without leaving `idle`, and a file can change after the pass ends. The eviction deleted
|
||||
all of those.
|
||||
|
||||
**Fix.** Before the mode is written and anything is evicted, the switch walks the folder against
|
||||
the baseline, as a move out of a mirror folder already does, and refuses when something is only on
|
||||
this disk. The message names the path; an ignored one is called excluded from sync. A tracked file
|
||||
deleted here but not yet on Drive also refuses. An ignored one does not, since its baseline row
|
||||
stays for good and would hold the switch forever.
|
||||
|
||||
**Test:** unit tests `a_fully_synced_mirror_folder_may_go_online_only`,
|
||||
`an_ignored_folder_only_this_disk_has_holds_the_switch_back`,
|
||||
`a_deleted_ignored_path_does_not_hold_the_switch_back`,
|
||||
`an_edit_after_the_last_pass_holds_the_switch_back`, `a_new_file_at_the_root_holds_the_switch_back`,
|
||||
`an_unreadable_folder_holds_the_switch_back` and `a_symlink_holds_the_switch_back`. The switch
|
||||
itself has not been driven against a live account.
|
||||
|
||||
---
|
||||
|
||||
### B197 — Switching to online-only trashes synced files that became ignored
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-10-08, reading the push pass while fixing B196.
|
||||
**Where:** `crates/pdfs-fuse/src/sync.rs`, `Core::push_pass`; `crates/pdfs-fuse/src/sync/planner.rs`,
|
||||
`local_deletions`
|
||||
|
||||
**Repro:** Mirror a folder until `build/` is on Drive, add `build/` to `.pdfsignore`, then switch
|
||||
the folder to **Online only**. `build/` goes to the Drive trash, and the switch then deletes the
|
||||
local copy.
|
||||
|
||||
**Cause.** The push pass that runs before the switch read its deletions as "in the baseline, not in
|
||||
the local walk". The walk skips ignored paths, so every synced path that later became ignored read
|
||||
as deleted. A full pass does not do this: it drops ignored paths from classification, which is
|
||||
what the user guide promises ("Ignoring never deletes anything").
|
||||
|
||||
**Fix.** The push pass reads its deletions from the same filtered baseline the wipe guard checks:
|
||||
without ignored paths, and without paths the scan could not read (B196).
|
||||
|
||||
**Test:** unit tests `a_push_pass_never_reads_an_ignored_path_as_deleted` and
|
||||
`local_deletions_come_out_shallowest_first`.
|
||||
|
||||
---
|
||||
|
||||
### B196 — One unreadable folder stops the whole synced folder from syncing
|
||||
|
||||
**Status:** Fixed (unverified). The reporter has not run the fix yet.
|
||||
**Found:** 2026-10-08, [#33](https://github.com/narrrl/proton-drive-linux/issues/33). A synced
|
||||
`~/Documents` held a restic cache a Docker container had created, which the user may not read.
|
||||
Every pass failed with `Couldn't sync Documents: read …/restic/cache/…/snapshots: Permission
|
||||
denied`, and nothing else in `Documents` synced.
|
||||
**Where:** `crates/pdfs-fuse/src/sync.rs`, `walk_local_tree`; `crates/pdfs-fuse/src/sync/planner.rs`,
|
||||
`Unscanned`
|
||||
|
||||
**Repro:** In a mirror folder, `sudo mkdir locked && sudo chmod 700 locked`, then sync.
|
||||
|
||||
**Cause.** B55 made every scan failure fatal to the pass, because a path left out of the scan read
|
||||
as a local deletion and was trashed on Drive. That was safe, but a folder the user may not read is
|
||||
not transient: the pass failed on every poll, for good. The same held for a file deleted between
|
||||
the listing and its `stat`, which a busy folder hits now and then, and for a name that is not UTF-8.
|
||||
|
||||
**Fix.** The scan leaves such a path out and records it, and the pass drops everything under it
|
||||
from classification, the wipe guard and the push pass's deletions, as it already did for ignored
|
||||
paths. A left-out path is then neither deleted on Drive nor downloaded into. A folder that lists
|
||||
but does not allow `stat` is left out whole. An unreadable or absent root, and every other I/O
|
||||
error, still fails the pass. The activity feed names the unreadable paths once each time the set
|
||||
changes, and the folder stays `idle`. Adding the folder to `.pdfsignore` was already a workaround
|
||||
and still silences the report.
|
||||
|
||||
**Test:** unit tests `an_unreadable_subdirectory_is_left_out_and_the_rest_scans`,
|
||||
`a_listable_but_unsearchable_folder_is_left_out_whole`,
|
||||
`a_path_the_scan_left_out_is_never_a_local_deletion`,
|
||||
`unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings` and
|
||||
`a_non_utf8_name_is_left_out_and_reported`. `an_unreadable_root_fails_the_scan` and
|
||||
`an_absent_root_fails_the_scan` still hold.
|
||||
|
||||
---
|
||||
|
||||
### B195 — The app crashes when Proton asks for a CAPTCHA on secureblue
|
||||
|
||||
**Status:** Fixed (unverified). Nobody has run the fix on secureblue yet.
|
||||
**Found:** 2026-10-07, [#29](https://github.com/narrrl/proton-drive-linux/issues/29). On
|
||||
secureblue 44 (a hardened Fedora Atomic), `pdfs-app` aborted a few seconds after the sign-in, with
|
||||
`bwrap: Can't get type of source /run/systemd/journal/socket: Permission denied` and
|
||||
`Failed to fully launch dbus-proxy`.
|
||||
**Where:** `crates/pdfs-gui/src/app/pages/verify.rs`, `crates/pdfs-gui/src/app/pages/login.rs`
|
||||
|
||||
**Repro:** Sign in on secureblue from a network Proton asks a CAPTCHA for, such as a VPN.
|
||||
|
||||
**Cause.** The CAPTCHA page runs in a WebKitGTK view (B8). Before it loads anything, WebKit starts
|
||||
its sandbox: bubblewrap, with the journal socket bound in, and `xdg-dbus-proxy`. secureblue keeps
|
||||
bubblewrap from the socket, the proxy does not start, and WebKit ends its process with `g_error`.
|
||||
The view was in the app's own process, so the whole app went down, and nothing in our code can
|
||||
catch that.
|
||||
|
||||
**Fix.** The view runs in a child, `pdfs-app --human-verification`, that reads the URL on stdin
|
||||
and prints the token on stdout. A child that exits without a token after a crash or a non-zero
|
||||
status ends the sign-in with a message that says the page could not be opened. TROUBLESHOOTING.md
|
||||
gives the cause and a one-time workaround. The sandbox is not switched off by the app: users choose
|
||||
a hardened system for that isolation.
|
||||
|
||||
**Test:** unit tests `a_window_that_died_fails`, `a_window_closed_without_a_token_cancels`,
|
||||
`a_token_counts_even_if_the_window_died_after_it` and `the_token_is_found_among_other_output`.
|
||||
On 2026-10-08 the child ran under real WebKit on Arch against a local page that posts a completion,
|
||||
and handed back its token.
|
||||
|
||||
---
|
||||
|
||||
### B194 — On a fresh install the tray exits and the Takeout page cannot remember its archives
|
||||
|
||||
**Status:** Fixed.
|
||||
**Found:** 2026-10-07, in the log of [#29](https://github.com/narrrl/proton-drive-linux/issues/29):
|
||||
`failed to bind tray single-instance socket: No such file or directory` and
|
||||
`cannot remember staged Takeout archives: No such file or directory`.
|
||||
**Where:** `crates/pdfs-gui/src/main.rs`, `build_window` in `crates/pdfs-gui/src/app/main.rs`
|
||||
|
||||
**Repro:** Start `pdfs-app` with no `~/.local/state/proton-drive-linux`, as on a fresh install. The
|
||||
tray is gone, and staging a Takeout archive logs the warning.
|
||||
|
||||
**Cause.** Both write to the state directory, and only the daemon and the CLI made it
|
||||
(`AppDirs::ensure`). The app starts the tray before the first sign-in, so before the daemon has
|
||||
ever run.
|
||||
|
||||
**Fix.** `pdfs-tray` and `pdfs-app` call `AppDirs::ensure` at start, which also makes the
|
||||
directories owner-only (B6).
|
||||
|
||||
**Verified:** 2026-10-08, `pdfs-tray` with `XDG_STATE_HOME`, `XDG_CONFIG_HOME` and
|
||||
`XDG_CACHE_HOME` pointing at an empty directory. It exited with the error before the fix, and
|
||||
afterwards ran with `tray.sock` in a `0700` state directory.
|
||||
|
||||
---
|
||||
|
||||
### B193 — Deleting a file whose create failed can trash a file of the user's on Drive
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
@@ -4766,6 +4914,10 @@ subdirectory holding a file, asserting the error names the subtree),
|
||||
strictness must not cost a working pass, so a healthy tree still reports every entry and still
|
||||
skips symlinks by design.
|
||||
|
||||
B196 later relaxed this: a path the user may not read, an entry that vanishes mid-scan and a name
|
||||
that is not UTF-8 are left out of the pass instead of failing it, and the pass drops everything
|
||||
under them from classification, so the B55 guarantee holds without the wedge.
|
||||
|
||||
Not covered: transient I/O errors, and an entry that disappears between `read_dir` and `stat`.
|
||||
Both are races that would need a filesystem shim to force; the code path they take is the same
|
||||
`symlink_metadata` failure the permission test drives.
|
||||
|
||||
@@ -11,6 +11,57 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **One unreadable folder stopped a synced folder from syncing at all.** A folder you may not
|
||||
read, such as a cache a container created, failed every pass, so nothing else in the synced
|
||||
folder reached Drive. Such a folder is now left out and named once in the activity feed, and
|
||||
everything around it syncs. A file deleted during a scan, and a name that is not UTF-8, no
|
||||
longer fail the pass either.
|
||||
([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
|
||||
- **Switching a folder to online-only could delete files that were never on Drive.** Ignored
|
||||
folders such as `.git/`, symlinks, unreadable folders and files changed after the last sync were
|
||||
deleted with the local copy. The switch now refuses and names what is only on this computer.
|
||||
- **Switching to online-only trashed synced files that had become ignored.** Adding a synced path
|
||||
to `.pdfsignore` and then switching moved it to the Drive trash. Ignoring never deletes now, as
|
||||
the user guide says.
|
||||
|
||||
### Added
|
||||
- **Move another computer's backup to this one.** **Move to This Computer…** on the Computers page,
|
||||
or `pdfs devices migrate UID`, moves every folder another computer backs up, for example an old
|
||||
Windows PC, into this computer's backup on Proton Drive. Then it offers them for syncing here. The
|
||||
folders move on Proton Drive itself, so nothing is uploaded again. The other computer stays in the
|
||||
account, without those folders.
|
||||
|
||||
## [3.1.2] — 2026-10-08
|
||||
|
||||
Schema: **42**, unchanged.
|
||||
|
||||
### Fixed
|
||||
- **The app crashed when Proton asked for a CAPTCHA on hardened systems.** On secureblue, WebKit
|
||||
cannot start the sandbox for the verification page and ends its process, which took the app
|
||||
down with it. The page now opens in a separate window process: if that process fails, the
|
||||
sign-in stops with a message that says why, and TROUBLESHOOTING.md has a workaround.
|
||||
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||
- **On a fresh install the tray icon did not start.** The tray and the app now create their state
|
||||
directory themselves instead of waiting for the service to do it. This also lets the Takeout
|
||||
page remember staged archives before the first mount.
|
||||
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||
|
||||
## [3.1.1] — 2026-10-07
|
||||
|
||||
Schema: **42**, unchanged.
|
||||
|
||||
### Changed
|
||||
- **The client identifies itself with its release.** Sign-in and every API call used to send the
|
||||
fixed `external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0`. If Proton retired that
|
||||
version, every release would fail to sign in at once and no update could fix it. The client now
|
||||
sends `external-drive-linux@<version>-stable` and `proton-drive-linux/<version>`. `app_version`
|
||||
and `user_agent` in `config.json` are optional overrides now. The fixed values that releases up
|
||||
to 3.1.0 wrote there count as unset and are removed on the next save. A value you set by hand is
|
||||
kept.
|
||||
- The About dialog shows the release version, and its debug info shows the identity that is
|
||||
actually sent.
|
||||
|
||||
## [3.1.0] — 2026-10-07
|
||||
|
||||
Schema: **42**, unchanged.
|
||||
@@ -1635,6 +1686,12 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||
by a new POSIX compliance suite for the filesystem.
|
||||
|
||||
[3.1.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2
|
||||
|
||||
[3.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1
|
||||
|
||||
[3.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.0
|
||||
|
||||
[3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0
|
||||
|
||||
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
|
||||
|
||||
@@ -103,6 +103,7 @@ commands are for scripts and for cases where the mount is not available.
|
||||
| `pdfs devices rename UID NAME` | Rename a computer. |
|
||||
| `pdfs devices rm UID` | Remove a computer and its backup from the account. |
|
||||
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
|
||||
| `pdfs devices migrate UID [--yes]` | Move another computer's folders into this computer's backup, then restore them here like `pdfs sync restore`. The other computer stays registered, without those folders. `--yes` skips the confirmation and accepts every proposed path. See [RECOVERY.md](RECOVERY.md). |
|
||||
|
||||
## Trash
|
||||
|
||||
|
||||
@@ -89,7 +89,11 @@ fix it.
|
||||
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
|
||||
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
|
||||
|
||||
`app_version` and `user_agent` identify the client to Proton. Leave them alone.
|
||||
`app_version` and `user_agent` identify the client to Proton. When the keys are absent, the
|
||||
client sends its own release (`external-drive-linux@<version>-stable`, `proton-drive-linux/<version>`).
|
||||
Leave them out unless a support case asks for a different value. The fixed
|
||||
`external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0` that releases up to 3.1.0 wrote
|
||||
here are treated as absent and removed on the next save.
|
||||
|
||||
### `ignore_patterns`
|
||||
|
||||
|
||||
@@ -133,6 +133,29 @@ pdfs sync restore --device UID
|
||||
The folders stay part of the other computer's backup and sync in both directions. This machine
|
||||
does not take over that computer's backup.
|
||||
|
||||
### Moving another computer's backup here
|
||||
|
||||
To switch computers for good, for example from a Windows PC to this Linux machine, move the old
|
||||
computer's folders into this computer's backup instead. Use **Move to This Computer…** on that
|
||||
computer's row, or:
|
||||
|
||||
```bash
|
||||
pdfs devices list # find the old computer's UID
|
||||
pdfs devices migrate UID
|
||||
```
|
||||
|
||||
Each folder moves on Proton Drive itself, so nothing is uploaded again and no storage is used
|
||||
twice. A folder whose name this computer already uses gets the old computer's name added, for
|
||||
example `Documents (DESKTOP-1)`. The restore picker then opens, as in
|
||||
[Step 3](#step-3-restore-the-folders), so you choose where each folder goes here.
|
||||
|
||||
The old computer stays in your account, without those folders. Unlike
|
||||
[adoption](#step-2-continue-the-old-computers-backup), this machine keeps its own name and backup.
|
||||
|
||||
> [!WARNING]
|
||||
> Stop Proton Drive on the old computer first. A client still running there sees its folders
|
||||
> disappear, and may upload them again as new.
|
||||
|
||||
## 3. How the restore works
|
||||
|
||||
This section is for the curious and for anyone debugging a restore.
|
||||
|
||||
@@ -72,6 +72,20 @@ pdfs status
|
||||
unfamiliar network. The CLI cannot show it. Sign in with `pdfs-app`, which displays the challenge;
|
||||
the stored session then works for the CLI too.
|
||||
|
||||
**"The verification page couldn't be opened"** — WebKit, which shows the CAPTCHA page, could not
|
||||
start its sandbox. Hardened systems such as secureblue keep its bubblewrap from the journal socket,
|
||||
and the log then shows `bwrap: Can't get type of source /run/systemd/journal/socket: Permission
|
||||
denied` and `Failed to fully launch dbus-proxy`. Check that `bwrap` and `xdg-dbus-proxy` are
|
||||
installed. If they are, sign in once with the sandbox switched off for this one run:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 pdfs-app
|
||||
```
|
||||
|
||||
Quit the app first, so this starts a new instance. The page then runs without WebKit's isolation,
|
||||
so use this only for the sign-in. The stored session keeps working without it. Signing in from
|
||||
your usual network, without a VPN, often avoids the CAPTCHA in the first place.
|
||||
|
||||
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
|
||||
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
|
||||
enabled). `pdfs diagnose` shows `keyring session` as failing in that case.
|
||||
|
||||
+9
-1
@@ -96,7 +96,9 @@ app and in the web app.
|
||||
Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount
|
||||
of the folder, so the files stay visible but take no disk space until opened. Switch with the
|
||||
**Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back
|
||||
with `pdfs sync mode <id> mirror`. `pdfs locations` lists every local place the service occupies.
|
||||
with `pdfs sync mode <id> mirror`. The switch waits until everything in the folder is on Proton
|
||||
Drive, and refuses while the folder holds something that never goes there: an ignored path such
|
||||
as `.git/`, a symlink, or a folder you may not read. The message names it. `pdfs locations` lists every local place the service occupies.
|
||||
|
||||
### Moving between synced folders and My files
|
||||
|
||||
@@ -146,6 +148,9 @@ places, and both apply:
|
||||
Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is
|
||||
simply no longer tracked, and its copy on Proton Drive stays.
|
||||
|
||||
A folder you may not read, such as a cache a container created as root, is skipped the same way,
|
||||
and the activity feed names it once. Add it to `.pdfsignore` to stop that message.
|
||||
|
||||
## Changes waiting for Proton Drive
|
||||
|
||||
A change you made in the Proton Drive folder exists only on this computer until it reaches
|
||||
@@ -278,6 +283,9 @@ Proton Drive keeps earlier versions of every file. **Details → Versions…** i
|
||||
**Restore to This Computer…** downloads its folders here.
|
||||
- **Continue This Backup Here…** makes this computer take over another computer's backup, for
|
||||
example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md).
|
||||
- **Move to This Computer…** moves another computer's folders into this computer's backup, for
|
||||
example when you switch from Windows to Linux, and then offers them for syncing here. The other
|
||||
computer stays in the list, without those folders.
|
||||
|
||||
## Search launcher
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=3.1.0
|
||||
pkgver=3.1.2
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux-bin
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||
pkgver = 3.0.0
|
||||
pkgver = 3.1.2
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
arch = x86_64
|
||||
@@ -21,21 +21,21 @@ pkgbase = proton-drive-for-linux-bin
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-for-linux=3.0.0
|
||||
provides = proton-drive-linux=3.0.0
|
||||
provides = proton-drive-for-linux=3.1.2
|
||||
provides = proton-drive-linux=3.1.2
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
conflicts = proton-drive-for-linux-git
|
||||
options = !strip
|
||||
options = !debug
|
||||
source = proton-drive-linux-3.0.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.0.0/proton-drive-linux-3.0.0-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux-3.0.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray-3.0.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux-3.0.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive-3.0.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/proton-drive.service
|
||||
source = LICENSE-3.0.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/LICENSE
|
||||
sha256sums = 4d72420f287e0a9b1d65dfc2954779b2df1bf367cb41ffe28519b2791451b6ed
|
||||
source = proton-drive-linux-3.1.2-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.1.2/proton-drive-linux-3.1.2-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux-3.1.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray-3.1.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux-3.1.2.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive-3.1.2.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/proton-drive.service
|
||||
source = LICENSE-3.1.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/LICENSE
|
||||
sha256sums = 71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7
|
||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-bin
|
||||
pkgver=3.0.0
|
||||
pkgver=3.1.2
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||
arch=('x86_64')
|
||||
@@ -34,7 +34,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
|
||||
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||
sha256sums=('4d72420f287e0a9b1d65dfc2954779b2df1bf367cb41ffe28519b2791451b6ed'
|
||||
sha256sums=('71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7'
|
||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux-git
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||
pkgver = 3.0.0.r0.g2177383
|
||||
pkgver = 3.1.2.r0.g07c9b67
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
arch = x86_64
|
||||
@@ -24,8 +24,8 @@ pkgbase = proton-drive-for-linux-git
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-for-linux=3.0.0.r0.g2177383
|
||||
provides = proton-drive-linux=3.0.0.r0.g2177383
|
||||
provides = proton-drive-for-linux=3.1.2.r0.g07c9b67
|
||||
provides = proton-drive-linux=3.1.2.r0.g07c9b67
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-git
|
||||
pkgver=3.0.0.r0.g2177383
|
||||
pkgver=3.1.2.r0.g07c9b67
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux
|
||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||
pkgver = 3.0.0
|
||||
pkgver = 3.1.2
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
arch = x86_64
|
||||
@@ -23,13 +23,13 @@ pkgbase = proton-drive-for-linux
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-linux=3.0.0
|
||||
provides = proton-drive-linux=3.1.2
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux-bin
|
||||
conflicts = proton-drive-for-linux-git
|
||||
options = !lto
|
||||
source = proton-drive-for-linux-3.0.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.0.0.tar.gz
|
||||
sha256sums = 3a9ca82adae6b03e2b33af520ec49f702a3b4c83151ccc70ae3c21645b719ab5
|
||||
source = proton-drive-for-linux-3.1.2.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.1.2.tar.gz
|
||||
sha256sums = 260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1
|
||||
|
||||
pkgname = proton-drive-for-linux
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux
|
||||
pkgver=3.0.0
|
||||
pkgver=3.1.2
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||
arch=('x86_64')
|
||||
@@ -24,7 +24,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
|
||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||
options=('!lto')
|
||||
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('3a9ca82adae6b03e2b33af520ec49f702a3b4c83151ccc70ae3c21645b719ab5')
|
||||
sha256sums=('260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1')
|
||||
|
||||
_srcname="proton-drive-linux-$pkgver"
|
||||
|
||||
|
||||
+220
-171
File diff suppressed because it is too large.
Load diff
+208
-170
File diff suppressed because it is too large.
Load diff
+239
-184
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user