mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 17:32:00 +01:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2308021aa2 | ||
|
|
602f1c9577 | ||
|
|
9132c7ebd5 | ||
|
|
94304ccc1a | ||
|
|
ba5b6a8bf2 | ||
|
|
3295eda1f0 | ||
|
|
033c8d3d71 |
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -61,6 +61,8 @@ 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; }
|
||||
.media-player-window video { background-color: black; }
|
||||
.media-player-note { color: rgba(255, 255, 255, 0.5); }
|
||||
.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); }
|
||||
|
||||
@@ -41,6 +41,7 @@ use widgets::details::*;
|
||||
use widgets::file_list::*;
|
||||
use widgets::menu::*;
|
||||
use widgets::photo_drag::*;
|
||||
use widgets::player::*;
|
||||
use widgets::scrubber::*;
|
||||
use widgets::share_dialog::*;
|
||||
use widgets::thumbnails::*;
|
||||
@@ -1519,7 +1520,7 @@ fn debug_info(ui: &Rc<Ui>) -> String {
|
||||
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
|
||||
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
|
||||
fn show_shortcuts(window: &adw::ApplicationWindow) {
|
||||
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
|
||||
const GROUPS: [(&str, &[(&str, &str)]); 5] = [
|
||||
(
|
||||
gettext_noop("General"),
|
||||
&[
|
||||
@@ -1569,6 +1570,15 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
|
||||
("Escape <Alt>Left", gettext_noop("Close")),
|
||||
],
|
||||
),
|
||||
(
|
||||
gettext_noop("Video Player"),
|
||||
&[
|
||||
("space k", gettext_noop("Play / pause")),
|
||||
("j l", gettext_noop("Back / forward 10 seconds")),
|
||||
("m", gettext_noop("Mute")),
|
||||
("f", gettext_noop("Fullscreen")),
|
||||
],
|
||||
),
|
||||
];
|
||||
let groups: Vec<_> = GROUPS
|
||||
.iter()
|
||||
|
||||
@@ -898,27 +898,15 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
||||
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
// Open does what a double-click does: a video or a song plays in the app,
|
||||
// and Open With is the way to another player.
|
||||
menu.item(&pgettext("verb", "Open"), move || {
|
||||
// Open always means "download a local copy and hand off", even for a
|
||||
// media file — `activate_entry` would otherwise stream it.
|
||||
if is_streamable_media_entry(&entry_c) {
|
||||
download_and_open(&ui_c, &entry_c);
|
||||
} else {
|
||||
activate_entry(&ui_c, &entry_c);
|
||||
}
|
||||
activate_entry(&ui_c, &entry_c)
|
||||
});
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Open With…"), move || {
|
||||
open_entry_with(&ui_c, &entry_c)
|
||||
});
|
||||
// Play streams from the mount, no download; Open above still fetches a
|
||||
// local copy for anyone who wants one.
|
||||
if is_streamable_media_entry(entry) {
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&pgettext("verb", "Play"), move || {
|
||||
stream_entry(&ui_c, &entry_c)
|
||||
});
|
||||
}
|
||||
menu.section();
|
||||
|
||||
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||
@@ -1107,38 +1095,8 @@ pub(crate) fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Op
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// Whether an entry can be opened through FUSE instead of fully materialized.
|
||||
pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool {
|
||||
drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia
|
||||
}
|
||||
|
||||
/// Stream media straight from the mount, no download. Drive folders *are* part
|
||||
/// of the FUSE mount, so a player pointed at `<mountpoint>/<rel>` reads the file
|
||||
/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it
|
||||
/// seeks and buffers — instead of waiting for the whole file to land like
|
||||
/// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC
|
||||
/// `.mkv` starts playing in seconds.
|
||||
pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rel = entry_rel(ui, entry);
|
||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||
let abs = mounted_path(&mountpoint, &rel);
|
||||
let Some(path) = abs.to_str() else {
|
||||
toast_error(
|
||||
ui,
|
||||
&gettext("Couldn't play media"),
|
||||
&gettext("The file path isn't valid UTF-8."),
|
||||
);
|
||||
return;
|
||||
};
|
||||
toast(
|
||||
ui,
|
||||
&gettext_f("Streaming “{name}”…", &[("name", &entry.name)]),
|
||||
);
|
||||
play_external(path);
|
||||
}
|
||||
|
||||
/// Open an entry the Nautilus way: folders descend, media streams from the mount,
|
||||
/// other files download-and-open.
|
||||
/// Open an entry the Nautilus way: folders descend, media plays in the app,
|
||||
/// streamed from the mount, other files download-and-open.
|
||||
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rel = entry_rel(ui, entry);
|
||||
if entry.is_dir {
|
||||
@@ -1151,16 +1109,16 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
} else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
|
||||
// Media streams rather than downloads: that is exactly the "play it,
|
||||
// don't fetch the whole thing" behaviour this is for.
|
||||
stream_entry(ui, entry);
|
||||
open_media_player(ui, entry);
|
||||
} else {
|
||||
download_and_open(ui, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a file to the user's default application: through the mount when it is
|
||||
/// there, otherwise downloaded into the cache first. The open path behind both
|
||||
/// a plain double-click and the context menu's "Open" (including for a video,
|
||||
/// when the user wants their default application rather than the player).
|
||||
/// there, otherwise downloaded into the cache first. The open path behind a
|
||||
/// double-click and the context menu's "Open" for anything but a folder or
|
||||
/// media, and for media the mount doesn't show.
|
||||
///
|
||||
/// The mount comes first because the cache blob is a *copy* keyed by content
|
||||
/// hash — an application saving into it writes where Drive will never look, and
|
||||
|
||||
@@ -279,6 +279,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
clear_info(viewer);
|
||||
stop_video(viewer);
|
||||
viewer.media.set_visible_child_name("photo");
|
||||
sync_strip(viewer);
|
||||
reset_zoom(viewer);
|
||||
// A slideshow waits for this photo to land before it starts counting.
|
||||
disarm_slide(viewer);
|
||||
@@ -356,6 +357,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
None => {
|
||||
viewer.video.set_filename(Some(&path));
|
||||
viewer.media.set_visible_child_name("video");
|
||||
sync_strip(&viewer);
|
||||
*viewer.path.borrow_mut() = Some(path.clone());
|
||||
show_info(&viewer, size, ExifInfo::default());
|
||||
}
|
||||
@@ -396,7 +398,7 @@ fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
viewer.slide_fullscreened.set(true);
|
||||
}
|
||||
hide_chrome(viewer);
|
||||
viewer.strip_reveal.set_visible(false);
|
||||
sync_strip(viewer);
|
||||
if !viewer.loading.get() {
|
||||
arm_slide(ui, viewer);
|
||||
}
|
||||
@@ -405,9 +407,7 @@ 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);
|
||||
sync_strip(viewer);
|
||||
disarm_slide(viewer);
|
||||
if viewer.slide_fullscreened.replace(false) {
|
||||
viewer.window.unfullscreen();
|
||||
@@ -1114,10 +1114,18 @@ fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_tim
|
||||
None => position,
|
||||
};
|
||||
viewer.counter.set_label(&line);
|
||||
sync_strip(viewer);
|
||||
mark_in_strip(viewer, index);
|
||||
}
|
||||
|
||||
/// Show the strip of thumbnails when there is more than one photo to pick
|
||||
/// from. Not in a slideshow, and not over a video, where it would cover the
|
||||
/// player's seek bar.
|
||||
fn sync_strip(viewer: &Rc<Viewer>) {
|
||||
let video = viewer.media.visible_child_name().as_deref() == Some("video");
|
||||
viewer
|
||||
.strip_reveal
|
||||
.set_visible(loaded > 1 && !viewer.slideshow.get());
|
||||
mark_in_strip(viewer, index);
|
||||
.set_visible(viewer.strip_selection.n_items() > 1 && !viewer.slideshow.get() && !video);
|
||||
}
|
||||
|
||||
/// Mark the photo at `index` in the strip and bring it into view.
|
||||
@@ -1239,6 +1247,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
wire_player(&video, &window);
|
||||
let media = gtk4::Stack::new();
|
||||
media.add_named(&scroller, Some("photo"));
|
||||
media.add_named(&video, Some("video"));
|
||||
@@ -1335,6 +1344,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
// 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 fullscreen_btn = fullscreen_button(&window);
|
||||
fullscreen_btn.add_css_class("flat");
|
||||
fullscreen_btn.add_css_class("viewer-action-btn");
|
||||
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
|
||||
close_btn.add_css_class("viewer-close-btn");
|
||||
|
||||
@@ -1344,6 +1356,7 @@ 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(&fullscreen_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);
|
||||
@@ -1660,7 +1673,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
}
|
||||
});
|
||||
let viewer_items = Rc::downgrade(&viewer);
|
||||
let ui_items = ui.clone();
|
||||
let items_handler = ui
|
||||
.gallery
|
||||
.model
|
||||
@@ -1671,9 +1683,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
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());
|
||||
sync_strip(&viewer);
|
||||
});
|
||||
|
||||
// The one strong handle on the viewer. Every handler holds a weak one: a
|
||||
@@ -1873,6 +1883,12 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
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");
|
||||
if playing_video
|
||||
&& let Some(name) = key.name()
|
||||
&& player_key(&viewer_key.video, &name, state)
|
||||
{
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
match key.name().as_deref() {
|
||||
Some("space") if playing_video => {
|
||||
if let Some(stream) = viewer_key.video.media_stream() {
|
||||
@@ -1896,13 +1912,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
Some("i") => viewer_key
|
||||
.info_toggle
|
||||
.set_active(!viewer_key.info_toggle.is_active()),
|
||||
Some("f" | "F11") => {
|
||||
if w_key.is_fullscreen() {
|
||||
w_key.unfullscreen();
|
||||
} else {
|
||||
w_key.fullscreen();
|
||||
}
|
||||
}
|
||||
Some("f" | "F11") => toggle_fullscreen(&w_key),
|
||||
Some("Delete" | "KP_Delete") => trash_shown_photo(&ui_key, &viewer_key),
|
||||
Some("Escape" | "q") => w_key.close(),
|
||||
Some("w") if ctrl => w_key.close(),
|
||||
@@ -1934,6 +1944,12 @@ fn open_viewer_menu(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
};
|
||||
let mut menu = ActionMenu::new();
|
||||
menu.item(&gettext("Slideshow"), with_viewer(toggle_slideshow, ui));
|
||||
if viewer.media.visible_child_name().as_deref() == Some("video") {
|
||||
let video = viewer.video.clone();
|
||||
menu.toggle(&gettext("Repeat"), video.is_loop(), move |on| {
|
||||
video.set_loop(on)
|
||||
});
|
||||
}
|
||||
menu.section();
|
||||
// These need the full-size file, which may still be downloading.
|
||||
if viewer.path.borrow().is_some() {
|
||||
|
||||
@@ -4502,17 +4502,6 @@ pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
|
||||
service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false));
|
||||
}
|
||||
|
||||
/// Play a video with an external player. Prefers `mpv` — it sniffs the container
|
||||
/// from the bytes, so the cache's extensionless blob plays fine, and it is the
|
||||
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
|
||||
/// user's default handler when mpv isn't installed.
|
||||
pub(crate) fn play_external(path: &str) {
|
||||
match Command::new("mpv").arg(path).spawn() {
|
||||
Ok(child) => pdfs_core::opener::reap(child),
|
||||
Err(_) => open_path(path),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
|
||||
@@ -4,6 +4,7 @@ pub(crate) mod details;
|
||||
pub(crate) mod file_list;
|
||||
pub(crate) mod menu;
|
||||
pub(crate) mod photo_drag;
|
||||
pub(crate) mod player;
|
||||
pub(crate) mod scrubber;
|
||||
pub(crate) mod share_dialog;
|
||||
pub(crate) mod thumbnails;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Playing videos and music inside the app.
|
||||
//!
|
||||
//! The pieces both players share live here: the one My files opens a video or
|
||||
//! a song in ([`open_media_player`]) and the Photos lightbox. A GTK
|
||||
//! [`gtk4::Video`] brings play, pause, a seek bar and the volume; this adds
|
||||
//! fullscreen, the keys every video player answers to, a repeat switch and a
|
||||
//! volume that carries over from one video to the next.
|
||||
|
||||
use crate::activation::{mounted_path, present_within};
|
||||
use crate::*;
|
||||
|
||||
/// How far `J` and `L` (or Shift+←/→) jump.
|
||||
const SEEK_LONG: i64 = 10;
|
||||
|
||||
/// How far ← and → jump in the My files player, where they don't move to
|
||||
/// another photo.
|
||||
const SEEK_SHORT: i64 = 5;
|
||||
|
||||
/// How much ↑ and ↓ change the volume in the My files player.
|
||||
const VOLUME_STEP: f64 = 0.1;
|
||||
|
||||
thread_local! {
|
||||
/// The volume and mute the user last set, applied to the next stream so
|
||||
/// muting one video doesn't leave the next one blaring. Kept for the run
|
||||
/// of the app only.
|
||||
static LEVEL: Cell<Option<(f64, bool)>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Where a jump of `delta_secs` from `now` lands, in microseconds, as a
|
||||
/// [`gtk4::MediaStream`] counts. Kept inside the stream; a `duration` of 0
|
||||
/// means the stream doesn't know its length yet, which bounds only the start.
|
||||
pub(crate) fn seek_target(now: i64, duration: i64, delta_secs: i64) -> i64 {
|
||||
let target = now
|
||||
.saturating_add(delta_secs.saturating_mul(1_000_000))
|
||||
.max(0);
|
||||
if duration > 0 {
|
||||
target.min(duration)
|
||||
} else {
|
||||
target
|
||||
}
|
||||
}
|
||||
|
||||
/// Jump the stream `delta_secs` forward, or back when negative.
|
||||
pub(crate) fn seek_by(video: >k4::Video, delta_secs: i64) {
|
||||
if let Some(stream) = video.media_stream()
|
||||
&& stream.is_seekable()
|
||||
{
|
||||
stream.seek(seek_target(
|
||||
stream.timestamp(),
|
||||
stream.duration(),
|
||||
delta_secs,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Play a paused stream, pause a playing one.
|
||||
pub(crate) fn toggle_playing(video: >k4::Video) {
|
||||
if let Some(stream) = video.media_stream() {
|
||||
stream.set_playing(!stream.is_playing());
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_muted(video: >k4::Video) {
|
||||
if let Some(stream) = video.media_stream() {
|
||||
stream.set_muted(!stream.is_muted());
|
||||
}
|
||||
}
|
||||
|
||||
fn change_volume(video: >k4::Video, delta: f64) {
|
||||
if let Some(stream) = video.media_stream() {
|
||||
stream.set_muted(false);
|
||||
stream.set_volume((stream.volume() + delta).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter fullscreen, or leave it.
|
||||
pub(crate) fn toggle_fullscreen(window: >k4::Window) {
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
} else {
|
||||
window.fullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys both players answer to the same way: `K` plays and pauses, `J` and
|
||||
/// `L` (or Shift+← and Shift+→) jump ten seconds, `M` mutes. True when `key`
|
||||
/// was one of them.
|
||||
pub(crate) fn player_key(video: >k4::Video, key: &str, state: gtk4::gdk::ModifierType) -> bool {
|
||||
let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
|
||||
let plain = !state
|
||||
.intersects(gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::ALT_MASK);
|
||||
if !plain {
|
||||
return false;
|
||||
}
|
||||
match key {
|
||||
"k" | "K" => toggle_playing(video),
|
||||
"j" | "J" => seek_by(video, -SEEK_LONG),
|
||||
"l" | "L" => seek_by(video, SEEK_LONG),
|
||||
"Left" if shift => seek_by(video, -SEEK_LONG),
|
||||
"Right" if shift => seek_by(video, SEEK_LONG),
|
||||
"m" | "M" => toggle_muted(video),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Make `video` carry the volume over between streams and toggle `window`'s
|
||||
/// fullscreen on a double-click.
|
||||
pub(crate) fn wire_player(video: >k4::Video, window: >k4::Window) {
|
||||
video.connect_media_stream_notify(|video| {
|
||||
let Some(stream) = video.media_stream() else {
|
||||
return;
|
||||
};
|
||||
if let Some((volume, muted)) = LEVEL.get() {
|
||||
stream.set_volume(volume);
|
||||
stream.set_muted(muted);
|
||||
}
|
||||
let remember = |stream: >k4::MediaStream| {
|
||||
LEVEL.set(Some((stream.volume(), stream.is_muted())));
|
||||
};
|
||||
stream.connect_volume_notify(remember);
|
||||
stream.connect_muted_notify(remember);
|
||||
});
|
||||
|
||||
// The controls' own buttons and seek bar claim their clicks, so this only
|
||||
// sees a double-click on the picture.
|
||||
let click = gtk4::GestureClick::new();
|
||||
let window = window.downgrade();
|
||||
click.connect_pressed(move |_, n_press, _, _| {
|
||||
if n_press == 2
|
||||
&& let Some(window) = window.upgrade()
|
||||
{
|
||||
toggle_fullscreen(&window);
|
||||
}
|
||||
});
|
||||
video.add_controller(click);
|
||||
}
|
||||
|
||||
/// A button that enters and leaves `window`'s fullscreen, showing which one
|
||||
/// it will do.
|
||||
pub(crate) fn fullscreen_button(window: >k4::Window) -> gtk4::Button {
|
||||
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
|
||||
let paint = |button: >k4::Button, fullscreen: bool| {
|
||||
if fullscreen {
|
||||
button.set_icon_name("view-restore-symbolic");
|
||||
button.set_tooltip_text(Some(&gettext("Leave Fullscreen (F)")));
|
||||
} else {
|
||||
button.set_icon_name("view-fullscreen-symbolic");
|
||||
button.set_tooltip_text(Some(&gettext("Fullscreen (F)")));
|
||||
}
|
||||
};
|
||||
paint(&button, window.is_fullscreen());
|
||||
let weak = button.downgrade();
|
||||
window.connect_fullscreened_notify(move |window| {
|
||||
if let Some(button) = weak.upgrade() {
|
||||
paint(&button, window.is_fullscreen());
|
||||
}
|
||||
});
|
||||
let target = window.downgrade();
|
||||
button.connect_clicked(move |_| {
|
||||
if let Some(window) = target.upgrade() {
|
||||
toggle_fullscreen(&window);
|
||||
}
|
||||
});
|
||||
button
|
||||
}
|
||||
|
||||
/// Play a video or a song from My files in a window of its own, streamed from
|
||||
/// the mount: the player reads the blocks it needs as it plays and seeks, so a
|
||||
/// long video starts in seconds instead of after a full download. A file the
|
||||
/// mount doesn't show, or the service is down, opens in the default
|
||||
/// application instead, after a download.
|
||||
pub(crate) fn open_media_player(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rel = entry_rel(ui, entry);
|
||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||
let (ui, entry) = (ui.clone(), entry.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
match present_within(mounted_path(&mountpoint, &rel)).await {
|
||||
Some(path) => build_media_player(&ui, &entry, &path),
|
||||
None => download_and_open(&ui, &entry),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn build_media_player(ui: &Rc<Ui>, entry: &DirEntry, path: &Path) {
|
||||
let window = adw::Window::builder()
|
||||
.title(&entry.name)
|
||||
.default_width(960)
|
||||
.default_height(600)
|
||||
.build();
|
||||
if let Some(parent) = ui_window(ui) {
|
||||
window.set_transient_for(Some(&parent));
|
||||
}
|
||||
window.add_css_class("media-player-window");
|
||||
let gtk_window: gtk4::Window = window.clone().upcast();
|
||||
|
||||
let video = gtk4::Video::builder()
|
||||
.autoplay(true)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
wire_player(&video, >k_window);
|
||||
|
||||
// A song has no picture, which would leave a black window above the
|
||||
// controls: a note stands in for it.
|
||||
let note = gtk4::Image::builder()
|
||||
.icon_name("audio-x-generic-symbolic")
|
||||
.pixel_size(128)
|
||||
.halign(gtk4::Align::Center)
|
||||
.valign(gtk4::Align::Center)
|
||||
.can_target(false)
|
||||
.visible(false)
|
||||
.build();
|
||||
note.add_css_class("media-player-note");
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_child(Some(&video));
|
||||
overlay.add_overlay(¬e);
|
||||
|
||||
let failed = adw::StatusPage::builder()
|
||||
.icon_name("dialog-warning-symbolic")
|
||||
.title(gettext("Couldn't play this file"))
|
||||
.build();
|
||||
let open_with = gtk4::Button::builder()
|
||||
.label(gettext("Open With…"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
open_with.add_css_class("pill");
|
||||
open_with.add_css_class("suggested-action");
|
||||
failed.set_child(Some(&open_with));
|
||||
|
||||
let stack = gtk4::Stack::new();
|
||||
stack.add_named(&overlay, Some("player"));
|
||||
stack.add_named(&failed, Some("failed"));
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
header.pack_end(&fullscreen_button(>k_window));
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Open With…"), move || {
|
||||
open_entry_with(&ui_c, &entry_c)
|
||||
});
|
||||
let video_c = video.clone();
|
||||
// Translators: menu item of the video player that plays the file again
|
||||
// and again.
|
||||
menu.toggle(&gettext("Repeat"), false, move |on| video_c.set_loop(on));
|
||||
header.pack_end(&menu.button());
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&stack));
|
||||
// Fullscreen is the video alone, its own controls still at hand.
|
||||
window
|
||||
.bind_property("fullscreened", &toolbar, "reveal-top-bars")
|
||||
.invert_boolean()
|
||||
.sync_create()
|
||||
.build();
|
||||
window.set_content(Some(&toolbar));
|
||||
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
open_with.connect_clicked(move |_| open_entry_with(&ui_c, &entry_c));
|
||||
|
||||
let (stack_c, failed_c, note_c) = (stack.clone(), failed.clone(), note.clone());
|
||||
video.connect_media_stream_notify(move |video| {
|
||||
let Some(stream) = video.media_stream() else {
|
||||
return;
|
||||
};
|
||||
let (stack, failed) = (stack_c.clone(), failed_c.clone());
|
||||
stream.connect_error_notify(move |stream| {
|
||||
if let Some(error) = stream.error() {
|
||||
tracing::warn!("in-app player failed: {error}");
|
||||
failed.set_description(Some(&error.to_string()));
|
||||
stack.set_visible_child_name("failed");
|
||||
}
|
||||
});
|
||||
let note = note_c.clone();
|
||||
stream.connect_prepared_notify(move |stream| {
|
||||
note.set_visible(stream.is_prepared() && stream.has_audio() && !stream.has_video());
|
||||
});
|
||||
});
|
||||
video.set_filename(Some(path));
|
||||
|
||||
let keys = gtk4::EventControllerKey::new();
|
||||
let (video_k, window_k) = (video.clone(), gtk_window.downgrade());
|
||||
keys.connect_key_pressed(move |_, key, _code, state| {
|
||||
let Some(window) = window_k.upgrade() else {
|
||||
return glib::Propagation::Proceed;
|
||||
};
|
||||
let Some(name) = key.name() else {
|
||||
return glib::Propagation::Proceed;
|
||||
};
|
||||
// The open menu keeps its arrows and Enter.
|
||||
let in_menu = gtk4::prelude::GtkWindowExt::focus(&window)
|
||||
.is_some_and(|focus| focus.ancestor(gtk4::Popover::static_type()).is_some());
|
||||
if in_menu {
|
||||
return glib::Propagation::Proceed;
|
||||
}
|
||||
if player_key(&video_k, &name, state) {
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||
match name.as_str() {
|
||||
"space" => toggle_playing(&video_k),
|
||||
"Left" => seek_by(&video_k, -SEEK_SHORT),
|
||||
"Right" => seek_by(&video_k, SEEK_SHORT),
|
||||
"Up" => change_volume(&video_k, VOLUME_STEP),
|
||||
"Down" => change_volume(&video_k, -VOLUME_STEP),
|
||||
"f" | "F11" => toggle_fullscreen(&window),
|
||||
// Escape leaves fullscreen before it closes anything.
|
||||
"Escape" if window.is_fullscreen() => window.unfullscreen(),
|
||||
"Escape" | "q" => window.close(),
|
||||
"w" if ctrl => window.close(),
|
||||
_ => return glib::Propagation::Proceed,
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
// Capture, so the arrows and Space reach the player rather than moving the
|
||||
// focus between its controls.
|
||||
keys.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||
window.add_controller(keys);
|
||||
|
||||
// Stop the sound with the window, not when the window is finally freed.
|
||||
let video_c = video.clone();
|
||||
window.connect_close_request(move |_| {
|
||||
video_c.set_media_stream(gtk4::MediaStream::NONE);
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
window.present();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SEC: i64 = 1_000_000;
|
||||
|
||||
#[test]
|
||||
fn a_jump_stays_inside_the_video() {
|
||||
assert_eq!(seek_target(30 * SEC, 60 * SEC, 10), 40 * SEC);
|
||||
assert_eq!(seek_target(30 * SEC, 60 * SEC, -10), 20 * SEC);
|
||||
assert_eq!(seek_target(5 * SEC, 60 * SEC, -10), 0);
|
||||
assert_eq!(seek_target(55 * SEC, 60 * SEC, 10), 60 * SEC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jump_forward_in_a_stream_of_unknown_length_is_not_held_back() {
|
||||
assert_eq!(seek_target(30 * SEC, 0, 10), 40 * SEC);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const STOCK: &[&str] = &[
|
||||
// GTK's built-in icons
|
||||
"info-outline-symbolic",
|
||||
// Adwaita
|
||||
"audio-x-generic-symbolic",
|
||||
"checkbox-checked-symbolic",
|
||||
"checkbox-symbolic",
|
||||
"computer-symbolic",
|
||||
@@ -68,12 +69,14 @@ const STOCK: &[&str] = &[
|
||||
"text-x-generic-symbolic",
|
||||
"user-trash-symbolic",
|
||||
"video-x-generic-symbolic",
|
||||
"view-fullscreen-symbolic",
|
||||
"view-grid-symbolic",
|
||||
"view-list-symbolic",
|
||||
"view-more-symbolic",
|
||||
"view-paged-symbolic",
|
||||
"view-pin-symbolic",
|
||||
"view-refresh-symbolic",
|
||||
"view-restore-symbolic",
|
||||
"window-close-symbolic",
|
||||
"zoom-fit-best-symbolic",
|
||||
"zoom-in-symbolic",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -95,6 +95,15 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
|
||||
- **Upload progress in Photos.** Uploading several photos at once shows "Uploading 3 of 12
|
||||
items…" with a progress bar above the timeline, instead of a single toast at the start.
|
||||
|
||||
- **Videos and music play in the app.** Opening a video or a song in My files, by a double-click
|
||||
or **Open**, plays it in a player window, streamed from the mount like before. **Open With…**
|
||||
still hands it to another player, and the separate **Play** item is gone. When the file can't
|
||||
be played (a codec GStreamer lacks), the window says so and offers **Open With…**. Both this
|
||||
player and the one in Photos gained a fullscreen button, fullscreen on a double-click,
|
||||
**Repeat** in the ⋮ menu, `K` to play and pause, `J` and `L` (or Shift+←/→) to jump
|
||||
10 seconds, and `M` to mute. The volume carries over from one video to the next, and the
|
||||
thumbnail strip in Photos stays off a video, where it covered the seek bar.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
|
||||
@@ -103,6 +112,10 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Photos zoomed all the way out shows the whole timeline.** The first 200 photos fit on one
|
||||
screen there, and with nothing to scroll no more were loaded. More now load whenever the photos
|
||||
don't fill the window, also after a zoom or a resize.
|
||||
|
||||
- **My files comes back after the service was killed for hanging** (B279). A killed service left
|
||||
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
|
||||
forever without opening anything, until the mounts were cleared by hand. The service now clears
|
||||
|
||||
@@ -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.
|
||||
@@ -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?;
|
||||
@@ -24,6 +24,7 @@ crates/pdfs-gui/src/app/widgets/compat.rs
|
||||
crates/pdfs-gui/src/app/widgets/details.rs
|
||||
crates/pdfs-gui/src/app/widgets/file_list.rs
|
||||
crates/pdfs-gui/src/app/widgets/menu.rs
|
||||
crates/pdfs-gui/src/app/widgets/player.rs
|
||||
crates/pdfs-gui/src/app/widgets/share_dialog.rs
|
||||
crates/pdfs-gui/src/app/widgets/thumbnails.rs
|
||||
crates/pdfs-gui/src/app/widgets/versions_dialog.rs
|
||||
|
||||
+395
-362
File diff suppressed because it is too large.
Load diff
+385
-362
File diff suppressed because it is too large.
Load diff
+395
-362
File diff suppressed because it is too large.
Load diff
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user