From 5dfb7172359fe205115dea6ff9a489946f811bf5 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 12 Oct 2026 22:41:52 +0200 Subject: [PATCH] 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. --- crates/pstr-app/src/app.rs | 17 ++++++++ crates/pstr-app/src/engine.rs | 28 +++++++++++++ crates/pstr-app/src/ui/shares.rs | 68 +++++++++++++++++++++++++++++++- 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 1c644d4..8e85f53 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -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, + }, AddShare { name: String, url: String, @@ -531,6 +537,10 @@ pub struct ShareForm { pub error: Option, /// 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, + 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; diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 474513d..9eacb33 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -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) { + 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(); diff --git a/crates/pstr-app/src/ui/shares.rs b/crates/pstr-app/src/ui/shares.rs index 91487ae..bb2b75a 100644 --- a/crates/pstr-app/src/ui/shares.rs +++ b/crates/pstr-app/src/ui/shares.rs @@ -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, ) { 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) { + 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")