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