Compare commits

..
Author SHA1 Message Date
nvrl ba5b6a8bf2 fix(core): retain unsaved session tokens
Retry token persistence when the keyring reports a missing session.
oo7 can hide locked entries, so discarding the pending rotation leaves
a spent refresh token in the saved session.

Refs #78
2026-11-14 15:18:27 +01:00
nvrl 3295eda1f0 Merge pull request #88 from narrrl/feat/photos-drag-upload
Feat/photos drag upload
2026-11-14 14:38:24 +01:00
nvrl 41212efdf3 feat(gui,fuse,core): share photos, copy image, thumbnail strip in the viewer
- Share… in the grid's photo menu and the viewer's ⋮ menu opens the Share
  dialog by uid. The daemon accepts a uid from the Photos timeline for
  sharing (resolve_shareable), since the photos volume is in no mount.
  Photos in an album someone else shared are not offered.
- Copy Image (Ctrl+C) puts the shown still on the clipboard. The viewer
  has its own toast overlay, so this and Save a Copy confirm over it
  instead of behind it.
- A thumbnail strip along the viewer's bottom edge, on the gallery model,
  marks the shown photo and jumps on click. Several pictures can now
  wait for one thumbnail, so the strip and the grid tile don't steal it
  from each other.
- PhotoItem carries place_id (serde default); the line under the
  viewer's title reads "Berlin · 12 of 340".
2026-11-14 13:58:25 +01:00
nvrl 5f0b15fe70 fix(gui): a photo viewer bar that reads on any photo
The bar's white-on-translucent-white buttons vanished over a bright photo,
and eight look-alike icons, a floppy disk and an open-document glyph among
them, left the user guessing. The bar now holds the favorite, the details
and a menu with Slideshow, Save a Copy, Open With, Add to Album, Show on
Map and Move to Trash, on dark discs that read over white and black
alike.

A shot stored as more than one file shows a labelled pill ("RAW", "Live",
"2 of 3") instead of a paged icon, lit while the second file is shown.
The title is the localized full date instead of a raw timestamp.

Delete asks first, as the grid does: the dialog opens over the viewer,
and the album prompts can open there too. Alt+Left closes the viewer and
F10 opens the menu; the shortcuts window lists F5, F10 and Alt+Left.
2026-11-14 13:37:50 +01:00
nvrl 033c8d3d71 Merge pull request #86 from narrrl/fix/keep-place-when-navigating
fix(gui): keep the user's place when navigating
2026-11-14 12:57:35 +01:00
30 changed files with 4296 additions and 3286 deletions

No files matched your search

+45 -10
View File
@@ -996,21 +996,15 @@ impl TokenSink {
}
}
/// Write the unsaved rotation, if there is one. A blob that moved on, or
/// is gone with a logout, drops it: there is nothing it may still go into.
/// Write the unsaved rotation, if there is one. A blob that moved on drops
/// it. A missing blob is retried: some Secret Service providers also report
/// locked or temporarily hidden items as absent. Never recreate it here.
fn flush(&self) -> Result<()> {
let mut state = self.state.lock();
let Some(tokens) = state.unsaved.clone() else {
return Ok(());
};
let mut stored = match load() {
Ok(stored) => stored,
Err(Error::NotLoggedIn) => {
state.settle();
return Ok(());
}
Err(e) => return Err(e),
};
let mut stored = load()?;
match take_rotation(
&mut stored,
&self.session_id,
@@ -1306,6 +1300,47 @@ mod tests {
}
}
/// oo7 0.7.0 reports a locked collection's items as absent (upstream #614).
/// Treating that as logout loses the only live tokens (B284, issue #78).
#[test]
fn a_temporarily_missing_session_does_not_discard_a_rotation() {
let _turn = mock_keyring();
write_stored(&stored_with("r")).unwrap();
let sink = Arc::new(TokenSink::new("s", "r".into()));
let entry = keyring_entry().unwrap();
entry
.as_any()
.downcast_ref::<keyring_core::mock::Cred>()
.unwrap()
.set_error(keyring_core::Error::NoEntry);
assert!(sink.rotated(tokens("a2", "r2")).is_err());
assert!(sink.state.lock().unsaved.is_some());
assert_eq!(load().unwrap().refresh_token, "r");
sink.flush().unwrap();
assert_eq!(load().unwrap().refresh_token, "r2");
assert!(sink.state.lock().unsaved.is_none());
}
#[test]
fn retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login() {
let _turn = mock_keyring();
let sink = TokenSink::new("s", "r".into());
sink.state.lock().unsaved = Some(tokens("a2", "r2"));
assert!(matches!(sink.flush(), Err(Error::NotLoggedIn)));
assert!(matches!(load(), Err(Error::NotLoggedIn)));
assert!(sink.state.lock().unsaved.is_some());
let mut login = stored_with("fresh");
login.session_id = "new".into();
write_stored(&login).unwrap();
sink.flush().unwrap();
assert!(sink.state.lock().unsaved.is_none());
assert_eq!(load().unwrap().refresh_token, "fresh");
}
#[test]
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
let unsaved = UnsavedRotation {
+5
View File
@@ -1792,6 +1792,11 @@ pub struct PhotoItem {
/// the JPEG it shows is not the whole of what is stored.
#[serde(default)]
pub has_raw: bool,
/// GeoNames id of the town the photo was taken in, when its location is
/// known; [`crate::places::city`] names it. Older daemons omit it, and
/// album entries do not carry it.
#[serde(default)]
pub place_id: Option<u32>,
}
/// A daemon too old to classify a timeline entry is assumed to have served a
+4
View File
@@ -186,6 +186,8 @@ impl Db {
// made, and it shows exactly what they put in it.
group_size: 1,
has_raw: false,
// Album rows carry no location.
place_id: None,
})
})?
.collect::<rusqlite::Result<_>>()?;
@@ -247,6 +249,8 @@ impl Db {
// made, and it shows exactly what they put in it.
group_size: 1,
has_raw: false,
// Album rows carry no location.
place_id: None,
})
})?
.collect::<rusqlite::Result<_>>()?;
+4 -1
View File
@@ -40,6 +40,8 @@ pub struct StoredPhoto {
/// Whether any member of the group is a camera raw file. The grid badges
/// that, because the JPEG it shows is not the whole of what is stored.
pub has_raw: bool,
/// The town the photo was taken in, once its location has been read.
pub place_id: Option<u32>,
}
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
@@ -256,7 +258,7 @@ fn group_photos(entries: &[Grouped]) -> Vec<String> {
/// own group: the column is `NULL` until the next refresh computes it, and a
/// `NULL` would otherwise match nothing and drop the photo off the page.
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
p.kind, p.favorite, g.n, g.raw \
p.kind, p.favorite, g.n, g.raw, p.place_id \
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
@@ -272,6 +274,7 @@ fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
favorite: r.get::<_, i64>(6)? != 0,
group_size: r.get::<_, i64>(7)?.max(1) as usize,
has_raw: r.get::<_, i64>(8)? != 0,
place_id: r.get(9)?,
})
}
+17
View File
@@ -363,6 +363,23 @@ fn place_photos_carry_their_own_location() {
);
}
/// A timeline photo names the town it was taken in, so the viewer can show it;
/// one without a location names none.
#[test]
fn timeline_photos_name_their_town() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
located("b", 500, "IMG_5.JPG", BERLIN),
TimelineRow::new("n", 400),
])
.unwrap();
let berlin = db.photo_places().unwrap()[0].id;
let mut photos = db.photos_by_uid(&["b".into(), "n".into()]).unwrap();
photos.sort_by(|a, b| a.uid.cmp(&b.uid));
let towns: Vec<Option<u32>> = photos.iter().map(|photo| photo.place_id).collect();
assert_eq!(towns, [Some(berlin), None]);
}
/// A refresh that skips an already-resolved photo keeps where it was taken; one
/// that reads the node again takes what the node now says.
#[test]
+22
View File
@@ -3168,6 +3168,28 @@ impl Core {
)
}
/// [`Core::resolve_anywhere`], widened to the Photos timeline for sharing.
///
/// A photo lives on the photos volume, which no location shows, so the
/// residency proof above never finds it. The timeline in the `photos` table
/// is this daemon's proof instead: it lists only photos on the user's own
/// volume, which are theirs to share. A photo in an album someone else
/// shared is not in it and stays refused.
pub(crate) fn resolve_shareable(&self, uid: &str) -> CoreResult<NodeUid> {
resolve_anywhere_with(
uid,
|uid| self.owns_visible_uid(uid),
|uid| {
let uid = uid.to_string();
Ok(self.db.mirror_contains_uid(&uid)?
|| !self
.db
.photos_by_uid(std::slice::from_ref(&uid))?
.is_empty())
},
)
}
fn source_parent_uid(&self, ino: u64, rel: &Path) -> CoreResult<NodeUid> {
let state = self.state();
let entry = state
+1
View File
@@ -1027,6 +1027,7 @@ impl Core {
favorite: photo.favorite,
group_size: photo.group_size as u32,
has_raw: photo.has_raw,
place_id: photo.place_id,
}
}
+8 -8
View File
@@ -105,7 +105,7 @@ impl Core {
role: &str,
message: Option<&str>,
) -> CoreResult<(usize, usize)> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.share_node_for_uid(&uid, emails, role, message)
}
@@ -138,7 +138,7 @@ impl Core {
&self,
uid: &str,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.list_share_for_uid(&uid)
}
@@ -215,7 +215,7 @@ impl Core {
kind: ShareEntryKind,
role: &str,
) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.update_share_role_for_uid(&uid, id, kind, role)
}
@@ -278,7 +278,7 @@ impl Core {
id: &str,
kind: ShareEntryKind,
) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.remove_share_entry_for_uid(&uid, id, kind)
}
@@ -349,7 +349,7 @@ impl Core {
password: Option<&str>,
expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.create_public_link_for_uid(&uid, role, password, expires)
}
@@ -391,7 +391,7 @@ impl Core {
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.update_public_link_for_uid(&uid, id, role, password, expires)
}
@@ -460,7 +460,7 @@ impl Core {
/// [`Core::remove_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.remove_public_link_for_uid(&uid, id)
}
@@ -486,7 +486,7 @@ impl Core {
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.stop_sharing_for_uid(&uid)
}
+15 -4
View File
@@ -61,14 +61,25 @@ scale.browser-status-meter trough { min-width: 140px; min-height: 6px; }
/* Photo viewer: always light-on-dark, whatever the system style */
.photo-viewer-window { background-color: #111014; }
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.45) 60%, rgba(0, 0, 0, 0)); padding: 10px 16px 40px 20px; color: white; }
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
.viewer-action-btn { color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }
.viewer-action-btn:hover { background-color: rgba(255, 255, 255, 0.28); color: white; }
/* Dark discs with a white glyph read on a white photo as well as on a black one. */
.viewer-action-btn { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 50%; min-width: 36px; min-height: 36px; margin-left: 4px; -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
.viewer-action-btn:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
.viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
.viewer-close-btn { margin-left: 12px; }
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
.viewer-group-pill { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 999px; min-height: 36px; padding: 0 14px; font-weight: 700; font-size: 0.85rem; }
.viewer-group-pill:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
.viewer-group-pill.lit { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
/* The thumbnail strip along the lightbox's bottom edge, the photo on screen ringed in the accent. */
.viewer-strip-bar { background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); padding: 28px 12px 10px 12px; }
.viewer-strip { background: none; }
.viewer-strip > row { padding: 2px; margin: 0 2px; border-radius: 8px; background: none; }
.viewer-strip > row:hover { background-color: rgba(255, 255, 255, 0.15); }
.viewer-strip > row:selected { background-color: var(--accent-bg-color); }
.viewer-strip-thumb { border-radius: 6px; background-color: rgba(255, 255, 255, 0.08); }
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
.viewer-nav-btn:disabled { opacity: 0; }
+33 -2
View File
@@ -1339,7 +1339,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
// The Albums view's New Album button.
let new_album = gio::SimpleAction::new("new-album", None);
let ui_album = ui.clone();
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new()));
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new(), None));
window.add_action(&new_album);
let shortcuts = gio::SimpleAction::new("shortcuts", None);
@@ -1560,8 +1560,11 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
("Home End", gettext_noop("First / last photo")),
("i", gettext_noop("Show details")),
("f", gettext_noop("Fullscreen")),
("F5", gettext_noop("Slideshow")),
("F10", gettext_noop("More")),
("<Control>c", gettext_noop("Copy Image")),
("Delete", gettext_noop("Move to Trash")),
("Escape", gettext_noop("Close")),
("Escape <Alt>Left", gettext_noop("Close")),
],
),
];
@@ -2010,6 +2013,7 @@ mod tests {
favorite: false,
group_size: 2,
has_raw: kind == PhotoKind::Raw,
place_id: None,
}
}
@@ -2032,6 +2036,33 @@ mod tests {
assert!(next_group_member(&[], "jpeg").is_none());
}
/// The pill names what kind of shot it switches through, and lights up
/// on the file that is not the shot's main one.
#[test]
fn the_group_pill_names_the_kind_of_shot() {
let pair = [
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
];
assert_eq!(group_pill(&pair, "jpeg"), Some(("RAW".into(), false)));
assert_eq!(group_pill(&pair, "raw"), Some(("RAW".into(), true)));
let live = [
member("still", Some("IMG_2.HEIC"), PhotoKind::Photo),
member("clip", Some("IMG_2.MOV"), PhotoKind::Video),
];
assert_eq!(group_pill(&live, "still"), Some(("Live".into(), false)));
assert_eq!(group_pill(&live, "clip"), Some(("Live".into(), true)));
let burst = [
member("a", None, PhotoKind::Photo),
member("b", None, PhotoKind::Photo),
member("c", None, PhotoKind::Photo),
];
assert_eq!(group_pill(&burst, "b"), Some(("2 of 3".into(), false)));
assert_eq!(group_pill(&burst, "elsewhere"), None);
}
/// The switch names what it will show; a file whose name the daemon has not
/// resolved still gets a sentence rather than a blank.
#[test]
+13 -13
View File
@@ -225,12 +225,13 @@ fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: &gtk4::Widget, x: f64
/// while the entry is blank.
fn prompt_album_name(
ui: &Rc<Ui>,
parent: Option<gtk4::Window>,
heading: &str,
action: &str,
current: &str,
on_name: impl Fn(String) + 'static,
) {
let win = ui_window(ui);
let win = parent.or_else(|| ui_window(ui));
let dialog = adw::AlertDialog::builder().heading(heading).build();
let group = adw::PreferencesGroup::new();
let row = adw::EntryRow::builder()
@@ -260,10 +261,12 @@ fn prompt_album_name(
}
/// Ask for a name and create an album, then add `photos` to it, if any.
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
///
/// The dialogs open over `parent`, or over the main window without one.
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
let ui_c = ui.clone();
let (heading, action) = (gettext("New Album"), gettext("Create"));
prompt_album_name(ui, &heading, &action, "", move |name| {
prompt_album_name(ui, parent, &heading, &action, "", move |name| {
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
let ui = ui_c.clone();
let photos = photos.clone();
@@ -296,7 +299,7 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
let (ui_c, uid) = (ui.clone(), album.uid.clone());
let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
prompt_album_name(ui, &heading, &action, &album.name, move |name| {
prompt_album_name(ui, None, &heading, &action, &album.name, move |name| {
run_album_request(
&ui_c,
Request::RenameAlbum {
@@ -368,7 +371,7 @@ fn reload_album_grid(ui: &Rc<Ui>) {
}
/// Offer our own albums to add `photos` to, plus a new one.
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
let ui = ui.clone();
glib::spawn_future_local(async move {
@@ -390,7 +393,7 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
}
};
if albums.is_empty() {
prompt_new_album(&ui, photos);
prompt_new_album(&ui, photos, parent);
return;
}
@@ -436,11 +439,11 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
}
});
let ui_c = ui.clone();
let (ui_c, parent_c) = (ui.clone(), parent.clone());
dialog.connect_response(Some("new"), move |_, _| {
prompt_new_album(&ui_c, photos.clone())
prompt_new_album(&ui_c, photos.clone(), parent_c.clone())
});
dialog.present(ui_window(&ui).as_ref());
dialog.present(parent.or_else(|| ui_window(&ui)).as_ref());
});
}
@@ -595,10 +598,7 @@ pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: &gtk4::Picture) {
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
return;
}
ui.gallery
.thumb_wanted
.borrow_mut()
.insert(uid.clone(), picture.clone());
ui.gallery.await_thumb(&uid, picture);
let mut queue = ui.gallery.thumb_queue.borrow_mut();
if !queue.contains(&uid) {
queue.push_back(uid);
+423 -133
View File
@@ -52,9 +52,9 @@ pub(crate) struct Viewer {
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
pub(crate) favorite: gtk4::ToggleButton,
pub(crate) favorite_suppress: Cell<bool>,
/// The button that switches between the files of one shot, and the files it
/// The pill that switches between the files of one shot, and the files it
/// switches between (the JPEG and the RAW of a photo, a live photo and its
/// clip). Empty, and the button hidden, for a photo stored as one file.
/// clip). Empty, and the pill hidden, for a photo stored as one file.
pub(crate) group_btn: gtk4::Button,
pub(crate) group: RefCell<Vec<PhotoItem>>,
/// uid of the photo currently on screen.
@@ -81,14 +81,25 @@ pub(crate) struct Viewer {
/// rests; whether the pointer is over one of them (which keeps them up);
/// and the pending fade-out.
pub(crate) chrome: Vec<gtk4::Revealer>,
/// The strip of thumbnails along the bottom, its selection (the photo on
/// screen), and the revealer it fades with. Hidden in a slideshow.
pub(crate) strip: gtk4::ListView,
pub(crate) strip_selection: gtk4::SingleSelection,
pub(crate) strip_reveal: gtk4::Revealer,
pub(crate) chrome_hover: Cell<bool>,
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
/// The slideshow toggle; the timer that moves it on; and whether it put
/// the window in fullscreen, so stopping it only leaves a fullscreen it
/// The ⋮ button, and whether its menu is open, which keeps the controls
/// up the way resting the pointer on them does.
pub(crate) more: gtk4::Button,
pub(crate) menu_open: Cell<bool>,
/// Whether a slideshow runs; the timer that moves it on; and whether it
/// put the window in fullscreen, so stopping it only leaves a fullscreen it
/// entered itself.
pub(crate) slideshow: gtk4::ToggleButton,
pub(crate) slideshow: Cell<bool>,
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
pub(crate) slide_fullscreened: Cell<bool>,
/// Toasts over the lightbox. The main window's would land behind it.
pub(crate) toasts: adw::ToastOverlay,
}
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
@@ -163,6 +174,14 @@ fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
.group_btn
.set_tooltip_text(Some(&member_tooltip(next)));
}
if let Some((label, lit)) = group_pill(&items, &uid) {
viewer.group_btn.set_label(&label);
if lit {
viewer.group_btn.add_css_class("lit");
} else {
viewer.group_btn.remove_css_class("lit");
}
}
*viewer.group.borrow_mut() = items;
viewer.group_btn.set_visible(true);
});
@@ -175,6 +194,34 @@ pub(crate) fn next_group_member<'a>(members: &'a [PhotoItem], uid: &str) -> Opti
members.get((at + 1) % members.len())
}
/// What the group pill reads for the shot whose files are `members`, with
/// `uid` on screen, and whether it is lit. A raw pair reads "RAW", lit while
/// the raw file is shown; a live photo reads "Live", lit while its clip plays;
/// a burst counts its frames.
pub(crate) fn group_pill(members: &[PhotoItem], uid: &str) -> Option<(String, bool)> {
let at = members.iter().position(|item| item.uid == uid)?;
let shown = members[at].kind;
if members.iter().any(|item| item.kind == PhotoKind::Raw) {
// Translators: badge on a photo tile whose shot includes a raw camera file.
return Some((gettext("RAW"), shown == PhotoKind::Raw));
}
if members.len() == 2 && members.iter().any(|item| item.kind == PhotoKind::Video) {
// Translators: the switch between a live photo and its short clip in the photo viewer.
return Some((pgettext("live photo", "Live"), shown == PhotoKind::Video));
}
Some((
// Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are.
gettext_f(
"{position} of {total}",
&[
("position", &thousands(at + 1)),
("total", &thousands(members.len())),
],
),
false,
))
}
/// What to call one file of a shot: its name, or its kind when the daemon has
/// not resolved a name for it.
pub(crate) fn member_label(item: &PhotoItem) -> String {
@@ -341,14 +388,15 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
/// screen counted down once it has loaded.
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if viewer.slideshow.replace(true) {
return;
}
if !viewer.window.is_fullscreen() {
viewer.window.fullscreen();
viewer.slide_fullscreened.set(true);
}
viewer
.slideshow
.set_icon_name("media-playback-pause-symbolic");
hide_chrome(viewer);
viewer.strip_reveal.set_visible(false);
if !viewer.loading.get() {
arm_slide(ui, viewer);
}
@@ -356,13 +404,22 @@ fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
fn stop_slideshow(viewer: &Rc<Viewer>) {
viewer.slideshow.set(false);
viewer
.strip_reveal
.set_visible(viewer.strip_selection.n_items() > 1);
disarm_slide(viewer);
if viewer.slide_fullscreened.replace(false) {
viewer.window.unfullscreen();
}
viewer.slideshow.set_icon_name("pdfs-slideshow-symbolic");
if viewer.slideshow.is_active() {
viewer.slideshow.set_active(false);
}
/// Start the slideshow, or stop the one running.
fn toggle_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if viewer.slideshow.get() {
stop_slideshow(viewer);
} else {
start_slideshow(ui, viewer);
}
}
@@ -370,7 +427,7 @@ fn stop_slideshow(viewer: &Rc<Viewer>) {
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
disarm_slide(viewer);
if !viewer.slideshow.is_active() {
if !viewer.slideshow.get() {
return;
}
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
@@ -790,7 +847,7 @@ fn show_chrome(viewer: &Rc<Viewer>) {
};
// This timer has fired, so its id must not be removed again.
viewer.chrome_source.borrow_mut().take();
if !viewer.chrome_hover.get() {
if !viewer.chrome_hover.get() && !viewer.menu_open.get() {
hide_chrome(&viewer);
}
});
@@ -808,14 +865,14 @@ fn hide_chrome(viewer: &Rc<Viewer>) {
}
/// Copy the photo on screen to where the user picks, under its Drive name.
pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: &gtk4::Window, source: &str, name: &str) {
pub(crate) fn save_photo_to_disk(viewer: &Rc<Viewer>, source: &str, name: &str) {
let dialog = gtk4::FileDialog::builder()
.title(gettext("Save a Copy"))
.initial_name(name)
.build();
let source = source.to_string();
let ui = ui.clone();
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
let toasts = viewer.toasts.clone();
dialog.save(Some(&viewer.window), gio::Cancellable::NONE, move |res| {
// An error here is the dialog being dismissed.
let Some(dest) = res.ok().and_then(|file| file.path()) else {
return;
@@ -824,26 +881,67 @@ pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: &gtk4::Window, source: &st
glib::spawn_future_local(async move {
let copied =
gio::spawn_blocking(move || std::fs::copy(&source, &dest).map(|_| dest)).await;
match copied {
let message = match copied {
Ok(Ok(dest)) => {
let saved = dest.file_name().map_or_else(
|| dest.display().to_string(),
|name| name.to_string_lossy().into_owned(),
);
// Translators: {name} is the saved file's name.
toast(&ui, &gettext_f("Saved “{name}”", &[("name", &saved)]));
gettext_f("Saved “{name}”", &[("name", &saved)])
}
Ok(Err(e)) => toast_error(&ui, &gettext("Couldn't save the copy"), &e.to_string()),
Err(_) => toast_error(
&ui,
Ok(Err(e)) => failure(&gettext("Couldn't save the copy"), &e.to_string()),
Err(_) => failure(
&gettext("Couldn't save the copy"),
&gettext("The copy was interrupted."),
),
}
};
toasts.add_toast(
adw::Toast::builder()
.title(&message)
.use_markup(false)
.build(),
);
});
});
}
/// A failed action's toast text, worded like [`toast_error`]'s.
fn failure(what: &str, detail: &str) -> String {
tracing::warn!("{what}: {detail}");
// Translators: a failed action's toast. {what} says what failed, such as
// "Couldn't rename"; {detail} is the reason, usually untranslated text
// from the mount service.
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
}
/// Put the photo on screen on the clipboard as an image, for pasting into a
/// chat or a document.
fn copy_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let Some(texture) = viewer
.picture
.paintable()
.and_downcast::<gtk4::gdk::Texture>()
else {
return;
};
viewer.window.clipboard().set_texture(&texture);
viewer.toasts.add_toast(
adw::Toast::builder()
// Translators: toast after the photo viewer put the photo on the clipboard.
.title(gettext("Image copied"))
.use_markup(false)
.build(),
);
}
/// Open the Share dialog for the photo on screen, over the lightbox.
fn share_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let uid = viewer.uid.borrow().clone();
let name = viewer.name.borrow().clone().unwrap_or_default();
share_photo(ui, uid, name, Some(viewer.window.clone()));
}
/// A symlink to the cached file `path` under its Drive `name`, in a directory
/// of its own below `base`. The cache names files by content hash, with no
/// extension; the app chooser and the app it launches both need the real name
@@ -983,16 +1081,116 @@ pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option<
}
}
/// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
/// The lightbox's title: when the photo was taken, as "Wednesday, October 7,
/// 2026, 13:42".
fn photo_title(capture_time: i64) -> String {
let title = dates::full(capture_time);
if title.is_empty() {
gettext("Unknown Date")
} else {
title
}
}
/// Set the lightbox's title (the photo's date) and the line under it: the town
/// it was taken in, when known, and its "12 of 340" counter.
fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_time: i64) {
let loaded = ui.gallery.model.n_items();
let more = ui.gallery.has_more.get();
viewer.prev.set_sensitive(index > 0);
viewer.next.set_sensitive(index + 1 < loaded || more);
viewer.title.set_label(&format_capture_time(capture_time));
viewer.title.set_label(&photo_title(capture_time));
let position = position_label(index, loaded, more, gallery_total(ui));
let place = viewer_item(ui, viewer, &viewer.uid.borrow())
.and_then(|item| item.place_id)
.and_then(pdfs_core::places::city)
.map(|city| city.name);
let line = match place {
Some(place) => {
let args = [("place", place), ("position", position.as_str())];
// Translators: the line under the photo viewer's title, such as "Berlin · 12 of 340"; {place} is a town, {position} the counter.
gettext_f("{place} · {position}", &args)
}
None => position,
};
viewer.counter.set_label(&line);
viewer
.counter
.set_label(&position_label(index, loaded, more, gallery_total(ui)));
.strip_reveal
.set_visible(loaded > 1 && !viewer.slideshow.get());
mark_in_strip(viewer, index);
}
/// Mark the photo at `index` in the strip and bring it into view.
fn mark_in_strip(viewer: &Rc<Viewer>, index: u32) {
if index >= viewer.strip_selection.n_items() {
return;
}
viewer.strip_selection.set_selected(index);
viewer
.strip
.scroll_to(index, gtk4::ListScrollFlags::NONE, None);
}
/// The strip of thumbnails along the lightbox's bottom edge: the gallery's
/// photos in its order, the one on screen marked. A click steps straight to a
/// photo; the thumbnails come through the gallery's own pipeline and cache.
fn build_strip(ui: &Rc<Ui>) -> (gtk4::ListView, gtk4::SingleSelection) {
let selection = gtk4::SingleSelection::new(Some(ui.gallery.model.clone()));
selection.set_autoselect(false);
selection.set_can_unselect(true);
let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let picture = gtk4::Picture::builder()
.content_fit(gtk4::ContentFit::Cover)
.width_request(56)
.height_request(56)
.can_shrink(true)
.build();
picture.add_css_class("viewer-strip-thumb");
// The arrow keys belong to the lightbox, not to the strip.
item.set_focusable(false);
item.set_child(Some(&picture));
});
let ui_bind = ui.clone();
factory.connect_bind(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let Some(picture) = item.child().and_downcast::<gtk4::Picture>() else {
return;
};
let Some(boxed) = item.item().and_downcast::<BoxedAnyObject>() else {
return;
};
let photo = boxed.borrow::<PhotoItem>().clone();
picture.set_paintable(gtk4::gdk::Paintable::NONE);
picture.set_tooltip_text(Some(&dates::full(photo.capture_time)));
want_thumb(&ui_bind, &photo, &picture);
schedule_thumbs(&ui_bind);
});
let ui_unbind = ui.clone();
factory.connect_unbind(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let (Some(picture), Some(boxed)) = (
item.child().and_downcast::<gtk4::Picture>(),
item.item().and_downcast::<BoxedAnyObject>(),
) else {
return;
};
let uid = boxed.borrow::<PhotoItem>().uid.clone();
ui_unbind
.gallery
.release_thumb(&uid, |waiting| waiting == &picture);
});
let list = gtk4::ListView::builder()
.model(&selection)
.factory(&factory)
.orientation(gtk4::Orientation::Horizontal)
.focusable(false)
.build();
list.add_css_class("viewer-strip");
(list, selection)
}
/// The in-app lightbox: the photo or video, edge-to-edge on a dark backdrop,
@@ -1125,22 +1323,18 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
favorite_btn.add_css_class("flat");
favorite_btn.add_css_class("viewer-action-btn");
let slideshow_btn = gtk4::ToggleButton::builder()
.icon_name("pdfs-slideshow-symbolic")
.tooltip_text(gettext("Slideshow (F5)"))
.valign(gtk4::Align::Center)
.build();
slideshow_btn.add_css_class("flat");
slideshow_btn.add_css_class("viewer-action-btn");
// Only shown for a shot stored as more than one file; `load_photo_group`
// decides that per photo.
let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
group_btn.set_visible(false);
// decides that per photo and labels it ("RAW", "Live", "2 of 3").
let group_btn = gtk4::Button::builder()
.valign(gtk4::Align::Center)
.visible(false)
.build();
group_btn.add_css_class("flat");
group_btn.add_css_class("viewer-group-pill");
let download_btn = action("document-save-symbolic", &gettext("Save a Copy…"));
let delete_btn = action("user-trash-symbolic", &gettext("Move to Trash (Delete)"));
let open_ext_btn = action("document-open-symbolic", &gettext("Open With…"));
// Everything used less often than the favorite and the details sits in
// one menu, named in words rather than in icons.
let more_btn = action("view-more-symbolic", &gettext("More"));
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
close_btn.add_css_class("viewer-close-btn");
@@ -1150,14 +1344,25 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
top_bar.append(&group_btn);
top_bar.append(&favorite_btn);
top_bar.append(&info_toggle);
top_bar.append(&slideshow_btn);
top_bar.append(&download_btn);
top_bar.append(&delete_btn);
top_bar.append(&open_ext_btn);
top_bar.append(&more_btn);
top_bar.append(&close_btn);
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
overlay.add_overlay(&top_reveal);
let (strip, strip_selection) = build_strip(ui);
let strip_scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::External)
.vscrollbar_policy(gtk4::PolicyType::Never)
.child(&strip)
.build();
strip_scroll.add_css_class("viewer-strip-bar");
let strip_reveal = fade(
strip_scroll.upcast_ref(),
gtk4::Align::Fill,
gtk4::Align::End,
);
overlay.add_overlay(&strip_reveal);
// Details panel: docked beside the image as a real surface, so opening it
// narrows the photo instead of covering part of it.
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
@@ -1245,7 +1450,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
narrow.add_setter(&info_split, "collapsed", Some(&true.to_value()));
adw_window.add_breakpoint(narrow);
let body = info_split.clone();
adw_window.set_content(Some(&body));
let toasts = adw::ToastOverlay::new();
toasts.set_child(Some(&body));
adw_window.set_content(Some(&toasts));
let viewer = Rc::new(Viewer {
window: window.clone(),
@@ -1276,12 +1483,23 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
pinch_start: Cell::new(1.0),
drag_start: Cell::new((0.0, 0.0)),
pointer: Cell::new((0.0, 0.0)),
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
chrome: vec![
top_reveal.clone(),
prev_reveal.clone(),
next_reveal.clone(),
strip_reveal.clone(),
],
strip: strip.clone(),
strip_selection: strip_selection.clone(),
strip_reveal: strip_reveal.clone(),
chrome_hover: Cell::new(false),
chrome_source: RefCell::new(None),
slideshow: slideshow_btn.clone(),
more: more_btn.clone(),
menu_open: Cell::new(false),
slideshow: Cell::new(false),
slide_source: RefCell::new(None),
slide_fullscreened: Cell::new(false),
toasts,
});
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
@@ -1307,7 +1525,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
}
});
body.add_controller(motion);
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
for revealer in [&top_reveal, &prev_reveal, &next_reveal, &strip_reveal] {
let hover = gtk4::EventControllerMotion::new();
let viewer_enter = Rc::downgrade(&viewer);
hover.connect_enter(move |_, _, _| {
@@ -1423,13 +1641,53 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
w_close.close();
});
// A click on a thumbnail selects it; step there. Every selection the
// lightbox sets itself is the photo on screen, and the model's own changes
// (a page landing, a favorite rewritten in place) keep the selected photo
// or drop it, so a selection elsewhere can only be the user's.
let ui_strip = ui.clone();
let viewer_strip = Rc::downgrade(&viewer);
strip_selection.connect_selection_changed(move |selection, _, _| {
let Some(viewer) = viewer_strip.upgrade() else {
return;
};
let picked = selection.selected();
let Some(current) = find_photo_index(&ui_strip.gallery.model, &viewer.uid.borrow()) else {
return;
};
if picked != gtk4::INVALID_LIST_POSITION && picked != current {
navigate_photo(&ui_strip, &viewer, picked as i32 - current as i32);
}
});
let viewer_items = Rc::downgrade(&viewer);
let ui_items = ui.clone();
let items_handler = ui
.gallery
.model
.connect_items_changed(move |model, _, _, _| {
let Some(viewer) = viewer_items.upgrade() else {
return;
};
if let Some(index) = find_photo_index(model, &viewer.uid.borrow()) {
viewer.strip_selection.set_selected(index);
}
viewer
.strip_reveal
.set_visible(ui_items.gallery.model.n_items() > 1 && !viewer.slideshow.get());
});
// The one strong handle on the viewer. Every handler holds a weak one: a
// strong one in a handler of a widget the viewer holds is a cycle, which
// kept every closed lightbox, its window and its full-size photo in memory
// for the life of the app.
let keep = RefCell::new(Some(viewer.clone()));
let ui_close = ui.clone();
let items_handler = RefCell::new(Some(items_handler));
window.connect_close_request(move |_| {
// The gallery's model outlives the lightbox.
if let Some(handler) = items_handler.borrow_mut().take() {
ui_close.gallery.model.disconnect(handler);
}
if let Some(viewer) = keep.borrow_mut().take() {
reveal_photo(&ui_close, &viewer.uid.borrow());
stop_video(&viewer);
@@ -1457,36 +1715,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
}
});
let ui_slides = ui.clone();
let viewer_slides = Rc::downgrade(&viewer);
slideshow_btn.connect_toggled(move |toggle| {
let Some(viewer_slides) = viewer_slides.upgrade() else {
return;
};
if toggle.is_active() {
start_slideshow(&ui_slides, &viewer_slides);
} else {
stop_slideshow(&viewer_slides);
}
});
let toggle_off = info_toggle.clone();
info_close.connect_clicked(move |_| toggle_off.set_active(false));
let ui_map = ui.clone();
let viewer_map = Rc::downgrade(&viewer);
let w_map = window.downgrade();
info_map.connect_clicked(move |_| {
let Some(w_map) = w_map.upgrade() else {
return;
};
let Some(viewer_map) = viewer_map.upgrade() else {
return;
};
let coords = *viewer_map.coords.borrow();
if let Some((lat, lon)) = coords {
w_map.close();
show_on_map(&ui_map, lat, lon);
if let Some(viewer_map) = viewer_map.upgrade() {
show_photo_on_map(&ui_map, &viewer_map);
}
});
@@ -1538,24 +1774,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
});
});
// Deleting the photo on screen closes the lightbox: what it was showing is
// gone, and leaving it open on a trashed photo invites a second Delete on
// something the user cannot see.
let ui_delete = ui.clone();
let viewer_delete = Rc::downgrade(&viewer);
let w_delete = window.downgrade();
delete_btn.connect_clicked(move |_| {
let Some(w_delete) = w_delete.upgrade() else {
return;
};
let Some(viewer_delete) = viewer_delete.upgrade() else {
return;
};
let uid = viewer_delete.uid.borrow().clone();
w_delete.close();
trash_photos(&ui_delete, vec![uid], false);
});
let ui_group = ui.clone();
let viewer_group = Rc::downgrade(&viewer);
group_btn.connect_clicked(move |_| {
@@ -1571,38 +1789,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
}
});
let ui_download = ui.clone();
let w_download = window.downgrade();
let viewer_download = Rc::downgrade(&viewer);
download_btn.connect_clicked(move |_| {
let Some(w_download) = w_download.upgrade() else {
return;
};
let Some(viewer_download) = viewer_download.upgrade() else {
return;
};
if let Some(path) = viewer_download.path.borrow().as_deref() {
let name = viewer_download.name.borrow();
save_photo_to_disk(
&ui_download,
&w_download,
path,
name.as_deref().unwrap_or("photo"),
);
}
});
let w_ext = window.downgrade();
let viewer_ext = Rc::downgrade(&viewer);
open_ext_btn.connect_clicked(move |_| {
let Some(w_ext) = w_ext.upgrade() else {
return;
};
let Some(viewer_ext) = viewer_ext.upgrade() else {
return;
};
if let Some(path) = viewer_ext.path.borrow().as_deref() {
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
let ui_more = ui.clone();
let viewer_more = Rc::downgrade(&viewer);
more_btn.connect_clicked(move |_| {
if let Some(viewer_more) = viewer_more.upgrade() {
open_viewer_menu(&ui_more, &viewer_more);
}
});
@@ -1680,6 +1871,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
return glib::Propagation::Proceed;
};
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
match key.name().as_deref() {
Some("space") if playing_video => {
@@ -1687,6 +1879,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
stream.set_playing(!stream.is_playing());
}
}
// Alt+Left is Back everywhere else in the app.
Some("Left") if alt => w_key.close(),
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
@@ -1695,13 +1889,10 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
Some("0" | "KP_0") => reset_zoom(&viewer_key),
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
Some("F5") => viewer_key
.slideshow
.set_active(!viewer_key.slideshow.is_active()),
Some("F5") => toggle_slideshow(&ui_key, &viewer_key),
// Escape ends a slideshow before it closes anything.
Some("Escape") if viewer_key.slideshow.is_active() => {
viewer_key.slideshow.set_active(false)
}
Some("Escape") if viewer_key.slideshow.get() => stop_slideshow(&viewer_key),
Some("F10" | "Menu") => open_viewer_menu(&ui_key, &viewer_key),
Some("i") => viewer_key
.info_toggle
.set_active(!viewer_key.info_toggle.is_active()),
@@ -1712,13 +1903,12 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
w_key.fullscreen();
}
}
Some("Delete" | "KP_Delete") => {
let uid = viewer_key.uid.borrow().clone();
w_key.close();
trash_photos(&ui_key, vec![uid], false);
}
Some("Delete" | "KP_Delete") => trash_shown_photo(&ui_key, &viewer_key),
Some("Escape" | "q") => w_key.close(),
Some("w") if ctrl => w_key.close(),
Some("c") if ctrl && viewer_key.path.borrow().is_some() && !playing_video => {
copy_shown_photo(&ui_key, &viewer_key)
}
_ => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
@@ -1729,6 +1919,106 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
window
}
/// The ⋮ menu: what the lightbox can do with the photo on screen besides
/// starring it and showing its details. Built when it opens, so it offers
/// only what applies to this photo.
fn open_viewer_menu(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let weak = Rc::downgrade(viewer);
let with_viewer = move |run: fn(&Rc<Ui>, &Rc<Viewer>), ui: &Rc<Ui>| {
let (ui, weak) = (ui.clone(), weak.clone());
move || {
if let Some(viewer) = weak.upgrade() {
run(&ui, &viewer);
}
}
};
let mut menu = ActionMenu::new();
menu.item(&gettext("Slideshow"), with_viewer(toggle_slideshow, ui));
menu.section();
// These need the full-size file, which may still be downloading.
if viewer.path.borrow().is_some() {
let still = viewer.media.visible_child_name().as_deref() == Some("photo");
if still {
// Translators: menu item that puts the photo on the clipboard as an image.
menu.item(&gettext("Copy Image"), with_viewer(copy_shown_photo, ui));
}
menu.item(&gettext("Save a Copy…"), with_viewer(save_shown_photo, ui));
menu.item(&gettext("Open With…"), with_viewer(open_shown_photo, ui));
}
if photo_shareable(ui) {
menu.item(&gettext("Share…"), with_viewer(share_shown_photo, ui));
}
// A RAW is not on the timeline, and an album holds the shot it belongs to.
if find_photo_index(&ui.gallery.model, &viewer.uid.borrow()).is_some() {
menu.item(
&gettext("Add to Album…"),
with_viewer(add_shown_photo_to_album, ui),
);
}
if viewer.coords.borrow().is_some() {
menu.item(&gettext("Show on Map"), with_viewer(show_photo_on_map, ui));
}
menu.section();
menu.item(
&gettext("Move to Trash…"),
with_viewer(trash_shown_photo, ui),
);
let button = &viewer.more;
let popover = menu.popup_at(
button,
f64::from(button.width()) / 2.0,
f64::from(button.height()),
);
viewer.menu_open.set(true);
let weak = Rc::downgrade(viewer);
popover.connect_closed(move |_| {
if let Some(viewer) = weak.upgrade() {
viewer.menu_open.set(false);
// A slideshow started from the menu keeps the controls hidden.
if !viewer.slideshow.get() {
show_chrome(&viewer);
}
}
});
}
fn save_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if let Some(path) = viewer.path.borrow().as_deref() {
let name = viewer.name.borrow();
save_photo_to_disk(viewer, path, name.as_deref().unwrap_or("photo"));
}
}
fn open_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if let Some(path) = viewer.path.borrow().as_deref() {
open_with(&viewer.window, path, viewer.name.borrow().as_deref());
}
}
fn add_shown_photo_to_album(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let uid = viewer.uid.borrow().clone();
prompt_add_to_album(ui, vec![uid], Some(viewer.window.clone()));
}
/// Close the lightbox and show where the photo was taken.
fn show_photo_on_map(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let coords = *viewer.coords.borrow();
if let Some((lat, lon)) = coords {
viewer.window.close();
show_on_map(ui, lat, lon);
}
}
/// Ask, then move the photo on screen to the trash. Confirmed, the lightbox
/// closes first: what it was showing is gone, and leaving it open on a
/// trashed photo invites a second Delete on something the user cannot see.
fn trash_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let uid = viewer.uid.borrow().clone();
let window = viewer.window.clone();
ask_trash_photos(ui, vec![uid], Some(window.clone()), move || window.close());
}
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
/// rather than the backdrop around it. [`gtk4::ContentFit::Contain`] centres the
/// image and letterboxes the rest, so the drawn rectangle is the widget scaled
+82 -23
View File
@@ -157,10 +157,11 @@ pub(crate) struct GalleryState {
/// Photos the daemon reported as having no thumbnail at all, so a tile that
/// can never be filled isn't requested again on every scroll past it.
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
/// Tiles on screen still waiting for their thumbnail, by uid. Populated as
/// sections are bound, drained as batches land, cleared on unbind — so a
/// batch only ever paints a widget that is still showing that photo.
pub(crate) thumb_wanted: RefCell<HashMap<String, gtk4::Picture>>,
/// Pictures on screen still waiting for their thumbnail, by uid. Populated
/// as they are bound, drained as batches land, cleared on unbind — so a
/// batch only ever paints a widget that is still showing that photo. One
/// photo can be waited for twice: by its tile and by the lightbox's strip.
pub(crate) thumb_wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
/// Uids queued for the next [`Request::PhotoThumbs`] batch, and whether a
/// batch is already in flight (only one at a time, so a long scroll can't
/// stack requests on the daemon).
@@ -243,6 +244,27 @@ impl GalleryState {
pub(crate) fn in_collection(&self) -> bool {
self.album.borrow().is_some() || self.place.borrow().is_some()
}
/// Have `picture` painted with the thumbnail of `uid` once it lands.
pub(crate) fn await_thumb(&self, uid: &str, picture: &gtk4::Picture) {
let mut wanted = self.thumb_wanted.borrow_mut();
let waiting = wanted.entry(uid.to_string()).or_default();
if !waiting.contains(picture) {
waiting.push(picture.clone());
}
}
/// Stop waiting for `uid` in the pictures `drop` picks, because they show
/// another photo now.
pub(crate) fn release_thumb(&self, uid: &str, drop: impl Fn(&gtk4::Picture) -> bool) {
let mut wanted = self.thumb_wanted.borrow_mut();
if let Some(waiting) = wanted.get_mut(uid) {
waiting.retain(|picture| !drop(picture));
if waiting.is_empty() {
wanted.remove(uid);
}
}
}
}
/// How many photos to pull per [`Request::PhotosTimeline`] page.
@@ -1314,14 +1336,10 @@ pub(crate) fn wire_gallery(
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
&& let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::<GalleryRow>()
{
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
for tile in tiles {
if wanted
.get(&tile.photo.uid)
.is_some_and(|picture| picture.is_ancestor(&row_box))
{
wanted.remove(&tile.photo.uid);
}
ui_unbind
.gallery
.release_thumb(&tile.photo.uid, |picture| picture.is_ancestor(&row_box));
}
}
});
@@ -1441,7 +1459,7 @@ pub(crate) fn wire_gallery(
let ui_album = ui.clone();
selection.album.connect_clicked(move |_| {
let uids: Vec<String> = ui_album.gallery.selected.borrow().iter().cloned().collect();
prompt_add_to_album(&ui_album, uids);
prompt_add_to_album(&ui_album, uids, None);
});
let ui_remove = ui.clone();
selection.remove.connect_clicked(move |_| {
@@ -2455,10 +2473,8 @@ impl TileWidget {
let Some(photo) = self.photo.borrow_mut().take() else {
return;
};
let mut wanted = ui.gallery.thumb_wanted.borrow_mut();
if wanted.get(&photo.uid) == Some(&self.picture) {
wanted.remove(&photo.uid);
}
ui.gallery
.release_thumb(&photo.uid, |picture| picture == &self.picture);
}
}
@@ -2764,6 +2780,18 @@ pub(crate) fn delete_selected(ui: &Rc<Ui>) {
/// Confirm, then move the photos behind `uids` to Proton trash.
pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
ask_trash_photos(ui, uids, None, || {});
}
/// [`confirm_trash_photos`] over `parent` rather than the main window, for
/// the photo viewer, which covers it. `before` runs once the user confirms,
/// before the photos go.
pub(crate) fn ask_trash_photos(
ui: &Rc<Ui>,
uids: Vec<String>,
parent: Option<gtk4::Window>,
before: impl Fn() + 'static,
) {
if uids.is_empty() {
return;
}
@@ -2803,10 +2831,11 @@ pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let win = ui_window(ui);
let win = parent.or_else(|| ui_window(ui));
let ui = ui.clone();
dialog.connect_response(None, move |_, response| {
if response == "trash" {
before();
trash_photos(&ui, uids.clone(), false);
}
});
@@ -2951,10 +2980,7 @@ pub(crate) fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: &gtk4::Picture
return;
}
ui.gallery
.thumb_wanted
.borrow_mut()
.insert(photo.uid.clone(), picture.clone());
ui.gallery.await_thumb(&photo.uid, picture);
match photo.thumb_path.as_deref() {
Some(path) => {
@@ -3173,7 +3199,8 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
}
};
ui.store_texture(&uid, texture.clone());
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
let waiting = ui.gallery.thumb_wanted.borrow_mut().remove(&uid);
for picture in waiting.into_iter().flatten() {
picture.set_paintable(Some(&texture));
}
}
@@ -3979,8 +4006,18 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: &gtk4::Button, x: f64
menu.section();
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
menu.item(&gettext("Add to Album…"), move || {
prompt_add_to_album(&ui_c, vec![uid.clone()])
prompt_add_to_album(&ui_c, vec![uid.clone()], None)
});
if photo_shareable(ui) {
let (ui_c, uid, name) = (
ui.clone(),
photo.uid.clone(),
photo.name.clone().unwrap_or_default(),
);
menu.item(&gettext("Share…"), move || {
share_photo(&ui_c, uid.clone(), name.clone(), None)
});
}
let open_album = ui.gallery.album.borrow().clone();
if let Some(album) = open_album.filter(|album| !album.shared) {
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
@@ -4032,6 +4069,27 @@ fn set_photo_favorite(ui: &Rc<Ui>, uid: String, favorite: bool) {
});
}
/// Whether the photos on show are the user's own to share: everything but an
/// album someone else shared, whose photos live on that person's volume.
pub(crate) fn photo_shareable(ui: &Ui) -> bool {
!ui.gallery
.album
.borrow()
.as_ref()
.is_some_and(|album| album.shared)
}
/// Open the Share dialog for one photo, addressed by uid: the photos volume
/// has no path in any mount. Over `parent` when the lightbox is open.
pub(crate) fn share_photo(ui: &Rc<Ui>, uid: String, name: String, parent: Option<gtk4::Window>) {
let entry = DirEntry {
uid,
name,
..DirEntry::default()
};
open_share_dialog_over(ui, &entry, parent);
}
/// Whether the photo grid is the view the Photos page shows: an album or a
/// place, or the timeline itself. Under Albums or Places it is out of sight.
pub(crate) fn photo_grid_shown(ui: &Ui) -> bool {
@@ -4490,6 +4548,7 @@ mod tests {
favorite: false,
group_size: 1,
has_raw: false,
place_id: None,
}
};
// Newest first, the way the daemon sends them.
+12 -2
View File
@@ -267,7 +267,12 @@ fn want_map_thumb(ui: &Rc<Ui>, photo: &PhotoItem) {
}
// A town's card may be waiting for the same photo: share its picture
// rather than take its place in the queue.
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
let waiting = ui
.gallery
.thumb_wanted
.borrow()
.get(&photo.uid)
.and_then(|waiting| waiting.first().cloned());
let picture = waiting.unwrap_or_else(|| {
let picture = gtk4::Picture::new();
want_thumb(ui, photo, &picture);
@@ -362,7 +367,12 @@ fn show_spot_photos(ui: &Rc<Ui>, photos: Vec<(PlaceInfo, PhotoItem)>, (x, y): (f
/// Show photo's thumbnail in `picture`, sharing one another widget is
/// already waiting for.
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: &gtk4::Picture) {
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
let waiting = ui
.gallery
.thumb_wanted
.borrow()
.get(&photo.uid)
.and_then(|waiting| waiting.first().cloned());
match waiting {
Some(waiting) => {
waiting
+9 -2
View File
@@ -101,8 +101,14 @@ impl ActionMenu {
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
/// popover removes itself once dismissed; it waits for an idle turn, so the
/// item that closed it still finds its action.
pub(crate) fn popup_at(self, anchor: &impl IsA<gtk4::Widget>, x: f64, y: f64) {
/// item that closed it still finds its action. Returned for a caller that
/// has to know when it closes.
pub(crate) fn popup_at(
self,
anchor: &impl IsA<gtk4::Widget>,
x: f64,
y: f64,
) -> gtk4::PopoverMenu {
let (menu, group) = self.finish();
let anchor = anchor.as_ref();
let host = popover_host(anchor);
@@ -122,6 +128,7 @@ impl ActionMenu {
glib::idle_add_local_once(move || p.unparent());
});
popover.popup();
popover
}
/// A flat ⋮ button that opens this menu.
@@ -334,6 +334,13 @@ pub(crate) struct ShareDialog {
/// Open the per-node Share dialog: invite Proton/external users, manage who has
/// access and their roles, and create/copy/remove a public link.
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
open_share_dialog_over(ui, entry, None);
}
/// [`open_share_dialog`] over `parent` rather than the main window. The photo
/// viewer is a window of its own, and a dialog on the main window would open
/// behind it.
pub(crate) fn open_share_dialog_over(ui: &Rc<Ui>, entry: &DirEntry, parent: Option<gtk4::Window>) {
if !*ui.mounted.borrow() {
toast_error(
ui,
@@ -491,7 +498,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
}
});
share_dialog_reload(&state);
dialog.present(ui_window(ui).as_ref());
dialog.present(parent.or_else(|| ui_window(ui)).as_ref());
}
/// Re-fetch the node's share and rebuild the people + public-link sections.
+29
View File
@@ -109,6 +109,7 @@ a live account since.
| Entry | Title |
|---|---|
| [B284](#b284--a-keyring-entry-reported-missing-discards-refreshed-session-tokens) | A keyring entry reported missing discards refreshed session tokens |
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
@@ -386,6 +387,34 @@ a live account since.
Newest first.
### B284 — A keyring entry reported missing discards refreshed session tokens
**Status:** Fixed (unverified)
**Found:** 2026-11-14, investigating [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78)
after the reporter's oo7 0.7.0 retest ruled out the original persistence fix as a sufficient
explanation. See [the investigation and isolated reproducer](ISSUE-78.md).
**Where:** `crates/pdfs-core/src/auth.rs`, `TokenSink::flush`
**Repro:** Refresh a session while the keyring lookup reports `NoEntry` for its existing blob.
oo7 0.7.0 does this for a locked collection (upstream #614), reproduced with fake tokens on a
private D-Bus connection. pdfs dropped the pending rotation and returned success, leaving the
spent refresh token in the saved blob without triggering B61's retry or kernel recovery paths.
The running session can still work, but restarting it exposes the stale saved credentials.
**Fix:** Propagate the missing-session error so the rotation stays pending and is retried.
Persistence still requires the matching existing blob; a retry never recreates a deleted
session or overwrites a new login. This does not make a locked keyring writable, and pending
tokens still need to reach it before power-off to survive a cold boot.
**Verified:** The two regression tests
`auth::tests::a_temporarily_missing_session_does_not_discard_a_rotation` and
`auth::tests::retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`
failed before the fix and pass afterward. The oo7 fixture confirms its locked lookup behavior
and repeated binary `SetSecret` persistence. The reporter's complete Fedora cold-boot sequence
has not been reproduced; issue #78's precise trigger remains unconfirmed.
---
### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
**Status:** Fixed, not verified live
+17
View File
@@ -79,6 +79,15 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
the grid to the photo it was showing. Alt+Left and Backspace go back a level in Shared with me
and Computers and leave an open album or place in Photos, as Escape does too.
- **Share photos, and more in the photo viewer.**
- **Share…** in a photo's menu and in the viewer's ⋮ menu opens the usual Share dialog: invite
people or make a public link. It is not offered for a photo in an album someone else shared.
- **Copy Image** (Ctrl+C) puts the photo on screen on the clipboard.
- A strip of thumbnails along the bottom of the viewer shows the photos around the one on
screen; click one to jump to it. It fades with the other controls and stays hidden during a
slideshow.
- The line under the date names the town the photo was taken in, when it is known.
### Changed
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
@@ -136,6 +145,14 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
- **A search in My files stays.** It was replaced by the folder's listing whenever files
changed, on refresh and on coming back to the page, while the query stayed in the box.
- **The photo viewer's buttons are readable on any photo.** They were white on a see-through
white, and vanished over a bright photo. The bar now keeps only the favorite star, the details
and a menu, which names Slideshow, Save a Copy, Open With, Add to Album, Show on Map and Move to
Trash in words where eight icons stood. A shot stored as more than one file shows a "RAW" or
"Live" switch, or "2 of 3" for a burst, where an unlabelled icon was. The title reads
"Wednesday, October 7, 2026, 13:42" rather than a timestamp. Delete asks first, as it does in
the grid, and Alt+Left closes the viewer.
- **A date jump in Photos stops when you look elsewhere.** A jump still loading the timeline
kept going after another filter, album or place was opened.
+91
View File
@@ -0,0 +1,91 @@
# Issue #78: session loss on Fedora 45
Investigation on 2026-11-14 of [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78),
using its report and comments through `gh`, pdfs 3.7.0/current source, and the oo7 0.7.0 tag.
## What is established
The reporter still saw the problem with oo7 0.7.0 on November 7. The original
[SetSecret persistence fix](https://github.com/linux-credentials/oo7/commit/7fb11dd111c4be8fec01996e3caa31c44fa962b3)
is present in that release. In an isolated test, three successive binary `SetSecret` updates
survived reopening the keyring file. Repeating the original persistence explanation is therefore
insufficient.
A different, reproducible failure exists at the boundary between oo7 and pdfs:
1. oo7 0.7.0's `Collection::search_inner_items` returns an empty list for a locked collection.
`SearchItems` consequently returns **both** the unlocked and locked lists empty even when the
matching item is on disk. The isolated test reproduces this with pdfs's exact lookup attributes.
This is upstream [oo7 #614](https://github.com/linux-credentials/oo7/issues/614).
2. `dbus-secret-service-keyring-store` 1.0.1 maps an empty search to `keyring_core::Error::NoEntry`;
pdfs maps that to `Error::NotLoggedIn`. The client cannot offer an unlock when no locked item
is reported.
3. In pdfs 3.7.0 and the code before this change, `TokenSink::flush` treated `NotLoggedIn` as a
logout, cleared the pending rotation and returned success. It skipped the error path that
retains the new tokens in the kernel keyring and retries their persistence. The code was
unchanged between v3.7.0 and this investigation.
4. If a token refresh encounters that reply, the saved blob retains a spent refresh token. The
running process can keep working with the new tokens in memory; a restart then exposes the
stale saved credentials. A regression test failed before the fix and passes afterward.
The same unsafe assumption also applies to transient empty lookups such as the race reported in
[oo7 #652](https://github.com/linux-credentials/oo7/issues/652), fixed in 0.7.1. That issue's
replacement race was not independently reproduced here; pdfs normally updates existing items
with `SetSecret`, so it must not be assumed to encounter the same-item `CreateItem` race.
## Change and limits
`TokenSink::flush` now propagates a missing-session error instead of discarding the rotation.
The existing retry, kernel-keyring recovery and unsaved-token status paths can then run. Once
the collection is unlocked and readable, the pending rotation is saved. A genuinely deleted
session is never recreated: flushing still requires a readable blob with the matching session
ID and expected refresh token. A new login causes the old rotation to be discarded as before.
This is a confirmed pdfs defect (B284), **not a confirmed complete explanation of the reporter's
cold-boot failure**. The issue has no oo7 logs or record showing that its collection was locked
when a token rotated. A locked keyring only at startup can hide a login, but does not by itself
explain `InvalidRefreshToken`. The token-loss path requires a successful refresh while the saved
entry is unreadable.
The fix does not make a locked keyring writable. If it stays unreadable until power-off, tokens
retained in process/kernel memory still cannot survive a cold boot. No new disk credential store
or credential format is introduced.
Other upstream Fedora-upgrade issues were checked: [oo7 #651](https://github.com/linux-credentials/oo7/issues/651)
creates duplicate placeholder/migrated collections and refuses writes; [oo7 #662](https://github.com/linux-credentials/oo7/issues/662)
can resolve the default alias to a locked collection. Both have fixes in
[0.7.1](https://github.com/linux-credentials/oo7/releases/tag/0.7.1), released November 10,
after the reporter's November 7 retest. Neither can be assigned as this report's cause without
matching runtime evidence.
## Reproduce without a Proton account or desktop keyring
The oo7 fixture uses fake tokens, a temporary disk keyring and a private peer-to-peer D-Bus
connection. It asserts the observed 0.7.0 behavior, including the incorrect empty locked lookup;
passing means that behavior was reproduced. It does not simulate Proton refresh or a full boot.
From the pdfs repository, choose an unused temporary checkout path:
```sh
git clone --depth 1 --branch 0.7.0 https://github.com/linux-credentials/oo7.git /tmp/pdfs-oo7-repro
git -C /tmp/pdfs-oo7-repro apply "$PWD/docs/repro/oo7-0.7.0-issue78.patch"
cargo test --manifest-path /tmp/pdfs-oo7-repro/Cargo.toml \
-p oo7-daemon --lib --features test-util issue78 -- --nocapture
```
Observed output:
```text
oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)
oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring
test item::tests::issue78_locked_lookup_hides_saved_tokens ... ok
```
The pdfs side is covered by `cargo test -p pdfs-core auth::tests`, specifically
`a_temporarily_missing_session_does_not_discard_a_rotation` and
`retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`.
Remaining live validation: reproduce on a Fedora 45 GNOME session across logout/shutdown,
correlating oo7 lock/write errors with pdfs refresh and retry logs, and checking the running
daemon's package version rather than just the CLI version. The investigation did not use a real
Proton account, alter a desktop keyring, or post to either GitHub issue.
+53
View File
@@ -0,0 +1,53 @@
diff --git a/server/src/item/tests.rs b/server/src/item/tests.rs
index 99c25dc..2ba705d 100644
--- a/server/src/item/tests.rs
+++ b/server/src/item/tests.rs
@@ -5,6 +5,48 @@ use tokio_stream::StreamExt;
use crate::{service::PrompterType, tests::TestServiceSetup};
+// Investigation for proton-drive-linux #78. Fake tokens and a private p2p bus;
+// no desktop keyring or Proton account is used.
+#[tokio::test]
+async fn issue78_locked_lookup_hides_saved_tokens() -> Result<(), Box<dyn std::error::Error>> {
+ let setup =
+ TestServiceSetup::with_login_keyring(oo7::Secret::from("test-password-long-enough"))
+ .await?;
+ let collection = setup.default_collection().await?;
+ let attributes = [("service", "proton-drive-linux"), ("username", "session")];
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(b"fake-refresh-r1"))?;
+ collection
+ .create_item("pdfs fake session", &attributes, &secret, false, None)
+ .await?;
+
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
+ setup.lock_collection(collection).await?;
+ assert!(collection.is_locked().await?);
+
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
+ // This is the problematic reply, not the Secret Service behavior expected
+ // by keyring-store: the saved item is neither unlocked nor reported locked.
+ assert_eq!((unlocked.len(), locked.len()), (0, 0));
+ assert_eq!(setup.login_keyring().await?.items().await?.len(), 1);
+ eprintln!("oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)");
+
+ setup.unlock_collection(collection).await?;
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
+
+ // Exercise the binary SetSecret path used by pdfs's keyring-store. Reopen
+ // the disk keyring after each update, rather than trusting a memory read.
+ for refresh in [b"fake-refresh-r2", b"fake-refresh-r3", b"fake-refresh-r4"] {
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(refresh))?;
+ unlocked[0].set_secret(&secret).await?;
+ let items = setup.login_keyring().await?.items().await?;
+ assert_eq!(items[0].secret().as_bytes(), refresh);
+ }
+ eprintln!("oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring");
+ Ok(())
+}
+
#[tokio::test]
async fn label_property() -> Result<(), Box<dyn std::error::Error>> {
let setup = TestServiceSetup::plain_session(true).await?;
+379 -343
View File
File diff suppressed because it is too large. Load diff
+379 -343
View File
File diff suppressed because it is too large. Load diff
+379 -343
View File
File diff suppressed because it is too large. Load diff
+379 -343
View File
File diff suppressed because it is too large. Load diff
+379 -343
View File
File diff suppressed because it is too large. Load diff
+379 -343
View File
File diff suppressed because it is too large. Load diff
+372 -342
View File
File diff suppressed because it is too large. Load diff
+379 -343
View File
File diff suppressed because it is too large. Load diff
+379 -343
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -91,7 +91,7 @@ SECTIONS = [
]
# Too long or too internal for the wiki; links to them go to GitHub.
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "SDK-0.11.0.md"}
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "ISSUE-78.md", "SDK-0.11.0.md"}
# Files outside docs/ whose links are checked but not published.
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]