feat(core,fuse,gui,cli): restore to this computer per device

ListDeviceRestorableFolders and RestoreDeviceFolders read another
registered device's folders and attach them with that device's share,
without changing this machine's cached device. The Computers page moves
restore into each computer's menu, and pdfs sync restore takes --device.
This commit is contained in:
nvrl committed 2026-09-23 04:15:17 +02:00
1 parent 0b63a65661
commit 52651caf8e
8 files changed
+199 -43

No files matched your search

+18 -5
View File
@@ -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<String>,
},
}
@@ -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<String>) -> 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 },
})?)
}
+23
View File
@@ -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<RestoreItem> },
/// [`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<RestoreItem>,
},
// ---- 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()],
+26 -10
View File
@@ -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 {
+33
View File
@@ -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<StoredDevice> {
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
+23 -5
View File
@@ -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 `~/<name>` 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<Vec<RestorableFolder>> {
let device = self.ensure_device()?;
pub(crate) fn list_restorable_folders(
&self,
device: Option<&str>,
) -> CoreResult<Vec<RestorableFolder>> {
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<String> {
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..];
+1 -1
View File
@@ -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);
+70 -22
View File
@@ -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<Ui>, retry: &gtk4::Button, restore: &gtk4::Button) {
/// Install the Devices page's retry button and the Rename action.
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: &gtk4::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<Ui>, 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<Ui>, 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<Ui>) {
});
}
/// 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 `~/<name>` — so every one of them is editable and
/// nothing is restored without being ticked.
pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>) {
pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>, 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<Ui>) {
}
/// The restore picker itself: one editable row per restorable folder.
fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
fn show_restore_picker(
ui: &Rc<Ui>,
items: Vec<RestorableFolder>,
device: Option<(String, String)>,
) {
let win = ui_window(ui);
let candidates: Vec<RestorableFolder> =
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.",
&nothing_to_restore(device.as_ref().map(|(_, n)| n.as_str())),
);
return;
}
@@ -521,7 +535,9 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
.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<Ui>, items: Vec<RestorableFolder>) {
// 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<Ui>, items: Vec<RestorableFolder>) {
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”"));
}
}
+5
View File
@@ -46,6 +46,11 @@ Schema: **31** (unchanged).
- **Stop sharing.** Shared by Me → item menu → "Stop Sharing…" and `pdfs unshare <path>` (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 <uid>` 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.