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. /// Accept every proposed local path without asking.
#[arg(long)] #[arg(long)]
yes: bool, 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 } => { SyncCmd::Mode { id, mode } => {
ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { 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(()) Ok(())
} }
@@ -1075,8 +1079,13 @@ fn cmd_sync_queue() -> Result<()> {
/// The daemon only ever *proposes* local paths — a path recorded by another /// 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 /// 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. /// flow is propose-and-confirm. `--yes` accepts the proposals for scripting.
fn cmd_sync_restore(yes: bool) -> Result<()> { fn cmd_sync_restore(yes: bool, device: Option<String>) -> Result<()> {
let response = control_request(CtlRequest::ListRestorableFolders)?; let response = control_request(match &device {
Some(device) => CtlRequest::ListDeviceRestorableFolders {
device: device.clone(),
},
None => CtlRequest::ListRestorableFolders,
})?;
if emit_json(&response)? { if emit_json(&response)? {
return Ok(()); return Ok(());
} }
@@ -1126,8 +1135,12 @@ fn cmd_sync_restore(yes: bool) -> Result<()> {
println!("Nothing selected."); println!("Nothing selected.");
return Ok(()); return Ok(());
} }
ok_or_bail(control_request(CtlRequest::RestoreSyncFolders { ok_or_bail(control_request(match device {
items: chosen, 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. /// Attach the given remote device folders to local paths and sync them down.
/// Replies with [`Response::Ok`]. /// Replies with [`Response::Ok`].
RestoreSyncFolders { items: Vec<RestoreItem> }, 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 --------------------------------------------------- // ---- sharing a node ---------------------------------------------------
/// Invite `emails` (Proton and/or external addresses, auto-detected) to the /// Invite `emails` (Proton and/or external addresses, auto-detected) to the
@@ -2491,6 +2503,17 @@ mod tests {
mode: "mirror".into(), 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 { Request::ShareNode {
path: "a/b".into(), path: "a/b".into(),
emails: vec!["x@proton.me".into(), "y@example.com".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), 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 }, Ok(items) => CtlResponse::RestorableFolders { items },
Err(e) => CtlResponse::error(e), 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, // Like AddSyncFolder: each restored folder downloads a whole tree,
// which outlasts the socket timeout. Ack, then work. // which outlasts the socket timeout. Ack, then work.
let core = core.clone(); let core = core.clone();
std::thread::spawn(move || match core.restore_sync_folders(&items) { std::thread::spawn(move || {
Ok(message) => { match core.restore_sync_folders(&items, device.as_deref()) {
core.touch_profile(); Ok(message) => {
core.log_activity(ActivityKind::Download, &message, "", true); core.touch_profile();
} core.log_activity(ActivityKind::Download, &message, "", true);
Err(e) => { }
warn!(error = %e, "restore failed"); Err(e) => {
core.log_activity(ActivityKind::Download, "restore folders", &e, false); warn!(error = %e, "restore failed");
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
}
} }
}); });
CtlResponse::Ok { CtlResponse::Ok {
+33
View File
@@ -227,6 +227,39 @@ impl Core {
Ok(format!("adopted device {name}")) 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) ------------------------- // ---- device folder sync (devices.md, Phase 1) -------------------------
/// Auto-register (or recover) this machine as a Proton Drive Device, caching /// 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) ---------------------------------------- // ---- restore (features.md 5.2) ----------------------------------------
/// The folders under this machine's device that could be synced here, each /// The folders under a device that could be synced here, each carrying a
/// carrying a proposed local path. /// 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 /// The proposal comes from the profile when one exists and its path is
/// usable on this machine, and from `~/<name>` otherwise — a restored /// usable on this machine, and from `~/<name>` otherwise — a restored
/// `/home/other-user/Documents` would be wrong on a machine where that user /// `/home/other-user/Documents` would be wrong on a machine where that user
/// does not exist, and silently creating it would be worse. /// does not exist, and silently creating it would be worse.
pub(crate) fn list_restorable_folders(&self) -> CoreResult<Vec<RestorableFolder>> { pub(crate) fn list_restorable_folders(
let device = self.ensure_device()?; &self,
device: Option<&str>,
) -> CoreResult<Vec<RestorableFolder>> {
let device = self.restore_source(device)?;
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| { let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
CoreError::internal(format!("bad device root uid: {}", device.root_uid)) CoreError::internal(format!("bad device root uid: {}", device.root_uid))
})?; })?;
@@ -334,11 +338,12 @@ impl Core {
pub(crate) fn restore_sync_folders( pub(crate) fn restore_sync_folders(
&self, &self,
items: &[pdfs_core::control::RestoreItem], items: &[pdfs_core::control::RestoreItem],
device: Option<&str>,
) -> CoreResult<String> { ) -> CoreResult<String> {
if items.is_empty() { if items.is_empty() {
return Err(CoreError::invalid("nothing to restore")); return Err(CoreError::invalid("nothing to restore"));
} }
let device = self.ensure_device()?; let device = self.restore_source(device)?;
let existing = self let existing = self
.db .db
.sync_folder_list() .sync_folder_list()
@@ -499,6 +504,19 @@ mod tests {
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()"); 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 { fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
let start = source.find(signature).expect("function signature exists"); let start = source.find(signature).expect("function signature exists");
let body = &source[start..]; let body = &source[start..];
+1 -1
View File
@@ -719,7 +719,7 @@ fn build_window(app: &adw::Application) {
wire_trash(&ui, &trash_widgets); wire_trash(&ui, &trash_widgets);
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark); wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
wire_shared_by_me(&ui, &shared_by_me_widgets.retry); 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_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
wire_activity(&ui, &activity_widgets.retry, &activity_widgets.filters); wire_activity(&ui, &activity_widgets.retry, &activity_widgets.filters);
wire_takeout(&ui, &takeout_widgets); 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 /// machine's own device is deliberately not among them; see
/// [`repaint_devices`]. /// [`repaint_devices`].
pub(crate) group: adw::PreferencesGroup, 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) retry: gtk4::Button,
pub(crate) refresh: 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 /// listing the account's other registered devices. The synced folders
/// themselves live on the Sync page. /// themselves live on the Sync page.
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { 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(); let refresh = refresh_button();
// This page is about device *identity* — which computer this is, which other // 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.set_margin_end(18);
inner.append(&content); inner.append(&content);
let (frame, header, _) = page_frame("Computers", &inner); let (frame, header, _) = page_frame("Computers", &inner);
header.pack_start(&restore);
header.pack_end(&refresh); header.pack_end(&refresh);
( (
@@ -125,22 +117,19 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
sync_group, sync_group,
rename_this, rename_this,
group, group,
restore,
retry, retry,
refresh, refresh,
}, },
) )
} }
/// Install the Devices page's retry button and the "Restore Folders" action. /// Install the Devices page's retry button and the Rename action.
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: &gtk4::Button, restore: &gtk4::Button) { pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: &gtk4::Button) {
let ui_retry = ui.clone(); let ui_retry = ui.clone();
retry.connect_clicked(move |_| { retry.connect_clicked(move |_| {
service::restart(); service::restart();
load_devices(&ui_retry); load_devices(&ui_retry);
}); });
let ui_restore = ui.clone();
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
let ui_ren = ui.clone(); let ui_ren = ui.clone();
ui.devices.rename_this.connect_clicked(move |_| { ui.devices.rename_this.connect_clicked(move |_| {
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() { 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_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_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_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![ 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…", "Rename…",
Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)), 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(); let ui_go = ui.clone();
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations")); manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
row.add_suffix(&manage); 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_group.add(&row);
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()]; *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 /// Ask the daemon what a device holds, then show a picker mapping each remote
/// each remote folder onto a local directory. /// 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 /// 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 /// makes sense here, else `~/<name>` — so every one of them is editable and
/// nothing is restored without being ticked. /// 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(); 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(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let result = rx.recv().await; let result = rx.recv().await;
ui.busy_end(); ui.busy_end();
match result { 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 })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't list folders to restore", &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. /// 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 win = ui_window(ui);
let candidates: Vec<RestorableFolder> = let candidates: Vec<RestorableFolder> =
items.into_iter().filter(|f| !f.already_synced).collect(); items.into_iter().filter(|f| !f.already_synced).collect();
if candidates.is_empty() { if candidates.is_empty() {
toast( toast(
ui, ui,
"Nothing to restore — this computer's folders are all synced here.", &nothing_to_restore(device.as_ref().map(|(_, n)| n.as_str())),
); );
return; return;
} }
@@ -521,7 +535,9 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
.build(); .build();
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Restore folders") .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) .extra_child(&scroll)
.build(); .build();
dialog.add_response("cancel", "Cancel"); 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. // rows appear as the periodic refresh picks them up.
run_devices_mutation( run_devices_mutation(
&ui, &ui,
Request::RestoreSyncFolders { items }, match &device {
Some((uid, _)) => Request::RestoreDeviceFolders {
device: uid.clone(),
items,
},
None => Request::RestoreSyncFolders { items },
},
"Restoring folders…", "Restoring folders…",
"Couldn't restore folders", "Couldn't restore folders",
); );
@@ -559,6 +581,25 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
dialog.present(win.as_ref()); 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 /// 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 /// 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). /// 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" "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 - **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 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. 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, - **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. 1 day, 7 days, 30 days), and an existing link shows when it expires or that it has expired.