From ef0f0469390f11a8cbd72e2072b1f6c8527a3d7b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 11:24:43 +0200 Subject: [PATCH] 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). --- crates/pdfs-gui/src/app/main.rs | 18 +++++++++++-- crates/pdfs-gui/src/app/pages/activity.rs | 4 ++- crates/pdfs-gui/src/app/pages/albums.rs | 4 ++- crates/pdfs-gui/src/app/pages/browser.rs | 4 ++- crates/pdfs-gui/src/app/pages/devices.rs | 11 ++++++-- crates/pdfs-gui/src/app/pages/duplicates.rs | 3 ++- crates/pdfs-gui/src/app/pages/locations.rs | 4 ++- crates/pdfs-gui/src/app/pages/photo_viewer.rs | 1 + crates/pdfs-gui/src/app/pages/photos.rs | 4 ++- crates/pdfs-gui/src/app/pages/places.rs | 4 ++- crates/pdfs-gui/src/app/pages/shared.rs | 6 ++++- crates/pdfs-gui/src/app/pages/shared_by_me.rs | 4 ++- crates/pdfs-gui/src/app/pages/status.rs | 1 + crates/pdfs-gui/src/app/pages/takeout.rs | 1 + crates/pdfs-gui/src/app/pages/trash.rs | 4 ++- crates/pdfs-gui/src/app/widgets/details.rs | 2 ++ .../pdfs-gui/src/app/widgets/share_dialog.rs | 2 ++ .../src/app/widgets/versions_dialog.rs | 1 + docs/BUGS.md | 25 +++++++++++++++++++ 19 files changed, 89 insertions(+), 14 deletions(-) diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 37f4378..277d6f1 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -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, 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, message: &str) { fn toast_action(ui: &Rc, message: &str, label: &str, action: impl Fn(&Rc) + '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, 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); } diff --git a/crates/pdfs-gui/src/app/pages/activity.rs b/crates/pdfs-gui/src/app/pages/activity.rs index 065b019..9a7b558 100644 --- a/crates/pdfs-gui/src/app/pages/activity.rs +++ b/crates/pdfs-gui/src/app/pages/activity.rs @@ -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(>k4::glib::markup_escape_text(description))); ui.activity.retry.set_visible(retry); ui.activity.content.set_visible_child_name("status"); } diff --git a/crates/pdfs-gui/src/app/pages/albums.rs b/crates/pdfs-gui/src/app/pages/albums.rs index 1ee2f34..29e64ec 100644 --- a/crates/pdfs-gui/src/app/pages/albums.rs +++ b/crates/pdfs-gui/src/app/pages/albums.rs @@ -645,7 +645,9 @@ pub(crate) fn close_album(ui: &Rc) { fn albums_status(ui: &Rc, 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(>k4::glib::markup_escape_text(description))); ui.gallery.albums_stack.set_visible_child_name("status"); } diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index 291e0d8..dd39886 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -500,7 +500,9 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { pub(crate) fn browser_status(ui: &Rc, 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(>k4::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. diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index cdd28c7..fa7d84b 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -204,7 +204,9 @@ pub(crate) fn wire_devices(ui: &Rc, widgets: &DevicesWidgets) { pub(crate) fn devices_status(ui: &Rc, 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(>k4::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(>k4::Image::from_icon_name("computer-symbolic")); @@ -426,6 +429,7 @@ fn other_computer_row(ui: &Rc, 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(>k4::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); diff --git a/crates/pdfs-gui/src/app/pages/duplicates.rs b/crates/pdfs-gui/src/app/pages/duplicates.rs index 2ebc2ed..97881ff 100644 --- a/crates/pdfs-gui/src/app/pages/duplicates.rs +++ b/crates/pdfs-gui/src/app/pages/duplicates.rs @@ -209,7 +209,8 @@ fn ask(view: &Rc, 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(>k4::glib::markup_escape_text(&message))); view.progress.set_visible(false); return; } diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs index fdf22fc..a758c92 100644 --- a/crates/pdfs-gui/src/app/pages/locations.rs +++ b/crates/pdfs-gui/src/app/pages/locations.rs @@ -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(>k4::glib::markup_escape_text(description))); ui.locations.retry.set_visible(retry); ui.locations.content.set_visible_child_name("status"); } diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index f394649..6a3ab95 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -481,6 +481,7 @@ pub(crate) fn show_info(viewer: &Rc, 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"); diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 94105b2..2e9feb2 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -3459,7 +3459,9 @@ pub(crate) fn load_gallery(ui: &Rc, append: bool) { pub(crate) fn gallery_status(ui: &Rc, 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(>k4::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. diff --git a/crates/pdfs-gui/src/app/pages/places.rs b/crates/pdfs-gui/src/app/pages/places.rs index ea0364b..4c817ae 100644 --- a/crates/pdfs-gui/src/app/pages/places.rs +++ b/crates/pdfs-gui/src/app/pages/places.rs @@ -448,7 +448,9 @@ pub(crate) fn close_place(ui: &Rc) { fn places_status(ui: &Rc, 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(>k4::glib::markup_escape_text(description))); ui.gallery.places_stack.set_visible_child_name("status"); } diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index efc1352..2bfd3d5 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -311,7 +311,9 @@ pub(crate) fn wire_shared(ui: &Rc, widgets: &SharedWidgets) { pub(crate) fn shared_status(ui: &Rc, 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(>k4::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(>k4::Image::from_icon_name(if inv.is_dir { "folder-symbolic" @@ -862,6 +865,7 @@ fn repaint_links(ui: &Rc, 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")) diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index 2d99bf7..42e5879 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -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(>k4::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(""); diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index e14bafc..dd2dfbd 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -1566,6 +1566,7 @@ pub(crate) fn repaint_pins(ui: &Rc, 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); diff --git a/crates/pdfs-gui/src/app/pages/takeout.rs b/crates/pdfs-gui/src/app/pages/takeout.rs index bbbf64c..8e888b5 100644 --- a/crates/pdfs-gui/src/app/pages/takeout.rs +++ b/crates/pdfs-gui/src/app/pages/takeout.rs @@ -510,6 +510,7 @@ pub(crate) fn repaint_archives(ui: &Rc) { .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") diff --git a/crates/pdfs-gui/src/app/pages/trash.rs b/crates/pdfs-gui/src/app/pages/trash.rs index 5882a9a..843e524 100644 --- a/crates/pdfs-gui/src/app/pages/trash.rs +++ b/crates/pdfs-gui/src/app/pages/trash.rs @@ -330,7 +330,9 @@ pub(crate) fn trash_unreachable(ui: &Rc) { pub(crate) fn trash_status(ui: &Rc, 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(>k4::glib::markup_escape_text(description))); ui.trash.retry.set_visible(retry); ui.trash.content.set_visible_child_name("status"); ui.trash.subtitle.set_subtitle(""); diff --git a/crates/pdfs-gui/src/app/widgets/details.rs b/crates/pdfs-gui/src/app/widgets/details.rs index 625f53b..4cb9c0a 100644 --- a/crates/pdfs-gui/src/app/widgets/details.rs +++ b/crates/pdfs-gui/src/app/widgets/details.rs @@ -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); diff --git a/crates/pdfs-gui/src/app/widgets/share_dialog.rs b/crates/pdfs-gui/src/app/widgets/share_dialog.rs index 8da0459..1bca73d 100644 --- a/crates/pdfs-gui/src/app/widgets/share_dialog.rs +++ b/crates/pdfs-gui/src/app/widgets/share_dialog.rs @@ -491,6 +491,7 @@ pub(crate) fn repaint_share_people(state: &Rc, 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, link: Option<&PublicLi url.clone() }) .subtitle(&subtitle) + .use_markup(false) .build(); row.add_css_class("property"); diff --git a/crates/pdfs-gui/src/app/widgets/versions_dialog.rs b/crates/pdfs-gui/src/app/widgets/versions_dialog.rs index 6b1366d..ad9bf6c 100644 --- a/crates/pdfs-gui/src/app/widgets/versions_dialog.rs +++ b/crates/pdfs-gui/src/app/widgets/versions_dialog.rs @@ -219,6 +219,7 @@ fn repaint_versions(state: &Rc, items: &[RevisionInfo]) { let row = adw::ActionRow::builder() .title(title) .subtitle(subtitle) + .use_markup(false) .build(); let save = gtk4::Button::builder() diff --git a/docs/BUGS.md b/docs/BUGS.md index d4702ee..e93be49 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -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 `x` 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