feat(app): re-enter a share's link on the desktop

A keyring that was reset, or a link password that changed, left a share
the desktop could only remove and add again, losing its catalog, watch
history and downloads. The phones have had a way back since B18; the
desktop's share rows now have Re-enter link, which stores the new
secret through ShareStore::replace_secrets, reopens the share and
crawls it.
This commit is contained in:
nvrl committed 2026-10-12 22:42:07 +02:00
1 parent 3ff55a9af9
commit 5dfb717235
3 files changed
+112 -1

No files matched your search

+17
View File
@@ -131,6 +131,12 @@ pub enum Action {
PasteShare(String),
/// Go to the add form, caret in the link field.
OpenShareForm,
/// Store a new link, and password, for a share already in the library.
RepairShare {
id: String,
url: String,
password: Option<String>,
},
AddShare {
name: String,
url: String,
@@ -531,6 +537,10 @@ pub struct ShareForm {
pub error: Option<String>,
/// Put the caret in the link field on the next frame the form is drawn.
pub focus: bool,
/// The share whose link is being re-entered in its own row, if any.
pub repairing: Option<String>,
pub repair_url: String,
pub repair_password: String,
}
pub struct App {
@@ -1534,6 +1544,13 @@ impl App {
self.form.error = None;
self.note(ctx, "share link pasted — add it, or name it first", false);
}
Action::RepairShare { id, url, password } => {
self.crawling = true;
self.form.repairing = None;
self.form.repair_url.clear();
self.form.repair_password.clear();
self.engine.repair_share(id, url, password);
}
Action::OpenShareForm => {
self.page = Page::Shares;
self.form.focus = true;
+28
View File
@@ -682,6 +682,34 @@ impl Engine {
});
}
/// Re-supply the link behind a share whose stored secret is unreadable —
/// a keyring that was reset — or whose custom password changed, keeping
/// its catalog, watch state and downloads. See
/// [`ShareStore::replace_secrets`]. Then crawl it, which is what proves
/// the new secret works.
pub fn repair_share(&self, id: String, url: String, password: Option<String>) {
let engine = self.clone();
self.runtime.spawn(async move {
let share = match engine.store.replace_secrets(&id, &url, password.as_deref()) {
Ok(share) => share,
Err(error) => {
// The add form is not where this was asked, and nothing
// will be crawled to say the crawl is over.
engine.emit(Event::CrawlFinished);
return engine.fail("update the link", error);
}
};
engine.emit(Event::Status(format!(
"updated the link for {}",
share.name
)));
engine.load_shares();
// Its client was opened with the old secret.
engine.connection.lock().take();
engine.connect_and_crawl(Some(vec![share.id]), false).await;
});
}
/// Add a folder of the signed-in account as a share, then crawl it.
pub fn add_account_folder(&self, name: String, folder: NodeUid) {
let engine = self.clone();
+67 -1
View File
@@ -40,7 +40,7 @@ pub fn show(
.iter()
.filter(|title| title.share_ids.contains(&share.id))
.count();
share_row(ui, share, titles, crawls.get(&share.id), actions);
share_row(ui, share, titles, crawls.get(&share.id), form, actions);
}
ui.add_space(theme::space::XXL);
@@ -57,6 +57,7 @@ fn share_row(
share: &Share,
titles: usize,
crawl: Option<&CrawlRecord>,
form: &mut ShareForm,
actions: &mut Vec<Action>,
) {
egui::Frame::new()
@@ -107,12 +108,77 @@ fn share_row(
if ui.button("Crawl").clicked() {
actions.push(Action::Crawl(Some(share.id.clone())));
}
// Not for a folder of the account, which has no link.
if share.folder.is_none()
&& ui
.button("Re-enter link")
.on_hover_text(
"For a keyring that lost the link, or a password that changed. \
The library, history and downloads stay.",
)
.clicked()
{
form.repairing = Some(share.id.clone());
form.repair_url.clear();
form.repair_password.clear();
}
});
});
if form.repairing.as_deref() == Some(share.id.as_str()) {
repair_form(ui, share, form, actions);
}
});
ui.add_space(theme::space::S);
}
/// The same link again, and a password if it has one, for a share already in
/// the library.
fn repair_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
ui.add_space(theme::space::M);
ui.add(
egui::TextEdit::singleline(&mut form.repair_url)
.password(true)
.hint_text("The share link, including everything after the #")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::S);
ui.add(
egui::TextEdit::singleline(&mut form.repair_password)
.password(true)
.hint_text("Link password, if it has one")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::S);
let problem = (!form.repair_url.trim().is_empty())
.then(|| pstr_core::shares::share_token(form.repair_url.trim()).err())
.flatten();
if let Some(problem) = &problem {
ui.label(
theme::Role::Caption
.rich(problem.to_string())
.color(theme::danger()),
);
}
ui.horizontal(|ui| {
let ready = !form.repair_url.trim().is_empty() && problem.is_none();
if ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save"))
.inner
.clicked()
{
actions.push(Action::RepairShare {
id: share.id.clone(),
url: form.repair_url.trim().to_owned(),
password: Some(form.repair_password.clone())
.filter(|password| !password.is_empty()),
});
}
if ui.button("Cancel").clicked() {
form.repairing = None;
}
});
}
/// The add form's link field, which the library's empty state focuses.
pub fn link_field_id() -> egui::Id {
egui::Id::new("share-link")