diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index af5eb62..9a80179 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -655,6 +655,10 @@ enum SyncCmd { /// Accept every proposed local path without asking. #[arg(long)] yes: bool, + /// Restore another computer's folders instead, by device uid (from + /// `pdfs devices list`). + #[arg(long)] + device: Option, }, } @@ -991,7 +995,7 @@ fn cmd_sync(action: SyncCmd) -> Result<()> { SyncCmd::Mode { id, mode } => { ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)? } - SyncCmd::Restore { yes } => return cmd_sync_restore(yes), + SyncCmd::Restore { yes, device } => return cmd_sync_restore(yes, device), } Ok(()) } @@ -1075,8 +1079,13 @@ fn cmd_sync_queue() -> Result<()> { /// The daemon only ever *proposes* local paths — a path recorded by another /// machine may name a home directory that does not exist here — so the default /// flow is propose-and-confirm. `--yes` accepts the proposals for scripting. -fn cmd_sync_restore(yes: bool) -> Result<()> { - let response = control_request(CtlRequest::ListRestorableFolders)?; +fn cmd_sync_restore(yes: bool, device: Option) -> Result<()> { + let response = control_request(match &device { + Some(device) => CtlRequest::ListDeviceRestorableFolders { + device: device.clone(), + }, + None => CtlRequest::ListRestorableFolders, + })?; if emit_json(&response)? { return Ok(()); } @@ -1126,8 +1135,12 @@ fn cmd_sync_restore(yes: bool) -> Result<()> { println!("Nothing selected."); return Ok(()); } - ok_or_bail(control_request(CtlRequest::RestoreSyncFolders { - items: chosen, + ok_or_bail(control_request(match device { + Some(device) => CtlRequest::RestoreDeviceFolders { + device, + items: chosen, + }, + None => CtlRequest::RestoreSyncFolders { items: chosen }, })?) } diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 1460bff..74c3533 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -443,6 +443,18 @@ pub enum Request { /// Attach the given remote device folders to local paths and sync them down. /// Replies with [`Response::Ok`]. RestoreSyncFolders { items: Vec }, + /// [`Request::ListRestorableFolders`] for another registered device, by + /// uid, so its folders can be synced down to this machine. Replies with + /// [`Response::RestorableFolders`]. + ListDeviceRestorableFolders { device: String }, + /// [`Request::RestoreSyncFolders`] for folders under another device. A + /// separate variant rather than an optional field: an older daemon that + /// ignored the field would attach the folders under this machine's device + /// share instead. Replies with [`Response::Ok`]. + RestoreDeviceFolders { + device: String, + items: Vec, + }, // ---- sharing a node --------------------------------------------------- /// Invite `emails` (Proton and/or external addresses, auto-detected) to the @@ -2491,6 +2503,17 @@ mod tests { mode: "mirror".into(), }], }, + Request::ListDeviceRestorableFolders { + device: "dev-2".into(), + }, + Request::RestoreDeviceFolders { + device: "dev-2".into(), + items: vec![RestoreItem { + remote_uid: "vol~link".into(), + local_path: "/home/me/Work".into(), + mode: "ondemand".into(), + }], + }, Request::ShareNode { path: "a/b".into(), emails: vec!["x@proton.me".into(), "y@example.com".into()], diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index f3212c7..c024281 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -1144,22 +1144,38 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::error(e), }, - Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders() { + Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders(None) { Ok(items) => CtlResponse::RestorableFolders { items }, Err(e) => CtlResponse::error(e), }, - Ok(CtlRequest::RestoreSyncFolders { items }) => { + Ok(CtlRequest::ListDeviceRestorableFolders { device }) => { + match core.list_restorable_folders(Some(&device)) { + Ok(items) => CtlResponse::RestorableFolders { items }, + Err(e) => CtlResponse::error(e), + } + } + Ok( + request @ (CtlRequest::RestoreSyncFolders { .. } + | CtlRequest::RestoreDeviceFolders { .. }), + ) => { + let (device, items) = match request { + CtlRequest::RestoreDeviceFolders { device, items } => (Some(device), items), + CtlRequest::RestoreSyncFolders { items } => (None, items), + _ => unreachable!("matched above"), + }; // Like AddSyncFolder: each restored folder downloads a whole tree, // which outlasts the socket timeout. Ack, then work. let core = core.clone(); - std::thread::spawn(move || match core.restore_sync_folders(&items) { - Ok(message) => { - core.touch_profile(); - core.log_activity(ActivityKind::Download, &message, "", true); - } - Err(e) => { - warn!(error = %e, "restore failed"); - core.log_activity(ActivityKind::Download, "restore folders", &e, false); + std::thread::spawn(move || { + match core.restore_sync_folders(&items, device.as_deref()) { + Ok(message) => { + core.touch_profile(); + core.log_activity(ActivityKind::Download, &message, "", true); + } + Err(e) => { + warn!(error = %e, "restore failed"); + core.log_activity(ActivityKind::Download, "restore folders", &e, false); + } } }); CtlResponse::Ok { diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index 268497d..bc94cd4 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -227,6 +227,39 @@ impl Core { Ok(format!("adopted device {name}")) } + /// The device a restore reads from: this machine's own for `None`, else + /// the registered device with that uid. + /// + /// Another device is looked up without touching the cached device row — + /// restoring its folders here must not make this machine back up as it. + /// Its folders are then attached with that device's share id, so they sync + /// against the other computer's tree. + pub(crate) fn restore_source(&self, device: Option<&str>) -> CoreResult { + let Some(uid) = device else { + return self.ensure_device(); + }; + let remote = self + .rt + .block_on(self.client.enumerate_devices()) + .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; + let d = remote + .into_iter() + .find(|d| d.uid.to_string() == uid) + .ok_or_else(|| CoreError::not_found(format!("no device {uid} in this account")))?; + Ok(StoredDevice { + uid: d.uid.to_string(), + share_id: d.share_id.to_string(), + root_uid: d.root_folder_uid.to_string(), + name: d + .name + .as_deref() + .ok() + .unwrap_or("(unnamed device)") + .to_string(), + created: d.creation_time, + }) + } + // ---- device folder sync (devices.md, Phase 1) ------------------------- /// Auto-register (or recover) this machine as a Proton Drive Device, caching diff --git a/crates/pdfs-fuse/src/profile.rs b/crates/pdfs-fuse/src/profile.rs index 0e32eb0..60f5068 100644 --- a/crates/pdfs-fuse/src/profile.rs +++ b/crates/pdfs-fuse/src/profile.rs @@ -248,15 +248,19 @@ impl Core { // ---- restore (features.md 5.2) ---------------------------------------- - /// The folders under this machine's device that could be synced here, each - /// carrying a proposed local path. + /// The folders under a device that could be synced here, each carrying a + /// proposed local path. `device` names another registered device by uid; + /// `None` is this machine's own. /// /// The proposal comes from the profile when one exists and its path is /// usable on this machine, and from `~/` otherwise — a restored /// `/home/other-user/Documents` would be wrong on a machine where that user /// does not exist, and silently creating it would be worse. - pub(crate) fn list_restorable_folders(&self) -> CoreResult> { - let device = self.ensure_device()?; + pub(crate) fn list_restorable_folders( + &self, + device: Option<&str>, + ) -> CoreResult> { + let device = self.restore_source(device)?; let root_uid = parse_uid(&device.root_uid).ok_or_else(|| { CoreError::internal(format!("bad device root uid: {}", device.root_uid)) })?; @@ -334,11 +338,12 @@ impl Core { pub(crate) fn restore_sync_folders( &self, items: &[pdfs_core::control::RestoreItem], + device: Option<&str>, ) -> CoreResult { if items.is_empty() { return Err(CoreError::invalid("nothing to restore")); } - let device = self.ensure_device()?; + let device = self.restore_source(device)?; let existing = self .db .sync_folder_list() @@ -499,6 +504,19 @@ mod tests { assert_before(add, "PROFILE_DIR_NAME", "ensure_device()"); } + /// Restoring another computer's folders reads that device, but must not + /// rewrite the cached device row: this machine would then back up as the + /// other computer. + #[test] + fn restoring_from_another_device_keeps_this_machines_identity() { + let source = function_source(include_str!("devices.rs"), "fn restore_source("); + assert!(source.contains("ensure_device()"), "None is this machine"); + assert!( + !source.contains("device_set"), + "the cached row is untouched" + ); + } + fn function_source<'a>(source: &'a str, signature: &str) -> &'a str { let start = source.find(signature).expect("function signature exists"); let body = &source[start..]; diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index dd6da66..29a8f94 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -719,7 +719,7 @@ fn build_window(app: &adw::Application) { wire_trash(&ui, &trash_widgets); wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark); wire_shared_by_me(&ui, &shared_by_me_widgets.retry); - wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore); + wire_devices(&ui, &devices_widgets.retry); wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder); wire_activity(&ui, &activity_widgets.retry, &activity_widgets.filters); wire_takeout(&ui, &takeout_widgets); diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index 1d88b47..ec481b7 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -36,9 +36,6 @@ pub(crate) struct DevicesWidgets { /// machine's own device is deliberately not among them; see /// [`repaint_devices`]. pub(crate) group: adw::PreferencesGroup, - /// "Restore Folders" — re-attach this device's remote folders to local - /// directories after adopting it on a new machine. - pub(crate) restore: gtk4::Button, pub(crate) retry: gtk4::Button, pub(crate) refresh: gtk4::Button, } @@ -48,10 +45,6 @@ pub(crate) struct DevicesWidgets { /// listing the account's other registered devices. The synced folders /// themselves live on the Sync page. pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { - let restore = gtk4::Button::builder() - .label("Restore Folders…") - .tooltip_text("Sync this computer's Drive folders back to local directories") - .build(); let refresh = refresh_button(); // This page is about device *identity* — which computer this is, which other @@ -114,7 +107,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { inner.set_margin_end(18); inner.append(&content); let (frame, header, _) = page_frame("Computers", &inner); - header.pack_start(&restore); header.pack_end(&refresh); ( @@ -125,22 +117,19 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { sync_group, rename_this, group, - restore, retry, refresh, }, ) } -/// Install the Devices page's retry button and the "Restore Folders" action. -pub(crate) fn wire_devices(ui: &Rc, retry: >k4::Button, restore: >k4::Button) { +/// Install the Devices page's retry button and the Rename action. +pub(crate) fn wire_devices(ui: &Rc, retry: >k4::Button) { let ui_retry = ui.clone(); retry.connect_clicked(move |_| { service::restart(); load_devices(&ui_retry); }); - let ui_restore = ui.clone(); - restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore)); let ui_ren = ui.clone(); ui.devices.rename_this.connect_clicked(move |_| { if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() { @@ -296,7 +285,14 @@ pub(crate) fn repaint_devices(ui: &Rc, devices: &[DeviceInfo]) { let (ui_ren, uid_ren, name_ren) = (ui.clone(), dev.uid.clone(), dev.name.clone()); let (ui_ad, uid_ad, name_ad) = (ui.clone(), dev.uid.clone(), dev.name.clone()); let (ui_rm, uid_rm, name_rm) = (ui.clone(), dev.uid.clone(), dev.name.clone()); + let (ui_rs, uid_rs, name_rs) = (ui.clone(), dev.uid.clone(), dev.name.clone()); row.add_suffix(&more_menu_button(vec![ + ( + "Restore to This Computer…", + Box::new(move || { + prompt_restore_folders(&ui_rs, Some((uid_rs.clone(), name_rs.clone()))) + }), + ), ( "Rename…", Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)), @@ -352,6 +348,11 @@ pub(crate) fn repaint_this_computer(ui: &Rc, folders: &[SyncFolderInfo]) { let ui_go = ui.clone(); manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations")); row.add_suffix(&manage); + let ui_rs = ui.clone(); + row.add_suffix(&more_menu_button(vec![( + "Restore Folders…", + Box::new(move || prompt_restore_folders(&ui_rs, None)), + )])); ui.devices.sync_group.add(&row); *ui.devices.sync_rows.borrow_mut() = vec![row.upcast()]; } @@ -455,21 +456,30 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc) { }); } -/// Ask the daemon what this machine's device holds, then show a picker mapping -/// each remote folder onto a local directory. +/// Ask the daemon what a device holds, then show a picker mapping each remote +/// folder onto a local directory. `device` is another computer as `(uid, name)`; +/// `None` is this machine's own device. /// /// The daemon's paths are proposals — from the device's `profile.json` when it /// makes sense here, else `~/` — so every one of them is editable and /// nothing is restored without being ticked. -pub(crate) fn prompt_restore_folders(ui: &Rc) { +pub(crate) fn prompt_restore_folders(ui: &Rc, device: Option<(String, String)>) { ui.busy_begin(); - let rx = spawn_request(ui.dirs.control_socket(), Request::ListRestorableFolders); + let request = match &device { + Some((uid, _)) => Request::ListDeviceRestorableFolders { + device: uid.clone(), + }, + None => Request::ListRestorableFolders, + }; + let rx = spawn_request(ui.dirs.control_socket(), request); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); match result { - Ok(Ok(Response::RestorableFolders { items })) => show_restore_picker(&ui, items), + Ok(Ok(Response::RestorableFolders { items })) => { + show_restore_picker(&ui, items, device) + } Ok(Ok(Response::Error { message, kind })) => { toast_failure(&ui, "Couldn't list folders to restore", &message, kind) } @@ -483,14 +493,18 @@ pub(crate) fn prompt_restore_folders(ui: &Rc) { } /// The restore picker itself: one editable row per restorable folder. -fn show_restore_picker(ui: &Rc, items: Vec) { +fn show_restore_picker( + ui: &Rc, + items: Vec, + device: Option<(String, String)>, +) { let win = ui_window(ui); let candidates: Vec = items.into_iter().filter(|f| !f.already_synced).collect(); if candidates.is_empty() { toast( ui, - "Nothing to restore — this computer's folders are all synced here.", + ¬hing_to_restore(device.as_ref().map(|(_, n)| n.as_str())), ); return; } @@ -521,7 +535,9 @@ fn show_restore_picker(ui: &Rc, items: Vec) { .build(); let dialog = adw::AlertDialog::builder() .heading("Restore folders") - .body("These folders are backed up under this computer in Proton Drive.") + .body(restore_picker_body( + device.as_ref().map(|(_, n)| n.as_str()), + )) .extra_child(&scroll) .build(); dialog.add_response("cancel", "Cancel"); @@ -551,7 +567,13 @@ fn show_restore_picker(ui: &Rc, items: Vec) { // rows appear as the periodic refresh picks them up. run_devices_mutation( &ui, - Request::RestoreSyncFolders { items }, + match &device { + Some((uid, _)) => Request::RestoreDeviceFolders { + device: uid.clone(), + items, + }, + None => Request::RestoreSyncFolders { items }, + }, "Restoring folders…", "Couldn't restore folders", ); @@ -559,6 +581,25 @@ fn show_restore_picker(ui: &Rc, items: Vec) { dialog.present(win.as_ref()); } +/// The restore picker's body: whose backup the folders come from. +pub(crate) fn restore_picker_body(device: Option<&str>) -> String { + match device { + Some(name) => format!( + "These folders are backed up from “{name}”. Restored folders keep syncing with \ + that computer's copy." + ), + None => "These folders are backed up under this computer in Proton Drive.".to_string(), + } +} + +/// The toast when every folder of a device is already synced here. +pub(crate) fn nothing_to_restore(device: Option<&str>) -> String { + match device { + Some(name) => format!("Nothing to restore — “{name}”'s folders are all synced here."), + None => "Nothing to restore — this computer's folders are all synced here.".to_string(), + } +} + /// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the /// row's subtitle and switch reflect the daemon's real state (the request may be /// rejected, e.g. switching to on-demand while a folder is mid-sync). @@ -865,4 +906,11 @@ mod tests { "2 folders backed up · 2 need attention" ); } + + #[test] + fn the_restore_picker_names_the_computer_it_restores_from() { + assert!(restore_picker_body(Some("laptop")).contains("“laptop”")); + assert!(restore_picker_body(None).contains("this computer")); + assert!(nothing_to_restore(Some("laptop")).contains("“laptop”")); + } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 92a527d..c2d9f33 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -46,6 +46,11 @@ Schema: **31** (unchanged). - **Stop sharing.** Shared by Me → item menu → "Stop Sharing…" and `pdfs unshare ` (with no id) remove the public link, every invitation and every member in one go. Each step is tried even if an earlier one fails, and a partial result names what is still shared. +- **Restore another computer's folders.** Every computer's menu on the Computers page has + "Restore to This Computer…", which syncs that computer's backed-up folders down to paths you + pick; they keep syncing with that computer's copy, and this machine keeps its own identity. + This computer's own restore moved from the header into its row's menu. `pdfs sync restore + --device ` does the same from the CLI. - **Public link expiry.** The Share dialog's create-link form has an "Expires" choice (Never, 1 day, 7 days, 30 days), and an existing link shows when it expires or that it has expired.