mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e3dd7db41 | ||
|
|
8d0351124c | ||
|
|
a888e0bb28 | ||
|
|
c7fc85e943 | ||
|
|
5bfae0fee1 |
No files matched your search
Generated
+4
-4
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "3.1.1"
|
||||
version = "3.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3490,7 +3490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "3.1.1"
|
||||
version = "3.1.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3516,7 +3516,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "3.1.1"
|
||||
version = "3.1.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "3.1.1"
|
||||
version = "3.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "3.1.1"
|
||||
version = "3.1.2"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1107,10 +1229,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;
|
||||
|
||||
@@ -202,12 +202,20 @@ impl Ui {
|
||||
}
|
||||
|
||||
fn main() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
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()),
|
||||
)
|
||||
.init();
|
||||
);
|
||||
// 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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+12
-3
@@ -377,6 +377,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 +548,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 +556,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 +567,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.
|
||||
|
||||
|
||||
+57
-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,7 @@ a live account since.
|
||||
|
||||
| Entry | Title |
|
||||
|---|---|
|
||||
| [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 +215,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 +298,60 @@ a live account since.
|
||||
|
||||
Newest first.
|
||||
|
||||
### 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).
|
||||
|
||||
@@ -11,6 +11,43 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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 +1672,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -278,6 +278,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.1
|
||||
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')
|
||||
|
||||
+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