fix(gui): show remote names as text, not Pango markup

Rows, toasts and status page descriptions parse their text as markup by
default. Shared item names, inviter addresses, device names, link URLs,
paths, EXIF values and daemon errors went in unescaped, so a name with
`&` showed as an empty row and other names were drawn as markup. Those
rows and toasts now use plain text, and status page descriptions are
escaped (B232).
This commit is contained in:
nvrl committed 2026-10-16 11:24:43 +02:00
1 parent 519ba0591a
commit ef0f046939
19 files changed
+89 -14

No files matched your search

+16 -2
View File
@@ -1065,8 +1065,17 @@ pub(crate) fn set_sidebar_badge(ui: &Ui, page: &str, count: u64) {
/// Show a transient toast. Non-blocking by design: an action's outcome is
/// reported without stealing focus or forcing a click, so the user can keep
/// working while a slow upload lands.
///
/// A toast's title is Pango markup unless told otherwise. Messages carry file
/// names and daemon errors, and one with a `&` or `<` in it showed as an empty
/// toast, so every toast here is plain text.
fn toast(ui: &Rc<Ui>, message: &str) {
ui.toasts.add_toast(adw::Toast::new(message));
ui.toasts.add_toast(
adw::Toast::builder()
.title(message)
.use_markup(false)
.build(),
);
}
/// Show a toast that offers one follow-up action — an Undo, or a way to the page
@@ -1077,6 +1086,7 @@ fn toast(ui: &Rc<Ui>, message: &str) {
fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) {
let toast = adw::Toast::builder()
.title(message)
.use_markup(false)
.button_label(label)
.timeout(8)
.build();
@@ -1099,7 +1109,11 @@ fn toast_error(ui: &Rc<Ui>, what: &str, detail: &str) {
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
};
tracing::warn!("{message}");
let toast = adw::Toast::builder().title(&message).timeout(6).build();
let toast = adw::Toast::builder()
.title(&message)
.use_markup(false)
.timeout(6)
.build();
ui.toasts.add_toast(toast);
}
+3 -1
View File
@@ -378,7 +378,9 @@ pub(crate) fn activity_status(
) {
ui.activity.status.set_icon_name(Some(icon));
ui.activity.status.set_title(title);
ui.activity.status.set_description(Some(description));
ui.activity
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.activity.retry.set_visible(retry);
ui.activity.content.set_visible_child_name("status");
}
+3 -1
View File
@@ -645,7 +645,9 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
ui.gallery.albums_status.set_icon_name(Some(icon));
ui.gallery.albums_status.set_title(title);
ui.gallery.albums_status.set_description(Some(description));
ui.gallery
.albums_status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.gallery.albums_stack.set_visible_child_name("status");
}
+3 -1
View File
@@ -500,7 +500,9 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
pub(crate) fn browser_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.browser.status.set_icon_name(Some(icon));
ui.browser.status.set_title(title);
ui.browser.status.set_description(Some(description));
ui.browser
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.browser.retry.set_visible(retry);
// Only the empty-folder state offers a way to fill the folder; every other
// status (loading, offline, error) turns them back off.
+9 -2
View File
@@ -204,7 +204,9 @@ pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.devices.status.set_icon_name(Some(icon));
ui.devices.status.set_title(title);
ui.devices.status.set_description(Some(description));
ui.devices
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.devices.retry.set_visible(retry);
ui.devices.content.set_visible_child_name("status");
}
@@ -390,6 +392,7 @@ fn this_computer_row(
let row = adw::ActionRow::builder()
.title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone()))
.subtitle(this_computer_subtitle(folders))
.use_markup(false)
.activatable(true)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
@@ -426,6 +429,7 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(&dev.name)
.subtitle(device_subtitle(dev))
.use_markup(false)
.activatable(true)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
@@ -763,7 +767,10 @@ fn show_restore_picker(
.active(true)
.valign(gtk4::Align::Center)
.build();
let row = adw::EntryRow::builder().title(&f.name).build();
let row = adw::EntryRow::builder()
.title(&f.name)
.use_markup(false)
.build();
row.set_text(&f.local_path);
row.add_prefix(&check);
group.add(&row);
+2 -1
View File
@@ -209,7 +209,8 @@ fn ask(view: &Rc<FinderView>, finder: Finder, generation: u32) {
Ok(Ok(Response::Error { message, .. })) => {
view.status.set_icon_name(Some("dialog-warning-symbolic"));
view.status.set_title(&failed);
view.status.set_description(Some(&message));
view.status
.set_description(Some(&gtk4::glib::markup_escape_text(&message)));
view.progress.set_visible(false);
return;
}
+3 -1
View File
@@ -1145,7 +1145,9 @@ pub(crate) fn locations_status(
) {
ui.locations.status.set_icon_name(Some(icon));
ui.locations.status.set_title(title);
ui.locations.status.set_description(Some(description));
ui.locations
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.locations.retry.set_visible(retry);
ui.locations.content.set_visible_child_name("status");
}
@@ -481,6 +481,7 @@ pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
let row = adw::ActionRow::builder()
.title(label.as_str())
.subtitle(value)
.use_markup(false)
.subtitle_selectable(true)
.build();
row.add_css_class("property");
+3 -1
View File
@@ -3459,7 +3459,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
pub(crate) fn gallery_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.gallery.status.set_icon_name(Some(icon));
ui.gallery.status.set_title(title);
ui.gallery.status.set_description(Some(description));
ui.gallery
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.gallery.retry.set_visible(retry);
// Only the empty timeline offers a way to fill it; every other status turns
// those buttons back off.
+3 -1
View File
@@ -448,7 +448,9 @@ pub(crate) fn close_place(ui: &Rc<Ui>) {
fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
ui.gallery.places_status.set_icon_name(Some(icon));
ui.gallery.places_status.set_title(title);
ui.gallery.places_status.set_description(Some(description));
ui.gallery
.places_status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.gallery.places_stack.set_visible_child_name("status");
}
+5 -1
View File
@@ -311,7 +311,9 @@ pub(crate) fn wire_shared(ui: &Rc<Ui>, widgets: &SharedWidgets) {
pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.shared.status.set_icon_name(Some(icon));
ui.shared.status.set_title(title);
ui.shared.status.set_description(Some(description));
ui.shared
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.shared.retry.set_visible(retry);
ui.shared.content.set_visible_child_name("status");
}
@@ -818,6 +820,7 @@ fn invitation_row(inv: &InvitationInfo) -> adw::ActionRow {
.title(&item)
// Translators: {email} is the address of the person who sent the invitation.
.subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)]))
.use_markup(false)
.build();
row.add_prefix(&gtk4::Image::from_icon_name(if inv.is_dir {
"folder-symbolic"
@@ -862,6 +865,7 @@ fn repaint_links(ui: &Rc<Ui>, bookmarks: &[BookmarkInfo]) {
let row = adw::ActionRow::builder()
.title(&title)
.subtitle(&bm.url)
.use_markup(false)
.subtitle_lines(1)
.activatable(true)
.tooltip_text(gettext("Open in browser"))
@@ -329,7 +329,9 @@ pub(crate) fn shared_by_me_status(
) {
ui.shared_by_me.status.set_icon_name(Some(icon));
ui.shared_by_me.status.set_title(title);
ui.shared_by_me.status.set_description(Some(description));
ui.shared_by_me
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.shared_by_me.retry.set_visible(retry);
ui.shared_by_me.content.set_visible_child_name("status");
ui.shared_by_me.subtitle.set_subtitle("");
+1
View File
@@ -1566,6 +1566,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
let row = adw::ActionRow::builder()
.title(&name)
.subtitle(&pin.path)
.use_markup(false)
.build();
let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic");
row.add_prefix(&icon);
+1
View File
@@ -510,6 +510,7 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
.unwrap_or_else(|| path.display().to_string()),
)
.subtitle(human_bytes(size))
.use_markup(false)
.build();
let remove = gtk4::Button::builder()
.icon_name("edit-delete-symbolic")
+3 -1
View File
@@ -330,7 +330,9 @@ pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.trash.status.set_icon_name(Some(icon));
ui.trash.status.set_title(title);
ui.trash.status.set_description(Some(description));
ui.trash
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.trash.retry.set_visible(retry);
ui.trash.content.set_visible_child_name("status");
ui.trash.subtitle.set_subtitle("");
@@ -98,6 +98,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
.title(gettext("Location"))
.subtitle("—")
.subtitle_lines(3)
// A folder path, which may hold `&` or `<`.
.use_markup(false)
.build();
path_row.add_css_class("property");
props.add(&size_row);
@@ -491,6 +491,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
let row = adw::ActionRow::builder()
.title(&entry.email)
.subtitle(&subtitle)
.use_markup(false)
.build();
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
@@ -605,6 +606,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
url.clone()
})
.subtitle(&subtitle)
.use_markup(false)
.build();
row.add_css_class("property");
@@ -219,6 +219,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
let row = adw::ActionRow::builder()
.title(title)
.subtitle(subtitle)
.use_markup(false)
.build();
let save = gtk4::Button::builder()
+25
View File
@@ -100,6 +100,7 @@ a live account since.
| Entry | Title |
|---|---|
| [B232](#b232--names-with--or--show-as-empty-rows-or-are-drawn-as-markup) | Names with & or < show as empty rows or are drawn as markup |
| [B231](#b231--a-large-upload-under-a-low-upload-limit-never-finishes) | A large upload under a low upload limit never finishes |
| [B230](#b230--a-create-queued-offline-adopts-another-devices-file-of-the-same-name) | A create queued offline adopts another device's file of the same name |
| [B229](#b229--a-queued-write-can-be-lost-to-a-power-cut-after-close-returned) | A queued write can be lost to a power cut after close returned |
@@ -334,6 +335,30 @@ a live account since.
Newest first.
### B232 — Names with & or < show as empty rows or are drawn as markup
**Status:** Fixed, not verified live
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, Pango markup injection).
**Where:** the toast helpers in `crates/pdfs-gui/src/app/main.rs`; rows in
`app/pages/shared.rs`, `devices.rs`, `status.rs`, `takeout.rs`, `photo_viewer.rs`,
`app/widgets/share_dialog.rs`, `details.rs`, `versions_dialog.rs`; every status page helper
**Repro:** Accept an invitation to a folder named `R&D`, or name a computer `Tom & Jerry`.
Its row in the app is empty. A name such as `<span size="80000">x</span>` is drawn as markup.
**Cause.** Libadwaita rows, toasts and status page descriptions read their text as Pango markup
unless told otherwise. Names that other people control (shared items, inviter addresses, device
names, link URLs), file paths, EXIF values and daemon error messages went in unescaped. Text that
did not parse showed as an empty row; text that did was drawn as markup.
**Fix.** Rows and toasts that show such text are built with `use_markup(false)`. Status page
descriptions, which have no such switch, are escaped with `markup_escape_text`.
**Test:** none automated; the change only sets a widget property. Checked by reading every row,
toast and status page that takes text from outside the program.
---
### B231 — A large upload under a low upload limit never finishes
**Status:** Fixed, not verified live