Compare commits

..
Author SHA1 Message Date
nvrl f2a9a0f64b feat(gui): Ctrl+scroll changes the icon size in My files
Scrolling with Ctrl held over the icon grid steps the icon size, as it
does in Photos. Touchpad deltas add up to a whole notch before each
step, so a small swipe does not jump through every size.
2026-11-14 02:49:41 +01:00
nvrl e571f22d5d feat(gui,core): larger icon sizes in My files
The icon grid's size slider stops at 256 px instead of 144 px, which is
big enough to tell photos apart by their thumbnail. Thumbnails are
512 px, so they stay sharp at that size on a 2x screen. Names may wrap
wider on the larger tiles.

Ctrl+plus, Ctrl+minus and Ctrl+0 step and reset the size while the grid
is shown, as they do in Photos. The size is saved in the config as
files_view.icon_size once the slider rests; configs without it keep the
72 px default.
2026-11-14 02:48:29 +01:00
nvrl 1c50797772 feat(gui,core): modern Photos layout
Lay the timeline out in justified rows. Each photo keeps its own shape,
clamped between 1:2 and 5:2, and every row but a section's last spans
the width to the pixel. "Square Tiles" in the page menu restores the
even grid, and the Year zoom stays square. The zoom level and the
tile shape persist in a new `photos_view` config section.

Move Timeline/Albums/Places into the header as an icon-and-label
switch that shows only icons in a narrow window. The filters take a
single row whose kind tabs scroll sideways.

Selection mode follows GNOME apps:
- the header shows "N selected" and Cancel;
- an ActionBar along the bottom offers Favorite (or unfavorite), Add
  to Album, Remove from Album, Save Copies and Move to Trash;
- each section heading gets a check that selects the whole section.
One function, sync_header, decides the header for the page, an open
album or place, and selection mode.

The heading of the section at the top of the grid floats over the
grid. Favorites get a star badge, and video placeholders a video
glyph. New bundled icons: pdfs-photos, pdfs-album and pdfs-slideshow.

Upload takes several photos and videos at once, and files dropped on
the timeline upload too. The media type comes from the MIME database,
except that a recording-sized .ts counts as video.

The viewer's details panel moves into an OverlaySplitView that lies
over the photo below 800sp.
2026-11-14 02:40:38 +01:00
nvrl 0183f17435 feat(gui,fuse,core): video thumbnails in file views
My files, Shared, Trash and the launcher now ask the daemon for video
thumbnails, not only image ones. Grid tiles with a video thumbnail show
a small play badge.

A `.ts` file is a video from 1 MiB up and a TypeScript source below
that. One rule in pdfs-core (`is_video_file_name`) decides it for the
file icon, the thumbnail gate, the launcher icon and preview, and the
opener's `@media` / `@text` classes. The daemon also checks the MPEG-TS
sync byte before it runs ffmpeg, so a large TypeScript bundle gets no
retries.

A video thumbnail no longer downloads the whole file into memory (B283):
- the server thumbnail is tried first, at no content cost;
- files up to 24 MiB come whole;
- larger ones bring a 16 MiB head, plus the MP4/MOV `moov` box when it
  lies past the head, into a sparse staging file that ffmpeg reads.
The 256 MiB cap is gone. Without ffmpeg, a video answers "no thumbnail"
at once. The folder thumbnail build includes videos.
2026-11-14 02:17:18 +01:00
nvrl b49631e18f Merge pull request #82 from narrrl/feat/shell-completions
feat(cli,core): shell completions with live values from the service
2026-11-12 11:48:12 +01:00
nvrl f25c2459a0 feat(cli,core): shell completions with live values from the service
`pdfs completions <shell>` prints a completion script for bash, zsh, fish,
elvish or PowerShell, built on clap_complete's dynamic engine: on Tab the
shell calls `pdfs` back, which completes commands and options and also the
values the running service knows, each with a description: paths in My
files, synced-folder and queue ids, computers, trash, albums, items shared
with me, invitations, bookmarks, conflict copies, and a file's versions or
a share's members and public link once its path is typed.

A completer that gets no answer from the service within two seconds offers
nothing rather than hanging the prompt. Listings that ask Proton are kept
for 30 s in $XDG_RUNTIME_DIR (0600), so repeated Tabs do not wait again.
Completion is triggered by PDFS_COMPLETE rather than clap's generic
COMPLETE, because the same binary runs the daemon.

Share roles are now checked by the CLI before the request. The package
recipes install the bash, zsh and fish scripts via packaging/completions.sh.
2026-11-12 11:43:53 +01:00
nvrl ce35a83a04 Merge pull request #79 from narrrl/chore/sdk-0.11.0
build(sdk): bump to 0.11.0
2026-11-12 10:25:00 +01:00
nvrl 762d06765e Merge pull request #80 from narrrl/fix/stale-mount-watchdog-startup
fix(fuse,cli): recover the mount after a watchdog kill and bound thumbnail memory
2026-11-12 10:24:43 +01:00
nvrl a0ab243be6 Merge branch 'main' into chore/sdk-0.11.0 2026-11-09 14:36:49 +01:00
nvrl 2930b457d4 fix(docs): exclude SDK review from wiki 2026-11-05 19:18:18 +01:00
nvrl d9da0e2263 build(sdk): bump to 0.11.0 2026-11-05 19:12:21 +01:00
58 changed files with 10591 additions and 8184 deletions

No files matched your search

Generated
+10 -8
View File
@@ -3541,7 +3541,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "3.8.0" version = "3.7.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3558,10 +3558,11 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "3.8.0" version = "3.7.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
"chrono",
"dbus-secret-service-keyring-store", "dbus-secret-service-keyring-store",
"directories", "directories",
"ignore", "ignore",
@@ -3585,7 +3586,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "3.8.0" version = "3.7.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3607,7 +3608,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "3.8.0" version = "3.7.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3847,13 +3848,14 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.10.1" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8fa2a88130883010b32035e208aaa36fb275f6d7d78ed44a1069b1d33d24481" checksum = "14a22e621abbc1c61b4640728a7de969c16b66d5d983b59c638d501fda25a7b9"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
"bytes", "bytes",
"chrono",
"futures", "futures",
"getrandom 0.4.3", "getrandom 0.4.3",
"hex", "hex",
@@ -3874,9 +3876,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.10.1" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af0bf8d50fa829d1a65b09c6855b606578d02734d4089874c971d1063c23c353" checksum = "bd55a5e8b6600f3aa282ebbdf13ff366431ff9aae84d4eb20b0ef06d598919fe"
dependencies = [ dependencies = [
"aes-gcm 0.11.1", "aes-gcm 0.11.1",
"async-trait", "async-trait",
+4 -3
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "3.8.0" version = "3.7.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
@@ -15,8 +15,9 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"] categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.10.1" } proton-sdk = { version = "0.11.0" }
proton-drive-rs = { version = "0.10.1" } proton-drive-rs = { version = "0.11.0" }
chrono = { version = "0.4", default-features = false, features = ["std"] }
# Internal crates. # Internal crates.
pdfs-core = { path = "crates/pdfs-core" } pdfs-core = { path = "crates/pdfs-core" }
+1
View File
@@ -12,6 +12,7 @@ description = "Core auth/session/config for the Proton Drive Linux client"
[dependencies] [dependencies]
proton-sdk.workspace = true proton-sdk.workspace = true
proton-drive-rs.workspace = true proton-drive-rs.workspace = true
chrono.workspace = true
tokio.workspace = true tokio.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
+38
View File
@@ -163,6 +163,10 @@ pub struct AppConfig {
/// Defaulted for configs predating the field. /// Defaulted for configs predating the field.
#[serde(default)] #[serde(default)]
pub files_view: FilesView, pub files_view: FilesView,
/// How the desktop app's Photos page lays out its grid. Defaulted for
/// configs predating the field.
#[serde(default)]
pub photos_view: PhotosView,
/// The desktop app's main window size, remembered between runs. Defaulted /// The desktop app's main window size, remembered between runs. Defaulted
/// for configs predating the field. /// for configs predating the field.
#[serde(default)] #[serde(default)]
@@ -220,6 +224,10 @@ pub struct FilesView {
pub descending: bool, pub descending: bool,
/// Folders ahead of files, whatever the sort. /// Folders ahead of files, whatever the sort.
pub folders_first: bool, pub folders_first: bool,
/// The icon grid's thumbnail edge in logical pixels. `None` means the
/// app's default.
#[serde(skip_serializing_if = "Option::is_none")]
pub icon_size: Option<i32>,
} }
impl Default for FilesView { impl Default for FilesView {
@@ -229,10 +237,23 @@ impl Default for FilesView {
sort: FileSort::Name, sort: FileSort::Name,
descending: false, descending: false,
folders_first: true, folders_first: true,
icon_size: None,
} }
} }
} }
/// The Photos page's grid choices, remembered between runs.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(default)]
pub struct PhotosView {
/// The zoom level: the row height the grid aims for, in logical pixels.
/// `None` means the app's default.
pub row_height: Option<i32>,
/// Crop every photo to a square tile instead of showing it at its own
/// shape.
pub square: bool,
}
/// The desktop app's main window, as it was when it last closed. /// The desktop app's main window, as it was when it last closed.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(default)] #[serde(default)]
@@ -761,6 +782,11 @@ mod tests {
sort: FileSort::Modified, sort: FileSort::Modified,
descending: true, descending: true,
folders_first: false, folders_first: false,
icon_size: Some(192),
},
photos_view: PhotosView {
row_height: Some(240),
square: true,
}, },
window: WindowState { window: WindowState {
width: 1200, width: 1200,
@@ -792,6 +818,7 @@ mod tests {
assert_eq!(decoded.upload_limit, Some(500_000)); assert_eq!(decoded.upload_limit, Some(500_000));
assert_eq!(decoded.download_limit, None); assert_eq!(decoded.download_limit, None);
assert_eq!(decoded.files_view, config.files_view); assert_eq!(decoded.files_view, config.files_view);
assert_eq!(decoded.photos_view, config.photos_view);
assert_eq!(decoded.window, config.window); assert_eq!(decoded.window, config.window);
} }
@@ -835,6 +862,17 @@ mod tests {
let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap(); let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap();
assert_eq!(partial.sort, FileSort::Size); assert_eq!(partial.sort, FileSort::Size);
assert!(partial.folders_first); assert!(partial.folders_first);
assert_eq!(partial.icon_size, None);
}
#[test]
fn a_config_without_a_photos_view_gets_the_default() {
let decoded: AppConfig =
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
assert_eq!(decoded.photos_view, PhotosView::default());
let partial: PhotosView = serde_json::from_str(r#"{"square":true}"#).unwrap();
assert!(partial.square);
assert_eq!(partial.row_height, None);
} }
#[test] #[test]
+63
View File
@@ -1851,6 +1851,34 @@ pub fn is_thumbnail_image_name(name: &str) -> bool {
|| RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str()) || RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
} }
/// The smallest `.ts` file taken for an MPEG transport stream. `.ts` is
/// TypeScript source as often as it is a recording, and the two sit far apart
/// in size: a source file is kilobytes, while a second of broadcast video is
/// already megabytes. Drive often stores either kind as
/// `application/octet-stream`, so the size is the only signal a listing has.
pub const TS_VIDEO_MIN_BYTES: u64 = 1024 * 1024;
/// Whether a file named `name`, of `size` bytes when the caller knows it, is a
/// video. The extension decides, except for `.ts`, which counts as video only
/// from [`TS_VIDEO_MIN_BYTES`] up. A `.ts` of unknown size counts as video, as
/// [`PhotoKind::classify`] has it.
pub fn is_video_file_name(name: &str, size: Option<u64>) -> bool {
if PhotoKind::classify(Some(name), None) != PhotoKind::Video {
return false;
}
let transport_stream = name
.rsplit_once('.')
.is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("ts"));
!transport_stream || size.is_none_or(|size| size >= TS_VIDEO_MIN_BYTES)
}
/// Whether file listings show a thumbnail for a file named `name`, of `size`
/// bytes when known: an image [`is_thumbnail_image_name`] accepts, or a video,
/// whose thumbnail is a frame the daemon takes with ffmpeg.
pub fn is_thumbnail_media_name(name: &str, size: Option<u64>) -> bool {
is_thumbnail_image_name(name) || is_video_file_name(name, size)
}
/// Whether `name` is one of the concrete RAW formats handled by the exiftool /// Whether `name` is one of the concrete RAW formats handled by the exiftool
/// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose /// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose
/// preview availability is not dependable. /// preview availability is not dependable.
@@ -2675,6 +2703,41 @@ mod tests {
assert_eq!(PhotoKind::classify(Some("x.jpg"), Some("video/mp4")), Photo); assert_eq!(PhotoKind::classify(Some("x.jpg"), Some("video/mp4")), Photo);
} }
#[test]
fn a_ts_file_is_a_video_only_at_the_size_of_one() {
const GB: u64 = 1024 * 1024 * 1024;
assert!(!is_video_file_name("app.ts", Some(3 * 1024)));
assert!(!is_video_file_name(
"bundle.TS",
Some(TS_VIDEO_MIN_BYTES - 1)
));
assert!(is_video_file_name("recording.ts", Some(9 * GB)));
assert!(is_video_file_name("recording.TS", Some(TS_VIDEO_MIN_BYTES)));
assert!(is_video_file_name("recording.ts", None));
// Only `.ts` is in doubt; every other video name is one at any size.
assert!(is_video_file_name("clip.mkv", Some(10)));
assert!(is_video_file_name("clip.m2ts", Some(10)));
assert!(!is_video_file_name("notes.txt", Some(9 * GB)));
assert!(!is_video_file_name("ts", Some(9 * GB)));
}
#[test]
fn listings_thumbnail_images_and_videos() {
assert!(is_thumbnail_media_name("photo.JPG", Some(10)));
assert!(is_thumbnail_media_name("camera.nef", None));
assert!(is_thumbnail_media_name("clip.mp4", Some(10)));
assert!(is_thumbnail_media_name(
"recording.ts",
Some(64 * 1024 * 1024)
));
assert!(!is_thumbnail_media_name("app.ts", Some(2048)));
assert!(!is_thumbnail_media_name(
"song.flac",
Some(64 * 1024 * 1024)
));
assert!(!is_thumbnail_media_name("notes.txt", None));
}
/// Keep every extension accepted by the shared classifier covered. Drive /// Keep every extension accepted by the shared classifier covered. Drive
/// activation uses this classification before it knows whether opening a /// activation uses this classification before it knows whether opening a
/// file should stream through FUSE or materialise a complete cache entry; /// file should stream through FUSE or materialise a complete cache entry;
+1
View File
@@ -29,6 +29,7 @@ pub mod similar;
pub mod syncignore; pub mod syncignore;
pub mod syncroot; pub mod syncroot;
pub mod takeout; pub mod takeout;
pub mod timestamp;
pub mod tray; pub mod tray;
pub mod update; pub mod update;
+70 -7
View File
@@ -27,6 +27,7 @@ use std::os::unix::process::CommandExt;
use std::path::Path; use std::path::Path;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use crate::control::is_video_file_name;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Environment override for the terminal used by `"terminal": true` rules. /// Environment override for the terminal used by `"terminal": true` rules.
@@ -108,11 +109,12 @@ impl OpenWith {
/// a Markdown file). The rules are about *what the user opened*, so the /// a Markdown file). The rules are about *what the user opened*, so the
/// caller passes that name and the argv still gets the real path. /// caller passes that name and the argv still gets the real path.
pub fn command_for_named(&self, path: &Path, name: &str, is_dir: bool) -> Vec<String> { pub fn command_for_named(&self, path: &Path, name: &str, is_dir: bool) -> Vec<String> {
let kind = Kind::of(path, name, is_dir);
for rule in &self.rules { for rule in &self.rules {
if rule.command.is_empty() { if rule.command.is_empty() {
continue; continue;
} }
if !rule.patterns.iter().any(|p| matches(p, name, is_dir)) { if !rule.patterns.iter().any(|p| matches(p, name, kind)) {
continue; continue;
} }
let argv = expand(&rule.command, path); let argv = expand(&rule.command, path);
@@ -325,16 +327,46 @@ fn expand_env(token: &str) -> Option<String> {
value.filter(|v| !v.is_empty()) value.filter(|v| !v.is_empty())
} }
/// What is being opened, as far as the `@class` patterns care.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
Dir,
Video,
File,
}
impl Kind {
/// `.ts` names TypeScript and MPEG transport streams alike, so for that
/// one extension the size on disk decides (see
/// [`is_video_file_name`](crate::control::is_video_file_name)). No other
/// name costs a `stat`.
fn of(path: &Path, name: &str, is_dir: bool) -> Kind {
if is_dir {
return Kind::Dir;
}
let size = extension(name)
.eq_ignore_ascii_case("ts")
.then(|| std::fs::metadata(path).ok().map(|meta| meta.len()))
.flatten();
if is_video_file_name(name, size) {
Kind::Video
} else {
Kind::File
}
}
}
/// Whether `pattern` — a glob or an `@class` — matches this name. /// Whether `pattern` — a glob or an `@class` — matches this name.
fn matches(pattern: &str, name: &str, is_dir: bool) -> bool { fn matches(pattern: &str, name: &str, kind: Kind) -> bool {
let name_lower = name.to_lowercase(); let name_lower = name.to_lowercase();
let file = kind == Kind::File;
match pattern.to_lowercase().as_str() { match pattern.to_lowercase().as_str() {
"@any" => true, "@any" => true,
"@dir" => is_dir, "@dir" => kind == Kind::Dir,
"@text" => !is_dir && class_text(&name_lower), "@text" => file && class_text(&name_lower),
"@document" => !is_dir && class_document(&name_lower), "@document" => file && class_document(&name_lower),
"@image" => !is_dir && class_image(&name_lower), "@image" => file && class_image(&name_lower),
"@media" => !is_dir && class_media(&name_lower), "@media" => kind == Kind::Video || (file && class_media(&name_lower)),
other if other.starts_with('@') => false, other if other.starts_with('@') => false,
other => glob_match(other, &name_lower), other => glob_match(other, &name_lower),
} }
@@ -533,6 +565,37 @@ mod tests {
); );
} }
#[test]
fn a_ts_file_opens_as_media_or_text_by_its_size() {
let policy = OpenWith {
terminal: Some(vec!["alacritty".into(), "-e".into()]),
default: None,
rules: vec![
rule(&["@text"], &["nvim"], true),
rule(&["@media"], &["mpv"], false),
],
};
let dir = std::env::temp_dir().join(format!("pdfs-opener-ts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let source = dir.join("blob-source");
std::fs::write(&source, "export const answer = 42;\n").unwrap();
let recording = dir.join("blob-recording");
std::fs::File::create(&recording)
.unwrap()
.set_len(crate::control::TS_VIDEO_MIN_BYTES)
.unwrap();
let source_argv = policy.command_for_named(&source, "app.ts", false);
let recording_argv = policy.command_for_named(&recording, "recording.ts", false);
// A file that is not on this machine yet has no size to go by.
let unknown_argv = policy.command_for_named(&dir.join("missing"), "recording.ts", false);
std::fs::remove_dir_all(&dir).unwrap();
assert_eq!(source_argv[..3], ["alacritty", "-e", "nvim"]);
assert_eq!(recording_argv[0], "mpv");
assert_eq!(unknown_argv[0], "mpv");
}
#[test] #[test]
fn an_empty_policy_is_xdg_open() { fn an_empty_policy_is_xdg_open() {
let policy = OpenWith::default(); let policy = OpenWith::default();
+33
View File
@@ -0,0 +1,33 @@
//! Convert the client's stored epoch seconds at the SDK boundary.
use proton_drive_rs::ModificationTime;
/// A UTC modification time, or `None` when outside the SDK's supported range.
pub fn modification_time(seconds: i64) -> Option<ModificationTime> {
chrono::DateTime::from_timestamp(seconds, 0).map(|time| time.fixed_offset())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_seconds_preserve_the_instant_including_before_1970() {
for seconds in [-1, 0, 1_709_208_000, 1_782_482_460] {
let time = modification_time(seconds).unwrap();
assert_eq!(time.timestamp(), seconds);
assert_eq!(time.offset().local_minus_utc(), 0);
assert_eq!(time.timestamp_subsec_nanos(), 0);
}
assert_eq!(
modification_time(-1).unwrap().to_rfc3339(),
"1969-12-31T23:59:59+00:00"
);
}
#[test]
fn out_of_range_seconds_omit_the_timestamp() {
assert!(modification_time(i64::MIN).is_none());
assert!(modification_time(i64::MAX).is_none());
}
}
+1 -35
View File
@@ -157,17 +157,6 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
if ours { if ours {
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write"); debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
} }
// A file kept offline keeps its copy through a change, and the
// keeper replaces it once this batch's listings are read again.
// The copy is checked against the revision's `(mtime, size)`, so
// no read takes it for a newer revision. Evicted, it was gone until
// something read the file, which then downloaded it (B282). A
// node new in, or moved into, a kept folder is fetched by checking
// that folder.
let kept = match !*is_trashed && !ours {
true => kept_by_pin(core, node_uid, parent_node_uid.as_ref()),
false => None,
};
let mut had_node = false; let mut had_node = false;
core.for_each_mount(|st, notify| { core.for_each_mount(|st, notify| {
if *is_trashed { if *is_trashed {
@@ -183,12 +172,9 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
} }
}); });
// Once, not once per mount: the content cache is per-daemon. // Once, not once per mount: the content cache is per-daemon.
if had_node && kept.is_none() { if had_node {
core.cache.evict(node_uid); core.cache.evict(node_uid);
} }
if let Some(kept) = kept {
dirty.kept.insert(kept);
}
// A create, rename or move-in shows up as a change to the parent // A create, rename or move-in shows up as a change to the parent
// listing too, which is recorded for the end of the batch rather // listing too, which is recorded for the end of the batch rather
// than acted on here — see [`DirtyParents`]. // than acted on here — see [`DirtyParents`].
@@ -219,8 +205,6 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
| DriveEvent::ScopeAccessLost { .. } | DriveEvent::ScopeAccessLost { .. }
| DriveEvent::SharedWithMeUpdated { .. } => { | DriveEvent::SharedWithMeUpdated { .. } => {
warn!("event access continuity lost; downgrading shared trees and resyncing lazily"); warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
// Changes went by unseen, kept files among them (B282).
core.recheck_all_pins();
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS)); apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
core.for_each_mount(|st, notify| { core.for_each_mount(|st, notify| {
let dirs: Vec<u64> = st.children.keys().copied().collect(); let dirs: Vec<u64> = st.children.keys().copied().collect();
@@ -312,21 +296,6 @@ struct DirtyParents {
/// Checked per mount — a mount that already lists every one of those /// Checked per mount — a mount that already lists every one of those
/// children under that parent keeps its listing. /// children under that parent keeps its listing.
ours: HashMap<NodeUid, HashSet<NodeUid>>, ours: HashMap<NodeUid, HashSet<NodeUid>>,
/// Pinned files and folders the batch changed, for the pin keeper to
/// check once the listings above are dropped (`keeper`).
kept: HashSet<NodeUid>,
}
/// What the pin keeper has to check after a remote change to `node_uid`,
/// whose parent is now `parent_uid`: the node itself when a pin keeps it, or
/// else the parent, when a pinned folder holds it and the node is new there.
fn kept_by_pin(core: &Core, node_uid: &NodeUid, parent_uid: Option<&NodeUid>) -> Option<NodeUid> {
if core.cache.is_pinned(node_uid) {
return Some(node_uid.clone());
}
parent_uid
.filter(|parent| core.cache.is_pinned(parent))
.cloned()
} }
/// Whether this mount's cached listing of `parent_uid` already accounts for /// Whether this mount's cached listing of `parent_uid` already accounts for
@@ -507,9 +476,6 @@ pub(super) async fn run_event_sync(
cursor = Some(applied); cursor = Some(applied);
} }
flush_dirty_parents(&core, &dirty); flush_dirty_parents(&core, &dirty);
for uid in &dirty.kept {
core.recheck_pinned(uid);
}
// Another device changed the tree: whatever a front-end lists may have // Another device changed the tree: whatever a front-end lists may have
// moved, including what is shared and what is in the trash. // moved, including what is shared and what is in the trash.
core.events core.events
+11 -45
View File
@@ -204,42 +204,18 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
/// B99), so resolving one needs this too. A mirror folder has no mount to run /// B99), so resolving one needs this too. A mirror folder has no mount to run
/// against: a file there is an ordinary local file. /// against: a file there is an ordinary local file.
fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> { fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
route_within(core, mountpoint, path, |path| {
format!("{path} is in a mirrored folder; change the local file there instead")
})
}
/// [`route_to_mount`] for keeping a node offline or releasing it. A file in an
/// on-demand synced folder is a different Drive node from any file of the
/// same name under the primary mount, and keeping one offline did nothing for
/// the other: a program reading the synced folder's copy downloaded it
/// (`docs/BUGS.md` B281). A mirror folder already holds every file on disk.
fn route_keep(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
route_within(core, mountpoint, path, |path| {
format!(
"{path} is in a mirrored folder, which keeps all its files on this computer already"
)
})
}
/// [`route_to_mount`], refusing a path that leaves the mount it routed to and
/// answering a path in a mirror folder, which has no mount, with `mirrored`.
fn route_within(
core: &Core,
mountpoint: &Path,
path: &str,
mirrored: impl FnOnce(&str) -> String,
) -> CoreResult<(Core, PathBuf)> {
let routed = route_to_mount(core, mountpoint, path).map_err(|e| { let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
let p = Path::new(path); let p = Path::new(path);
let in_mirror = core let mirrored = core
.db .db
.sync_folder_list() .sync_folder_list()
.unwrap_or_default() .unwrap_or_default()
.iter() .iter()
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path)); .any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
if in_mirror { if mirrored {
CoreError::invalid(mirrored(path)) CoreError::invalid(format!(
"{path} is in a mirrored folder; change the local file there instead"
))
} else { } else {
e e
} }
@@ -255,18 +231,6 @@ fn route_within(
} }
} }
/// The path the pin registry keeps for a pin made through `routed`: the
/// mount-relative path on the primary mount, as it always was, and the
/// absolute path the request named on an on-demand folder's mount, since
/// that node is not under the primary mountpoint (`docs/BUGS.md` B281).
fn pin_record(routed: &Core, rel: &Path, path: &str) -> PathBuf {
if routed.primary {
rel.to_path_buf()
} else {
Path::new(path).components().collect()
}
}
/// [`route_change`] for a request that trashes or renames the node it names. /// [`route_change`] for a request that trashes or renames the node it names.
/// A mount's root is My files or a whole synced folder. An on-demand folder's /// A mount's root is My files or a whole synced folder. An on-demand folder's
/// root has a parent on Drive, so nothing further down would stop `pdfs rm /// root has a parent on Drive, so nothing further down would stop `pdfs rm
@@ -557,8 +521,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(CtlRequest::Subscribe) => { Ok(CtlRequest::Subscribe) => {
CtlResponse::error(CoreError::invalid("a subscription is served on its own")) CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
} }
Ok(CtlRequest::Pin { path }) => match route_keep(core, mountpoint, &path) { Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
Ok((routed, rel)) => match routed.pin(&rel, &pin_record(&routed, &rel, &path)) { Ok(rel) => match core.pin(&rel) {
Ok(name) => { Ok(name) => {
core.touch_profile(); core.touch_profile();
CtlResponse::Ok { CtlResponse::Ok {
@@ -569,8 +533,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
}, },
Ok(CtlRequest::Unpin { path }) => match route_keep(core, mountpoint, &path) { Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
Ok((routed, rel)) => match routed.unpin(&rel) { Ok(rel) => match core.unpin(&rel) {
Ok(name) => { Ok(name) => {
core.touch_profile(); core.touch_profile();
CtlResponse::Ok { CtlResponse::Ok {
@@ -975,6 +939,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}); });
let metadata = proton_drive_rs::PhotoUploadMetadata { let metadata = proton_drive_rs::PhotoUploadMetadata {
capture_time, capture_time,
last_modification_time: capture_time
.and_then(pdfs_core::timestamp::modification_time),
..Default::default() ..Default::default()
}; };
let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name); let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name);
+27 -5
View File
@@ -29,7 +29,7 @@ use proton_drive_rs::proton_sdk::error::Result;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid, ShareId}; use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid, ShareId};
use proton_drive_rs::{ use proton_drive_rs::{
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
ProtonDriveClient, RevisionReader, Thumbnail, ProtonDriveClient, RevisionReader, Thumbnail, ThumbnailType,
}; };
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`]. /// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
@@ -118,6 +118,14 @@ pub(crate) trait DriveApi: Send + Sync {
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>>; async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>>;
/// The file's thumbnail of `thumbnail_type`, or `None` when its revision
/// carries none.
async fn download_thumbnail(
&self,
uid: &NodeUid,
thumbnail_type: ThumbnailType,
) -> Result<Option<Vec<u8>>>;
async fn download_file_to_dyn( async fn download_file_to_dyn(
&self, &self,
uid: &NodeUid, uid: &NodeUid,
@@ -352,7 +360,13 @@ impl DriveApi for ProtonDriveClient {
name: &str, name: &str,
last_modification_time: Option<i64>, last_modification_time: Option<i64>,
) -> Result<NodeUid> { ) -> Result<NodeUid> {
ProtonDriveClient::create_folder(self, parent_uid, name, last_modification_time).await ProtonDriveClient::create_folder(
self,
parent_uid,
name,
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
)
.await
} }
async fn upload_file( async fn upload_file(
@@ -384,7 +398,7 @@ impl DriveApi for ProtonDriveClient {
reader, reader,
intended_size, intended_size,
thumbnails, thumbnails,
last_modification_time, last_modification_time.and_then(pdfs_core::timestamp::modification_time),
aead, aead,
) )
.await .await
@@ -409,7 +423,7 @@ impl DriveApi for ProtonDriveClient {
reader, reader,
intended_size, intended_size,
thumbnails, thumbnails,
last_modification_time, last_modification_time.and_then(pdfs_core::timestamp::modification_time),
aead, aead,
) )
.await .await
@@ -429,7 +443,7 @@ impl DriveApi for ProtonDriveClient {
reader, reader,
intended_size, intended_size,
thumbnails, thumbnails,
last_modification_time, last_modification_time.and_then(pdfs_core::timestamp::modification_time),
) )
.await .await
} }
@@ -439,6 +453,14 @@ impl DriveApi for ProtonDriveClient {
Ok(Arc::new(reader)) Ok(Arc::new(reader))
} }
async fn download_thumbnail(
&self,
uid: &NodeUid,
thumbnail_type: ThumbnailType,
) -> Result<Option<Vec<u8>>> {
ProtonDriveClient::download_thumbnail(self, uid, thumbnail_type).await
}
async fn download_file_to_dyn( async fn download_file_to_dyn(
&self, &self,
uid: &NodeUid, uid: &NodeUid,
-271
View File
@@ -1,271 +0,0 @@
//! Keeping pinned files on this computer after they were pinned.
//!
//! A pin downloads a file once. Nothing downloaded it again afterwards: a
//! change from another device deleted the cached copy, a file added to a
//! pinned folder elsewhere was never fetched, and a download that failed while
//! the link was down stayed failed. Each of those left a file listed as kept
//! offline that the next read downloaded, and that could not be read at all
//! without a network (`docs/BUGS.md` B282).
//!
//! The keeper is one thread that checks pins again and downloads what is
//! missing or stale: every pin when the daemon starts and when the link comes
//! back, and the pins a batch of remote events touched once the batch is
//! applied. A remote change no longer deletes a pinned file's copy. The copy
//! is stored under the revision's `(mtime, size)`, so a read of a newer
//! revision does not take it, and the keeper replaces it once the new one is
//! downloaded.
use std::collections::HashSet;
use std::time::{Duration, Instant};
use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{debug, info, warn};
use super::{Core, is_local_uid, node_size, parse_uid};
/// How often the keeper looks for work.
const KEEPER_TICK: Duration = Duration::from_secs(1);
/// The first wait before a check that could not download everything is tried
/// again. It doubles on every failure, up to [`RETRY_MAX`].
const RETRY_FIRST: Duration = Duration::from_secs(30);
const RETRY_MAX: Duration = Duration::from_secs(30 * 60);
/// What the keeper owes a check.
#[derive(Debug, Default)]
pub(crate) struct PinWork {
/// Every pin.
all: bool,
/// The pinned files and folders named here. A folder is checked whole.
nodes: HashSet<NodeUid>,
}
impl PinWork {
fn is_empty(&self) -> bool {
!self.all && self.nodes.is_empty()
}
fn merge(&mut self, other: PinWork) {
self.all |= other.all;
self.nodes.extend(other.nodes);
}
}
/// A file a pin keeps, as the tree knows it now.
pub(crate) struct KeptFile {
pub(crate) uid: NodeUid,
pub(crate) name: String,
pub(crate) mtime: i64,
pub(crate) size: u64,
}
impl Core {
/// Have the keeper check every pin.
pub(crate) fn recheck_all_pins(&self) {
self.pin_work.lock().all = true;
}
/// Have the keeper check the pinned file or folder `uid`.
pub(crate) fn recheck_pinned(&self, uid: &NodeUid) {
self.pin_work.lock().nodes.insert(uid.clone());
}
/// The keeper's loop, until the daemon stops. Starts with a check of every
/// pin, which catches up on whatever changed while the daemon was down.
///
/// Work that failed waits out a backoff of its own. New work does not wait
/// for it, so a file that keeps failing does not hold up the next change.
pub(crate) fn run_pin_keeper(&self) {
self.recheck_all_pins();
let mut backlog = PinWork::default();
let mut backlog_due = Instant::now();
let mut retry = RETRY_FIRST;
while self.shutdown.sleep(KEEPER_TICK) {
if !self.is_online() {
continue;
}
let mut work = std::mem::take(&mut *self.pin_work.lock());
let retrying = !backlog.is_empty() && Instant::now() >= backlog_due;
if retrying {
work.merge(std::mem::take(&mut backlog));
}
if work.is_empty() {
continue;
}
let failed = self.keep_pins(&work);
if failed.is_empty() {
if retrying {
retry = RETRY_FIRST;
}
continue;
}
backlog.merge(failed);
backlog_due = Instant::now() + retry;
retry = (retry * 2).min(RETRY_MAX);
}
}
/// Download every file `work` covers whose cached copy is missing or
/// stale. Returns what could not be checked or downloaded.
fn keep_pins(&self, work: &PinWork) -> PinWork {
let mut failed = PinWork::default();
let roots: Vec<NodeUid> = if work.all {
self.cache
.list_pins()
.iter()
.filter_map(|pin| parse_uid(&pin.uid))
.chain(work.nodes.iter().cloned())
.collect()
} else {
work.nodes.iter().cloned().collect()
};
let mut seen: HashSet<u64> = HashSet::new();
let mut files: Vec<u64> = Vec::new();
for uid in roots {
if self.shutdown.is_stopping() {
return failed;
}
if !self.cache.is_pinned(&uid) {
continue;
}
let found = {
let st = self.state();
st.by_uid.get(&uid).and_then(|&ino| {
let e = st.entries.get(&ino)?;
Some((ino, e.parent, e.node.is_folder()))
})
};
let Some((ino, parent, is_folder)) = found else {
debug!(%uid, "pin keeper: pinned node is not in the tree");
continue;
};
if is_folder {
match self.pin_subtree_files(ino) {
Ok(inos) => files.extend(inos.into_iter().filter(|i| seen.insert(*i))),
Err(e) => {
warn!(%uid, error = %e, "pin keeper: listing a kept folder failed");
failed.nodes.insert(uid);
}
}
continue;
}
// An event about this file dropped its parent's listing. Listing
// it again brings the file's entry up to the revision Drive has
// now, which is what the cached copy is checked against.
if !work.all
&& parent != ino
&& let Err(e) = self.ensure_children(parent)
{
warn!(%uid, error = ?e, "pin keeper: listing a kept file's folder failed");
failed.nodes.insert(uid);
continue;
}
if seen.insert(ino) {
files.push(ino);
}
}
let stale: Vec<KeptFile> = files
.into_iter()
.filter_map(|ino| self.kept_file(ino))
.filter(|f| !self.cache.is_cached(&f.uid, f.mtime, f.size))
.collect();
if stale.is_empty() {
return failed;
}
let job = self.transfers.begin_job("Updating files kept offline");
job.set_total(stale.len() as u64);
let (mut fetched, mut failures) = (0usize, 0usize);
for file in stale {
if self.shutdown.is_stopping() {
break;
}
// Unpinned while the check ran.
if !self.cache.is_pinned(&file.uid) {
job.step();
continue;
}
job.detail(file.name.clone());
match self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size) {
Ok(()) => fetched += 1,
Err(e) => {
warn!(uid = %file.uid, error = %e, "pin keeper: download failed");
failed.nodes.insert(file.uid);
failures += 1;
}
}
job.step();
}
info!(
fetched,
failed = failures,
"pin keeper: brought files kept offline up to date"
);
failed
}
/// The file at `ino`, as a pin keeps it. `None` for a folder, a file this
/// daemon still owes an upload for, or a file whose real size cannot be
/// read.
///
/// A queued write is ahead of Drive. Downloading Drive's revision would
/// cache what the write replaces, and the drain keeps the uploaded bytes
/// of a pinned file once they land.
///
/// A listing carries the ciphertext size until its size upgrade lands
/// (B12). Stored under that size, the copy never matched the plaintext it
/// holds, so every read downloaded the file again and every check
/// downloaded it once more.
pub(crate) fn kept_file(&self, ino: u64) -> Option<KeptFile> {
let (uid, name, mtime, size, provisional) = {
let st = self.state();
let e = st.entries.get(&ino)?;
let NodeKind::File { claimed_size, .. } = &e.node.kind else {
return None;
};
(
e.uid.clone(),
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
claimed_size.is_none(),
)
};
if is_local_uid(&uid) || self.pending.lock().contains_key(&uid) {
return None;
}
let size = match provisional {
true => self.resolve_provisional_size(ino, &uid)?,
false => size,
};
Some(KeptFile {
uid,
name,
mtime,
size,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn work_that_failed_keeps_every_node_and_the_full_check() {
let mut backlog = PinWork::default();
assert!(backlog.is_empty());
let uid = parse_uid("vol~a").unwrap();
backlog.merge(PinWork {
all: false,
nodes: HashSet::from([uid.clone()]),
});
backlog.merge(PinWork {
all: true,
nodes: HashSet::from([uid.clone()]),
});
assert!(backlog.all);
assert_eq!(backlog.nodes.len(), 1);
assert!(!backlog.is_empty());
}
}
+16 -31
View File
@@ -91,7 +91,6 @@ mod drive;
use drive::{DriveApi, RevisionRead}; use drive::{DriveApi, RevisionRead};
mod events; mod events;
mod filesystem; mod filesystem;
mod keeper;
mod link; mod link;
pub use filesystem::ProtonFs; pub use filesystem::ProtonFs;
mod mount; mod mount;
@@ -590,8 +589,6 @@ struct Core {
/// Nudges the drain workers: set true and notify to have them re-examine /// Nudges the drain workers: set true and notify to have them re-examine
/// the queue instead of waiting out their backoff. /// the queue instead of waiting out their backoff.
drain_wake: Arc<(Mutex<bool>, Condvar)>, drain_wake: Arc<(Mutex<bool>, Condvar)>,
/// The pins the keeper owes a check (`keeper`).
pin_work: Arc<Mutex<keeper::PinWork>>,
/// The daemon's stop signal, shared with every background loop this `Core` /// The daemon's stop signal, shared with every background loop this `Core`
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44). /// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
shutdown: Arc<shutdown::Shutdown>, shutdown: Arc<shutdown::Shutdown>,
@@ -4273,12 +4270,7 @@ impl Core {
/// recursive pin and downloads every descendant file on a background thread, /// recursive pin and downloads every descendant file on a background thread,
/// reported as a job: a big folder far outlasts the socket read timeout. /// reported as a job: a big folder far outlasts the socket read timeout.
/// Returns a human message. /// Returns a human message.
/// fn pin(&self, rel: &Path) -> CoreResult<String> {
/// `recorded` is the path the pin registry keeps for the front ends to
/// open the pin by: `rel` itself on the primary mount, which they join to
/// the mountpoint, and the absolute local path on an on-demand folder's
/// mount, whose node is not reachable under the primary mountpoint at all.
fn pin(&self, rel: &Path, recorded: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?; let (ino, uid) = self.resolve(rel)?;
let (name, is_folder, mtime, size) = { let (name, is_folder, mtime, size) = {
let st = self.state(); let st = self.state();
@@ -4302,7 +4294,7 @@ impl Core {
// Record the recursive pin first so every descendant is eviction- // Record the recursive pin first so every descendant is eviction-
// exempt before we start filling the cache with the subtree. // exempt before we start filling the cache with the subtree.
self.cache self.cache
.add_pin(&uid, recorded, true) .add_pin(&uid, rel, true)
.map_err(|e| CoreError::from_api(&e, "pin"))?; .map_err(|e| CoreError::from_api(&e, "pin"))?;
let core = self.clone(); let core = self.clone();
let folder = name.clone(); let folder = name.clone();
@@ -4312,15 +4304,9 @@ impl Core {
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?; .map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
return Ok(format!("{name}: downloading its files in the background")); return Ok(format!("{name}: downloading its files in the background"));
} }
// Stored under a provisional size, the copy would never match the
// plaintext it holds (`docs/BUGS.md` B282).
let size = match self.kept_file(ino) {
Some(file) if file.uid == uid => file.size,
_ => size,
};
self.fetch_content(&uid, &name, mtime, size)?; self.fetch_content(&uid, &name, mtime, size)?;
self.cache self.cache
.add_pin(&uid, recorded, false) .add_pin(&uid, rel, false)
.map_err(|e| CoreError::from_api(&e, "pin"))?; .map_err(|e| CoreError::from_api(&e, "pin"))?;
Ok(name) Ok(name)
} }
@@ -4338,21 +4324,15 @@ impl Core {
Ok(files) => { Ok(files) => {
job.set_total(files.len() as u64); job.set_total(files.len() as u64);
let mut failed = 0usize; let mut failed = 0usize;
for ino in files { for (uid, file, mtime, size) in files {
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) { if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
return; return;
} }
// A file with a queued write, or no readable size, is job.detail(file.clone());
// left to the drain and the keeper (`keeper::kept_file`). if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
if let Some(file) = self.kept_file(ino) { warn!(%uid, error = %e, "pin folder: download failed");
job.detail(file.name.clone());
if let Err(e) =
self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size)
{
warn!(uid = %file.uid, error = %e, "pin folder: download failed");
failed += 1; failed += 1;
} }
}
job.step(); job.step();
} }
info!(folder = %name, failed, "pin folder: done"); info!(folder = %name, failed, "pin folder: done");
@@ -4385,9 +4365,9 @@ impl Core {
Ok(()) Ok(())
} }
/// The inode of every file in the subtree rooted at folder `ino`. /// Every file in the subtree rooted at folder `ino`.
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<u64>> { fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
let mut files: Vec<u64> = Vec::new(); let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
let mut stack = vec![ino]; let mut stack = vec![ino];
while let Some(dir) = stack.pop() { while let Some(dir) = stack.pop() {
if self.shutdown.is_stopping() { if self.shutdown.is_stopping() {
@@ -4402,7 +4382,12 @@ impl Core {
if e.node.is_folder() { if e.node.is_folder() {
stack.push(k); stack.push(k);
} else { } else {
files.push(k); files.push((
e.uid.clone(),
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
));
} }
} }
} }
-3
View File
@@ -286,9 +286,6 @@ impl Core {
warn!(error = %e, "moving backed-off ops up after a reconnect failed"); warn!(error = %e, "moving backed-off ops up after a reconnect failed");
} }
self.wake_drain(); self.wake_drain();
// Downloads for pins failed while the link was down, and the events
// that would have named the files are behind us (`docs/BUGS.md` B282).
self.recheck_all_pins();
} }
/// Wait up to `budget` for the link to come back. Returns whether it is up. /// Wait up to `budget` for the link to come back. Returns whether it is up.
-12
View File
@@ -511,7 +511,6 @@ pub(crate) fn mount_with(
landed: Arc::new(Mutex::new(HashMap::new())), landed: Arc::new(Mutex::new(HashMap::new())),
unadopted: Arc::new(Mutex::new(HashMap::new())), unadopted: Arc::new(Mutex::new(HashMap::new())),
drain_wake: Arc::new((Mutex::new(false), Condvar::new())), drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
pin_work: Arc::new(Mutex::new(Default::default())),
shutdown: Arc::new(crate::shutdown::Shutdown::default()), shutdown: Arc::new(crate::shutdown::Shutdown::default()),
upload_times: Arc::new(Mutex::new(HashMap::new())), upload_times: Arc::new(Mutex::new(HashMap::new())),
upload_cancel: Arc::new(Mutex::new(HashMap::new())), upload_cancel: Arc::new(Mutex::new(HashMap::new())),
@@ -622,17 +621,6 @@ pub(crate) fn mount_with(
keep_or_abandon(&core, control_socket, &mut workers, spawned)?; keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
} }
// Keep pinned files on this computer through changes made elsewhere, and
// fetch the ones a failed or missed download left out (`docs/BUGS.md`
// B282). Its own thread: a check lists folders and downloads files.
{
let keeper_core = core.clone();
let spawned = std::thread::Builder::new()
.name("pdfs-pin-keeper".into())
.spawn(move || keeper_core.run_pin_keeper());
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// Keep the launcher prompt's "This computer" index fresh. Its own thread: // Keep the launcher prompt's "This computer" index fresh. Its own thread:
// the walk is I/O-heavy and must never sit in front of a FUSE callback. // the walk is I/O-heavy and must never sit in front of a FUSE callback.
if matches!(host, Host::Daemon) { if matches!(host, Host::Daemon) {
+424 -64
View File
@@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet};
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs::OpenOptions; use std::fs::OpenOptions;
use std::io::{Read as _, Write as _}; use std::io::{Read as _, Write as _};
use std::os::unix::fs::OpenOptionsExt as _; use std::os::unix::fs::{FileExt as _, OpenOptionsExt as _};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@@ -20,14 +20,17 @@ use base64::Engine as _;
use pdfs_core::control::{ use pdfs_core::control::{
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo, FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name, ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name,
is_thumbnail_media_name,
}; };
use pdfs_core::db::{self, StoredPhoto}; use pdfs_core::db::{self, StoredPhoto};
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::error::Result as DriveResult;
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType}; use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use super::drive::RevisionRead;
use super::{ use super::{
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority, Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority,
node_size, now_ms, parse_uid, node_size, now_ms, parse_uid,
@@ -58,9 +61,18 @@ const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20);
static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0); static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0);
static EXIFTOOL_MISSING_WARNING: Once = Once::new(); static EXIFTOOL_MISSING_WARNING: Once = Once::new();
static FFMPEG_MISSING_WARNING: Once = Once::new(); static FFMPEG_MISSING_WARNING: Once = Once::new();
/// Largest video downloaded whole to grab one frame for its thumbnail. The /// How much of a video's start a thumbnail downloads. The first frame, and
/// download lands in memory, so a long 4K recording is left without one. /// the one a second in, sit inside it at any common bitrate. A video is never
const VIDEO_THUMB_MAX_BYTES: u64 = 256 * 1024 * 1024; /// downloaded whole for one frame: a recording can be many gigabytes.
const VIDEO_HEAD_BYTES: u64 = 16 * 1024 * 1024;
/// A video no larger than this is downloaded whole: its head and tail would
/// cover most of it anyway.
const VIDEO_WHOLE_MAX_BYTES: u64 = 24 * 1024 * 1024;
/// Largest MP4/MOV index (`moov` box) fetched from past the head. An hour of
/// video indexes in a few MiB; anything larger is not worth one frame.
const VIDEO_INDEX_MAX_BYTES: u64 = 32 * 1024 * 1024;
/// Top-level boxes walked looking for the index before giving up.
const VIDEO_BOX_WALK_MAX: usize = 32;
/// A single frame is seconds of work even for 4K; this only stops a wedged /// A single frame is seconds of work even for 4K; this only stops a wedged
/// ffmpeg from holding a thumbnail permit. /// ffmpeg from holding a thumbnail permit.
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30); const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
@@ -245,8 +257,8 @@ struct RawPreviewUnavailable;
/// the photo will never have a thumbnail, and only that verdict is persisted. /// the photo will never have a thumbnail, and only that verdict is persisted.
enum ThumbAttempt { enum ThumbAttempt {
Made(GeneratedThumb), Made(GeneratedThumb),
/// Decoded nothing — a format this build has no decoder for, or a video /// Decoded nothing: a format this build has no decoder for, a video
/// too large to fetch for one frame. Permanent. /// ffmpeg takes no frame from, or a `.ts` that is not one. Permanent.
Undecodable, Undecodable,
/// The photo couldn't be downloaded. Transient: try again next time. /// The photo couldn't be downloaded. Transient: try again next time.
Unavailable, Unavailable,
@@ -577,21 +589,6 @@ fn warn_ffmpeg_missing() {
}); });
} }
/// A thumbnail from one frame of a downloaded video, which ffmpeg reads from a
/// staging copy. See [`video_file_thumbnail`].
fn video_thumbnail(
bytes: &[u8],
name: &str,
staging_dir: &Path,
ffmpeg: &OsStr,
timeout: Duration,
) -> ScaleAttempt {
let Some(file) = RawTempFile::create_in(staging_dir, bytes, name) else {
return ScaleAttempt::Unavailable;
};
video_file_thumbnail(&file.0, ffmpeg, timeout)
}
/// A thumbnail from one frame of the video at `path`. A second in, past the /// A thumbnail from one frame of the video at `path`. A second in, past the
/// black lead-in most clips open on, and the first frame for a clip shorter than /// black lead-in most clips open on, and the first frame for a clip shorter than
/// that. ffmpeg applies the clip's rotation, so a portrait phone video comes out /// that. ffmpeg applies the clip's rotation, so a portrait phone video comes out
@@ -624,6 +621,151 @@ fn video_file_thumbnail(path: &Path, ffmpeg: &OsStr, timeout: Duration) -> Scale
ScaleAttempt::Undecodable ScaleAttempt::Undecodable
} }
/// What a thumbnail is made from.
enum Fetched {
/// The thumbnail itself, from the server.
Ready(Vec<u8>),
/// Nothing: a video with no server thumbnail, and no ffmpeg to take one.
Nothing,
/// A whole image.
Whole(Vec<u8>),
Video(VideoSample),
}
/// The parts of a video its thumbnail needs, downloaded: the start of the
/// file, and for an MP4 or MOV whose index is further in, that index.
struct VideoSample {
/// The whole file's length.
size: u64,
head: Vec<u8>,
/// The `moov` box and its offset, when it is not inside `head`.
index: Option<(u64, Vec<u8>)>,
}
impl VideoSample {
/// Whether this could be an MPEG transport stream: one opens on the 0x47
/// sync byte, and repeats it every 188-byte packet. Only asked of `.ts`
/// files, which are TypeScript source as often as they are video.
fn is_transport_stream(&self) -> bool {
self.head.first() == Some(&0x47) && self.head.get(188).is_none_or(|&byte| byte == 0x47)
}
}
/// Download what a thumbnail of the video `reader` reads needs. A file of up
/// to [`VIDEO_WHOLE_MAX_BYTES`] comes whole. A larger one brings its first
/// [`VIDEO_HEAD_BYTES`], which is all a stream format (Matroska, WebM, MPEG-TS,
/// AVI) needs for a frame, plus the index of an ISO media file (MP4, MOV, 3GP)
/// written with its index after the media data, which is how most cameras and
/// phones write it.
async fn sample_video(reader: &dyn RevisionRead) -> DriveResult<VideoSample> {
let size = reader.size();
if size <= VIDEO_WHOLE_MAX_BYTES {
let head = reader.read_at(0, size).await?;
return Ok(VideoSample {
size,
head,
index: None,
});
}
let head = reader.read_at(0, VIDEO_HEAD_BYTES).await?;
let index = match iso_media_index(reader, &head, size).await? {
Some((offset, len)) if offset.saturating_add(len) > head.len() as u64 => {
Some((offset, reader.read_at(offset, len).await?))
}
_ => None,
};
Ok(VideoSample { size, head, index })
}
/// Where the `moov` box of an ISO media file sits, as `(offset, length)`.
/// `None` for a file that is not one, or whose index is missing, oversized or
/// past [`VIDEO_BOX_WALK_MAX`] boxes. Box headers past `head` are read one at a
/// time, 16 bytes each.
async fn iso_media_index(
reader: &dyn RevisionRead,
head: &[u8],
size: u64,
) -> DriveResult<Option<(u64, u64)>> {
const FIRST_BOXES: [&[u8; 4]; 6] = [b"ftyp", b"moov", b"mdat", b"wide", b"free", b"skip"];
if !head
.get(4..8)
.is_some_and(|kind| FIRST_BOXES.iter().any(|known| kind == *known))
{
return Ok(None);
}
let mut offset = 0u64;
for _ in 0..VIDEO_BOX_WALK_MAX {
if offset.saturating_add(8) > size {
break;
}
let header = match usize::try_from(offset)
.ok()
.and_then(|start| head.get(start..start.checked_add(16)?))
{
Some(header) => header.to_vec(),
None => reader.read_at(offset, 16).await?,
};
let Some((kind, len)) = iso_box_header(&header, size - offset) else {
break;
};
if kind == *b"moov" {
return Ok((len <= VIDEO_INDEX_MAX_BYTES).then_some((offset, len)));
}
offset = offset.saturating_add(len);
}
Ok(None)
}
/// The type and length of the ISO box whose header starts `header`, of a box
/// that may run at most `left` bytes to the end of the file.
fn iso_box_header(header: &[u8], left: u64) -> Option<([u8; 4], u64)> {
let size = u32::from_be_bytes(header.get(0..4)?.try_into().ok()?);
let kind: [u8; 4] = header.get(4..8)?.try_into().ok()?;
let len = match size {
// Runs to the end of the file.
0 => left,
// The real length follows as 64 bits.
1 => u64::from_be_bytes(header.get(8..16)?.try_into().ok()?),
len => u64::from(len),
};
(8..=left).contains(&len).then_some((kind, len))
}
/// A thumbnail from one frame of a downloaded [`VideoSample`]. The sample is
/// laid out in a staging file at the offsets it came from, so ffmpeg finds an
/// index where the file's own boxes say it is. The gap between holds no data
/// and costs no disk.
fn video_sample_thumbnail(
sample: &VideoSample,
name: &str,
staging_dir: &Path,
ffmpeg: &OsStr,
timeout: Duration,
) -> ScaleAttempt {
let transport_stream_name = Path::new(name)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("ts"));
if transport_stream_name && !sample.is_transport_stream() {
return ScaleAttempt::Undecodable;
}
let Some(file) = RawTempFile::create_in(staging_dir, &sample.head, name) else {
return ScaleAttempt::Unavailable;
};
if let Some((offset, index)) = &sample.index {
let laid_out = OpenOptions::new()
.write(true)
.open(&file.0)
.and_then(|staged| {
staged.write_all_at(index, *offset)?;
staged.set_len(sample.size.max(offset + index.len() as u64))
});
if laid_out.is_err() {
return ScaleAttempt::Unavailable;
}
}
video_file_thumbnail(&file.0, ffmpeg, timeout)
}
/// The thumbnail to attach to an upload of the local file at `path`, which is /// The thumbnail to attach to an upload of the local file at `path`, which is
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and /// named `name` in Drive. Without one, Drive's own apps show a generic icon and
/// the gallery has to download the whole file to make its own. /// the gallery has to download the whole file to make its own.
@@ -1406,10 +1548,10 @@ impl Core {
.map(|item| (item.uid.clone(), item.name.clone())) .map(|item| (item.uid.clone(), item.name.clone()))
.collect(); .collect();
// Proton only stores server-generated thumbnails for nodes in Photos. // Drive makes no thumbnails itself; a file has one only when its
// These requests come exclusively from ordinary Drive, Shared and Trash // uploader attached it, as this client and Proton's apps do. The
// views, so downloading a remote thumbnail here would be a guaranteed // generation step asks for that first, and only makes one from the
// miss. Generate directly from the full image and cache the result. // file's content without it.
let mut wanted = Vec::new(); let mut wanted = Vec::new();
let mut seen = HashSet::new(); let mut seen = HashSet::new();
for (raw, uid, modified) in &parsed { for (raw, uid, modified) in &parsed {
@@ -1560,7 +1702,10 @@ impl Core {
return (uid, ThumbAttempt::Cancelled); return (uid, ThumbAttempt::Cancelled);
} }
let video = is_video_name(&name); let video = is_video_name(&name);
if video && !ffmpeg_available() { // Without ffmpeg a video can still have its uploader's
// thumbnail, but Photos has asked the server already.
let can_sample = !video || ffmpeg_available();
if !can_sample && matches!(job, ThumbJob::Photos) {
return (uid, ThumbAttempt::Unavailable); return (uid, ThumbAttempt::Unavailable);
} }
@@ -1609,21 +1754,34 @@ impl Core {
return (uid, ThumbAttempt::Cancelled); return (uid, ThumbAttempt::Cancelled);
} }
// A video is downloaded whole for one frame, so a long
// recording is not worth it — and would not fit in memory.
if video {
match core.photos().get_node(&uid).await {
Ok(Some(node)) if node_size(&node) > VIDEO_THUMB_MAX_BYTES => {
info!(%uid, size = node_size(&node), "video too large for a local thumbnail");
return (uid, ThumbAttempt::Undecodable);
}
Ok(Some(_)) => {}
Ok(None) | Err(_) => return (uid, ThumbAttempt::Unavailable),
}
}
let download_uid = uid.clone(); let download_uid = uid.clone();
let download = async move { client.download_file(&download_uid).await }; let drive = core.drive.clone();
let download = async move {
// Photos asked the server in one batch already. A
// file view's or a build's file may carry a
// thumbnail its uploader made, which costs one
// small download instead of its content.
if !matches!(job, ThumbJob::Photos)
&& let Some(bytes) = drive
.download_thumbnail(&download_uid, ThumbnailType::Thumbnail)
.await?
.filter(|bytes| !bytes.is_empty())
{
return Ok(Fetched::Ready(bytes));
}
if !can_sample {
return Ok(Fetched::Nothing);
}
if video {
let reader = drive.open_revision(&download_uid).await?;
sample_video(reader.as_ref()).await.map(Fetched::Video)
} else {
client
.download_file(&download_uid)
.await
.map(Fetched::Whole)
}
};
tokio::pin!(download); tokio::pin!(download);
let downloaded = match job { let downloaded = match job {
ThumbJob::Files(_) => loop { ThumbJob::Files(_) => loop {
@@ -1661,10 +1819,10 @@ impl Core {
let Some(downloaded) = downloaded else { let Some(downloaded) = downloaded else {
return (uid, ThumbAttempt::Cancelled); return (uid, ThumbAttempt::Cancelled);
}; };
let bytes = match downloaded { let fetched = match downloaded {
Ok(bytes) => bytes, Ok(fetched) => fetched,
Err(e) => { Err(e) => {
warn!(%uid, error = %e, "image download for thumbnail failed"); warn!(%uid, error = %e, "download for a thumbnail failed");
return (uid, ThumbAttempt::Unavailable); return (uid, ThumbAttempt::Unavailable);
} }
}; };
@@ -1674,18 +1832,20 @@ impl Core {
// Decoding + scaling a 20 MP JPEG is CPU-bound and would // Decoding + scaling a 20 MP JPEG is CPU-bound and would
// stall the runtime's worker; hand it to the blocking pool. // stall the runtime's worker; hand it to the blocking pool.
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf(); let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
let made = tokio::task::spawn_blocking(move || { let made = tokio::task::spawn_blocking(move || match fetched {
if video { Fetched::Video(sample) => video_sample_thumbnail(
video_thumbnail( &sample,
&bytes,
&name, &name,
&staging_dir, &staging_dir,
OsStr::new("ffmpeg"), OsStr::new("ffmpeg"),
FFMPEG_TIMEOUT, FFMPEG_TIMEOUT,
) ),
} else { Fetched::Whole(bytes) => scale_thumbnail(&bytes, &name, &staging_dir),
scale_thumbnail(&bytes, &name, &staging_dir) Fetched::Nothing => ScaleAttempt::Unavailable,
} Fetched::Ready(bytes) => match ratio_of(&bytes) {
Some(ratio) => ScaleAttempt::Made(GeneratedThumb { bytes, ratio }),
None => ScaleAttempt::Undecodable,
},
}) })
.await .await
.unwrap_or(ScaleAttempt::Unavailable); .unwrap_or(ScaleAttempt::Unavailable);
@@ -1869,7 +2029,7 @@ impl Core {
if seen_folders.insert(entry.uid) { if seen_folders.insert(entry.uid) {
folders.push(folder.join(entry.name)); folders.push(folder.join(entry.name));
} }
} else if is_thumbnail_image_name(&entry.name) { } else if is_thumbnail_media_name(&entry.name, Some(entry.size)) {
match parse_uid(&entry.uid) { match parse_uid(&entry.uid) {
Some(uid) if seen_images.insert(uid.clone()) => { Some(uid) if seen_images.insert(uid.clone()) => {
found_here += 1; found_here += 1;
@@ -2295,15 +2455,19 @@ mod decode_budget_tests {
mod thumb_tests { mod thumb_tests {
use super::{ use super::{
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES, RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
can_make_thumbnail, exiftool_binary, ffmpeg_available, fit_upload_thumb, is_video_name, VIDEO_HEAD_BYTES, VIDEO_WHOLE_MAX_BYTES, VideoSample, can_make_thumbnail, exiftool_binary,
ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool, similar_hashes, ffmpeg_available, fit_upload_thumb, is_video_name, ratio_of, sample_video, scale_thumbnail,
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail, scale_thumbnail_with_exiftool, similar_hashes, thumbnail_build_may_start,
upload_thumbnails_with, video_sample_thumbnail,
}; };
use crate::drive::RevisionRead;
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name}; use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
use proton_drive_rs::ThumbnailType; use proton_drive_rs::ThumbnailType;
use proton_drive_rs::proton_sdk::error::Result as DriveResult;
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt as _; use std::os::unix::fs::PermissionsExt as _;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration; use std::time::Duration;
/// A `width`×`height` JPEG, standing in for a camera photo the server never /// A `width`×`height` JPEG, standing in for a camera photo the server never
@@ -2536,6 +2700,199 @@ mod thumb_tests {
std::env::temp_dir().join(format!("pdfs-{test}-{}", std::process::id())) std::env::temp_dir().join(format!("pdfs-{test}-{}", std::process::id()))
} }
/// A sample of a video small enough to come whole.
fn whole(bytes: &[u8]) -> VideoSample {
VideoSample {
size: bytes.len() as u64,
head: bytes.to_vec(),
index: None,
}
}
fn block_on<F: std::future::Future>(future: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(future)
}
/// A Drive revision of `size` bytes, zero except for the parts written in,
/// that counts the bytes read from it. Stands for a recording far larger
/// than a test should hold in memory.
struct FakeRevision {
size: u64,
parts: Vec<(u64, Vec<u8>)>,
read: AtomicU64,
}
impl FakeRevision {
fn new(size: u64) -> Self {
Self {
size,
parts: Vec::new(),
read: AtomicU64::new(0),
}
}
fn with(mut self, offset: u64, bytes: Vec<u8>) -> Self {
self.parts.push((offset, bytes));
self
}
}
#[async_trait::async_trait]
impl RevisionRead for FakeRevision {
fn size(&self) -> u64 {
self.size
}
fn block_sizes(&self) -> &[u64] {
&[]
}
async fn read_at(&self, offset: u64, length: u64) -> DriveResult<Vec<u8>> {
let end = offset.saturating_add(length).min(self.size);
let mut out = vec![0u8; end.saturating_sub(offset) as usize];
for (start, bytes) in &self.parts {
let part_end = start + bytes.len() as u64;
let (from, to) = (offset.max(*start), end.min(part_end));
if from < to {
out[(from - offset) as usize..(to - offset) as usize]
.copy_from_slice(&bytes[(from - start) as usize..(to - start) as usize]);
}
}
self.read.fetch_add(out.len() as u64, Ordering::Relaxed);
Ok(out)
}
}
/// An ISO media box of `len` bytes, of which only the header is returned.
fn iso_box_header(kind: &[u8; 4], len: u64) -> Vec<u8> {
if let Ok(len) = u32::try_from(len) {
[&len.to_be_bytes()[..], kind].concat()
} else {
[&1u32.to_be_bytes()[..], kind, &len.to_be_bytes()[..]].concat()
}
}
const GIB: u64 = 1024 * 1024 * 1024;
#[test]
fn a_large_stream_video_downloads_its_head_alone() {
let mut head = vec![0u8; 188 * 2];
head[0] = 0x47;
head[188] = 0x47;
let revision = FakeRevision::new(9 * GIB).with(0, head);
let sample = block_on(sample_video(&revision)).unwrap();
assert_eq!(sample.size, 9 * GIB);
assert_eq!(sample.head.len() as u64, VIDEO_HEAD_BYTES);
assert!(sample.index.is_none());
assert!(sample.is_transport_stream());
assert_eq!(revision.read.load(Ordering::Relaxed), VIDEO_HEAD_BYTES);
}
/// A camera MP4 keeps its index after gigabytes of media data. The sample
/// is the head, the box headers on the way, and the index itself.
#[test]
fn a_large_mp4_downloads_its_head_and_its_index() {
let ftyp = [
iso_box_header(b"ftyp", 24),
b"isom\0\0\0\0mp41isom".to_vec(),
]
.concat();
let mdat_len = 6 * GIB;
let moov_at = ftyp.len() as u64 + mdat_len;
let moov = [iso_box_header(b"moov", 4096), vec![7u8; 4088]].concat();
let size = moov_at + moov.len() as u64;
let revision = FakeRevision::new(size)
.with(0, ftyp.clone())
.with(ftyp.len() as u64, iso_box_header(b"mdat", mdat_len))
.with(moov_at, moov.clone());
let sample = block_on(sample_video(&revision)).unwrap();
assert_eq!(sample.index, Some((moov_at, moov)));
assert!(
revision.read.load(Ordering::Relaxed) <= VIDEO_HEAD_BYTES + 4096 + 16,
"read {} bytes",
revision.read.load(Ordering::Relaxed)
);
}
#[test]
fn an_mp4_with_its_index_up_front_downloads_its_head_alone() {
let ftyp = [iso_box_header(b"ftyp", 16), b"isom\0\0\0\0".to_vec()].concat();
let moov = [iso_box_header(b"moov", 64), vec![1u8; 56]].concat();
let revision = FakeRevision::new(GIB)
.with(0, ftyp.clone())
.with(ftyp.len() as u64, moov)
.with(80, iso_box_header(b"mdat", GIB - 80));
let sample = block_on(sample_video(&revision)).unwrap();
assert!(sample.index.is_none());
assert_eq!(revision.read.load(Ordering::Relaxed), VIDEO_HEAD_BYTES);
}
#[test]
fn a_small_video_downloads_whole() {
let revision = FakeRevision::new(VIDEO_WHOLE_MAX_BYTES).with(0, vec![3u8; 64]);
let sample = block_on(sample_video(&revision)).unwrap();
assert_eq!(sample.head.len() as u64, VIDEO_WHOLE_MAX_BYTES);
assert!(sample.index.is_none());
}
/// `.ts` that is TypeScript reaches ffmpeg never: the sync byte is missing,
/// and that is a final answer, not a retry.
#[test]
fn a_ts_without_the_sync_byte_is_no_video() {
let attempt = video_sample_thumbnail(
&whole(b"export const answer = 42;"),
"bundle.ts",
&std::env::temp_dir(),
OsStr::new("pdfs-ffmpeg-deliberately-absent"),
Duration::from_millis(10),
);
assert!(matches!(attempt, ScaleAttempt::Undecodable));
}
/// ffmpeg reads the index where the file's own boxes put it, so the staging
/// file has the full length and the index at its offset.
#[test]
fn the_index_is_staged_at_its_own_offset() {
let tmp = scratch("video-index");
let frame = tmp.join("frame.jpg");
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(&frame, jpeg(640, 360)).unwrap();
let size: u64 = 3 * GIB;
let at: u64 = size - 4096;
// Arguments: -nostdin -loglevel error -ss <seek> -i <path> …
let ffmpeg = fake_ffmpeg(
&tmp,
&format!(
"[ \"$(stat -c %s \"$7\")\" = {size} ] && \
[ \"$(tail -c +{} \"$7\" | head -c 4)\" = moov ] && cat '{}'",
// The box type, after its 4-byte length; `tail` counts from 1.
at + 5,
frame.display()
),
);
let sample = VideoSample {
size,
head: vec![0u8; 1024],
index: Some((
at,
[iso_box_header(b"moov", 4096), vec![0u8; 4088]].concat(),
)),
};
let attempt = video_sample_thumbnail(
&sample,
"clip.mp4",
&tmp,
ffmpeg.as_os_str(),
Duration::from_secs(10),
);
std::fs::remove_dir_all(&tmp).unwrap();
assert!(matches!(attempt, ScaleAttempt::Made(_)));
}
#[test] #[test]
fn a_video_frame_becomes_a_thumbnail() { fn a_video_frame_becomes_a_thumbnail() {
let tmp = scratch("video-frame"); let tmp = scratch("video-frame");
@@ -2544,8 +2901,8 @@ mod thumb_tests {
std::fs::write(&frame, jpeg(1080, 1920)).unwrap(); std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display())); let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
let ScaleAttempt::Made(thumb) = video_thumbnail( let ScaleAttempt::Made(thumb) = video_sample_thumbnail(
b"video bytes", &whole(b"video bytes"),
"clip.mp4", "clip.mp4",
&tmp, &tmp,
ffmpeg.as_os_str(), ffmpeg.as_os_str(),
@@ -2561,8 +2918,8 @@ mod thumb_tests {
fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() { fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() {
let tmp = scratch("video-broken"); let tmp = scratch("video-broken");
let ffmpeg = fake_ffmpeg(&tmp, "exit 1"); let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
let attempt = video_thumbnail( let attempt = video_sample_thumbnail(
b"not a video", &whole(b"not a video"),
"clip.mp4", "clip.mp4",
&tmp, &tmp,
ffmpeg.as_os_str(), ffmpeg.as_os_str(),
@@ -2574,8 +2931,8 @@ mod thumb_tests {
#[test] #[test]
fn missing_ffmpeg_is_retryable() { fn missing_ffmpeg_is_retryable() {
let attempt = video_thumbnail( let attempt = video_sample_thumbnail(
b"video bytes", &whole(b"video bytes"),
"clip.mp4", "clip.mp4",
&std::env::temp_dir(), &std::env::temp_dir(),
OsStr::new("pdfs-ffmpeg-deliberately-absent"), OsStr::new("pdfs-ffmpeg-deliberately-absent"),
@@ -2591,9 +2948,12 @@ mod thumb_tests {
let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_FIXTURE"); let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_FIXTURE");
let path = std::path::PathBuf::from(path); let path = std::path::PathBuf::from(path);
let name = path.file_name().unwrap().to_string_lossy(); let name = path.file_name().unwrap().to_string_lossy();
let bytes = std::fs::read(&path).unwrap(); // Through the same partial download a Drive video gets.
let ScaleAttempt::Made(thumb) = video_thumbnail( let revision = FakeRevision::new(std::fs::read(&path).unwrap().len() as u64)
&bytes, .with(0, std::fs::read(&path).unwrap());
let sample = block_on(sample_video(&revision)).unwrap();
let ScaleAttempt::Made(thumb) = video_sample_thumbnail(
&sample,
&name, &name,
&std::env::temp_dir(), &std::env::temp_dir(),
OsStr::new("ffmpeg"), OsStr::new("ffmpeg"),
+1
View File
@@ -200,6 +200,7 @@ impl Core {
let reader = CountingReader::new(std::io::BufReader::new(file), &guard); let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
let metadata = PhotoUploadMetadata { let metadata = PhotoUploadMetadata {
capture_time: Some(candidate.wanted), capture_time: Some(candidate.wanted),
last_modification_time: pdfs_core::timestamp::modification_time(candidate.wanted),
..Default::default() ..Default::default()
}; };
let uploaded = self.rt.block_on(photos.upload_photo_from( let uploaded = self.rt.block_on(photos.upload_photo_from(
-5
View File
@@ -111,11 +111,6 @@ impl Core {
// stale from here on, and the server may not have applied the swap yet. // stale from here on, and the server may not have applied the swap yet.
self.cache.evict(uid); self.cache.evict(uid);
self.evict_reader(uid); self.evict_reader(uid);
// A file kept offline gets the restored content back on disk, and
// again when the event of the swap arrives (`docs/BUGS.md` B282).
if self.cache.is_pinned(uid) {
self.recheck_pinned(uid);
}
self.log_activity( self.log_activity(
ActivityKind::Restore, ActivityKind::Restore,
name, name,
+106 -112
View File
@@ -3447,118 +3447,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy() {
// Keeping a file offline took only paths under the primary mount, so
// the copy in an on-demand synced folder could not be kept, and a
// program reading it there downloaded it every time (B281).
let drive = FakeDrive::new();
let dir = scratch("pin-in-ondemand");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let ondemand = dir.join("ondemand");
sync_folder(&daemon, &ondemand, "ondemand");
let content = b"not a real png, but kept all the same".repeat(64);
let file = ondemand.join("photo.png");
std::fs::write(&file, &content).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
daemon.request(&Request::PurgeCache).unwrap();
let path = file.canonicalize().unwrap().to_string_lossy().into_owned();
let pinned = daemon
.request(&Request::Pin { path: path.clone() })
.unwrap();
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
let pins = || match daemon.request(&Request::ListPins) {
Ok(Response::Pins { pins }) => pins,
other => panic!("pins: {other:?}"),
};
let listed = pins();
assert!(
listed.iter().any(|pin| pin.path == path && pin.cached),
"the pin opens by the path it was made through: {listed:?}"
);
// With the link gone, only a kept copy can answer the read.
daemon.client.set_online(false);
assert_eq!(std::fs::read(&file).unwrap(), content);
daemon.client.set_online(true);
let unpinned = daemon
.request(&Request::Unpin { path: path.clone() })
.unwrap();
assert!(matches!(unpinned, Response::Ok { .. }), "{unpinned:?}");
assert!(pins().is_empty());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere() {
// A change from another device deleted a pinned file's copy and
// nothing downloaded it again, and a file added to a pinned folder
// elsewhere was never downloaded: both were listed as kept offline
// and read only with a network (B282).
let drive = FakeDrive::new();
let dir = scratch("pin-keeper");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"first").unwrap();
std::fs::write(daemon.mountpoint.join("solo.txt"), b"alone").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Docs/a.txt").is_some()
&& drive.lookup("solo.txt").is_some()
}));
daemon.request(&Request::PurgeCache).unwrap();
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
path: folder.into(),
}) {
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
_ => None,
};
for path in ["Docs", "solo.txt"] {
let pinned = daemon.request(&Request::Pin { path: path.into() }).unwrap();
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
}
assert!(wait_until(Duration::from_secs(30), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached)
&& entry("", "solo.txt").is_some_and(|e| e.cached)
}));
// Another device edits both files and adds one to the kept folder.
drive.device().write("Docs/a.txt", b"second, and longer");
drive.device().write("solo.txt", b"alone, edited");
drive.device().write("Docs/new.txt", b"made elsewhere");
assert!(
wait_until(Duration::from_secs(60), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached && e.size == 18)
&& entry("", "solo.txt").is_some_and(|e| e.cached && e.size == 13)
&& entry("Docs", "new.txt").is_some_and(|e| e.cached)
}),
"the keeper downloaded the new content: {:?} {:?} {:?}",
entry("Docs", "a.txt"),
entry("", "solo.txt"),
entry("Docs", "new.txt"),
);
// With the link gone, only the kept copies can answer.
daemon.client.set_online(false);
let read = |rel: &str| std::fs::read(daemon.mountpoint.join(rel)).unwrap();
assert_eq!(read("Docs/a.txt"), b"second, and longer");
assert_eq!(read("solo.txt"), b"alone, edited");
assert_eq!(read("Docs/new.txt"), b"made elsewhere");
daemon.client.set_online(true);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// The synced folder `id` as the daemon lists it. /// The synced folder `id` as the daemon lists it.
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> { fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
match daemon.request(&Request::ListSyncFolders) { match daemon.request(&Request::ListSyncFolders) {
@@ -3731,4 +3619,110 @@ mod tests {
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
/// A video in My files gets a thumbnail from a frame of its own, but a
/// recording can be gigabytes: only its start, and an MP4's index, are
/// downloaded for it. A video with a thumbnail from its uploader is not
/// downloaded at all, and a `.ts` that is TypeScript gets no thumbnail.
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_video_thumbnail_downloads_a_small_part_of_the_video() {
if std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_err()
{
eprintln!("skipped: needs ffmpeg");
return;
}
let dir = scratch("video-thumbs");
std::fs::create_dir_all(&dir).unwrap();
// Twenty seconds at 30 Mbit/s: larger than a thumbnail downloads whole.
// An MP4 written this way has its index at the end.
let encode = |name: &str| {
let path = dir.join(name);
let status = std::process::Command::new("ffmpeg")
.args(["-nostdin", "-loglevel", "error", "-y", "-f", "lavfi"])
.args(["-i", "testsrc2=size=1280x720:rate=30", "-t", "20"])
.args(["-c:v", "libx264", "-preset", "ultrafast"])
.args(["-b:v", "30M", "-maxrate", "30M", "-bufsize", "30M"])
.arg(&path)
.status()
.unwrap();
assert!(status.success());
let bytes = std::fs::read(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert!(bytes.len() > 30 << 20, "{name} is {} bytes", bytes.len());
bytes
};
let (recording, clip) = (encode("recording.ts"), encode("clip.mp4"));
let source = b"export const answer = 42;\n".repeat(64 * 1024);
let drive = FakeDrive::new();
drive.device().write("recording.ts", &recording);
drive.device().write("clip.mp4", &clip);
drive.device().write("app.ts", &source);
let phone = drive.device().write("phone.mp4", &clip);
let mut frame = Vec::new();
image::codecs::jpeg::JpegEncoder::new(&mut frame)
.encode_image(&image::RgbImage::new(320, 180))
.unwrap();
let client = drive.client(1, Faults::lan());
client.set_thumbnail(&phone, frame);
let daemon = Daemon::start(&dir.join("daemon"), client.clone()).unwrap();
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir {
path: String::new(),
}) else {
panic!("listing");
};
let items: Vec<control::FileThumbRequest> = entries
.iter()
.filter(|entry| !entry.is_dir)
.map(|entry| control::FileThumbRequest {
uid: entry.uid.clone(),
modified: entry.modified,
name: entry.name.clone(),
})
.collect();
let Ok(Response::FileThumbGeneration { generation }) =
daemon.request(&Request::ReserveFileThumbGeneration)
else {
panic!("generation");
};
let read_before = client.content_read();
let mut paths = std::collections::HashMap::new();
let settled = wait_until(Duration::from_secs(120), || {
let Ok(Response::Thumbs { items: replies }) = daemon.request(&Request::FileThumbs {
items: items.clone(),
generation,
}) else {
return false;
};
paths = replies
.iter()
.filter_map(|reply| {
let name = &items.iter().find(|item| item.uid == reply.uid)?.name;
Some((name.clone(), (reply.path.clone(), reply.pending)))
})
.collect();
paths.values().all(|(_, pending)| !pending) && paths.len() == 4
});
assert!(settled, "{paths:?}");
let read = client.content_read() - read_before;
for name in ["recording.ts", "clip.mp4", "phone.mp4"] {
assert!(paths[name].0.is_some(), "{name} has a thumbnail: {paths:?}");
}
assert_eq!(paths["app.ts"].0, None, "TypeScript is no video");
// Two 16 MiB heads, one MP4 index and the TypeScript file, and nothing
// of the video that came with a thumbnail.
assert!(
read < 34 << 20,
"downloaded {} MiB for three thumbnails",
read >> 20
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
} }
+43 -3
View File
@@ -62,7 +62,7 @@ use proton_drive_rs::proton_sdk::ids::{
}; };
use proton_drive_rs::{ use proton_drive_rs::{
AvailableNameTarget, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node, AvailableNameTarget, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node,
NodeKind, NodeMoveItem, RevisionState, Thumbnail, NodeKind, NodeMoveItem, RevisionState, Thumbnail, ThumbnailType,
}; };
use sha1::{Digest as _, Sha1}; use sha1::{Digest as _, Sha1};
@@ -396,6 +396,8 @@ impl FakeDrive {
hold_answer: Mutex::new(None), hold_answer: Mutex::new(None),
hold_trash: Mutex::new(None), hold_trash: Mutex::new(None),
hold_download: Mutex::new(None), hold_download: Mutex::new(None),
thumbnails: Mutex::new(HashMap::new()),
content_read: AtomicU64::new(0),
hold_any_landing: AtomicBool::new(false), hold_any_landing: AtomicBool::new(false),
hold_landing: Mutex::new(None), hold_landing: Mutex::new(None),
drop_after_create: AtomicBool::new(false), drop_after_create: AtomicBool::new(false),
@@ -677,6 +679,10 @@ struct ClientInner {
hold_trash: Mutex<Option<Arc<HoldState>>>, hold_trash: Mutex<Option<Arc<HoldState>>>,
/// The download [`FakeClient::hold_next_download`] is waiting for. /// The download [`FakeClient::hold_next_download`] is waiting for.
hold_download: Mutex<Option<Arc<HoldState>>>, hold_download: Mutex<Option<Arc<HoldState>>>,
/// Thumbnails set by [`FakeClient::set_thumbnail`].
thumbnails: Mutex<HashMap<NodeUid, Vec<u8>>>,
/// File content bytes served, by downloads and ranged reads alike.
content_read: AtomicU64,
/// The landing [`FakeClient::hold_next_landing`] is waiting for. /// The landing [`FakeClient::hold_next_landing`] is waiting for.
hold_landing: Mutex<Option<Arc<HoldState>>>, hold_landing: Mutex<Option<Arc<HoldState>>>,
/// Set by [`FakeClient::hold_next_landing_of_any`]: hold whichever node /// Set by [`FakeClient::hold_next_landing_of_any`]: hold whichever node
@@ -821,6 +827,16 @@ impl FakeClient {
/// Hold the next download of `uid`'s content once Drive has answered it, /// Hold the next download of `uid`'s content once Drive has answered it,
/// before the bytes reach the daemon, until the answer is dropped. /// before the bytes reach the daemon, until the answer is dropped.
/// Give `uid` a server thumbnail, as an upload that carried one would.
pub(crate) fn set_thumbnail(&self, uid: &NodeUid, bytes: Vec<u8>) {
self.0.thumbnails.lock().insert(uid.clone(), bytes);
}
/// File content bytes served so far, by whole downloads and ranged reads.
pub(crate) fn content_read(&self) -> u64 {
self.0.content_read.load(Ordering::SeqCst)
}
pub(crate) fn hold_next_download(&self, uid: &NodeUid) -> Held { pub(crate) fn hold_next_download(&self, uid: &NodeUid) -> Held {
let state = Arc::new(HoldState { let state = Arc::new(HoldState {
uid: uid.clone(), uid: uid.clone(),
@@ -1475,6 +1491,21 @@ impl DriveApi for FakeClient {
})) }))
} }
async fn download_thumbnail(
&self,
uid: &NodeUid,
_thumbnail_type: ThumbnailType,
) -> Result<Option<Vec<u8>>> {
self.request(false, |server| {
server
.current(uid)
.map(|_| ())
.ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found"))
})
.await?;
Ok(self.0.thumbnails.lock().get(uid).cloned())
}
async fn download_file_to_dyn( async fn download_file_to_dyn(
&self, &self,
uid: &NodeUid, uid: &NodeUid,
@@ -1490,6 +1521,9 @@ impl DriveApi for FakeClient {
.await?; .await?;
self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid)) self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid))
.await; .await;
self.0
.content_read
.fetch_add(content.len() as u64, Ordering::SeqCst);
output.write_all(&content).map_err(io_error) output.write_all(&content).map_err(io_error)
} }
@@ -1733,13 +1767,19 @@ impl RevisionRead for FakeRevision {
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>> { async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>> {
let content = self.content.clone(); let content = self.content.clone();
self.client let read = self
.client
.request(false, move |_| { .request(false, move |_| {
let start = (offset as usize).min(content.len()); let start = (offset as usize).min(content.len());
let end = (offset.saturating_add(length) as usize).min(content.len()); let end = (offset.saturating_add(length) as usize).min(content.len());
Ok(content[start..end].to_vec()) Ok(content[start..end].to_vec())
}) })
.await .await?;
self.client
.0
.content_read
.fetch_add(read.len() as u64, Ordering::SeqCst);
Ok(read)
} }
} }
+9 -3
View File
@@ -344,6 +344,9 @@ impl Core {
); );
let metadata = PhotoUploadMetadata { let metadata = PhotoUploadMetadata {
capture_time: photo.capture_time, capture_time: photo.capture_time,
last_modification_time: photo
.capture_time
.and_then(pdfs_core::timestamp::modification_time),
..Default::default() ..Default::default()
}; };
// A thumbnail is made from a file, so a photo that can have one // A thumbnail is made from a file, so a photo that can have one
@@ -365,10 +368,13 @@ impl Core {
let thumbnails = self.upload_thumbnails(path, &photo.name); let thumbnails = self.upload_thumbnails(path, &photo.name);
// No sidecar and no date in the name: the photo's own // No sidecar and no date in the name: the photo's own
// EXIF, before Drive files it under today. // EXIF, before Drive files it under today.
let metadata = PhotoUploadMetadata { let capture_time = metadata
capture_time: metadata
.capture_time .capture_time
.or_else(|| pdfs_core::exiftime::capture_time_of(path)), .or_else(|| pdfs_core::exiftime::capture_time_of(path));
let metadata = PhotoUploadMetadata {
capture_time,
last_modification_time: capture_time
.and_then(pdfs_core::timestamp::modification_time),
..metadata ..metadata
}; };
std::fs::File::open(path) std::fs::File::open(path)
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Album: a photo in front of the edge of another, a stack of them. -->
<path fill="#222222" d="M 4,1 H 13.5 A 1.5,1.5 0 0 1 15,2.5 V 12 H 13.5 V 2.5 H 4 Z"/>
<path fill="#222222" fill-rule="evenodd" d="M 2.5,4 H 10.5 A 1.5,1.5 0 0 1 12,5.5 V 13.5 A 1.5,1.5 0 0 1 10.5,15 H 2.5 A 1.5,1.5 0 0 1 1,13.5 V 5.5 A 1.5,1.5 0 0 1 2.5,4 Z M 2.5,5.5 V 13.5 H 10.5 V 5.5 Z"/>
<path fill="#222222" d="M 3.5,12.5 L 5.5,9.2 L 7.2,11.2 L 8.2,10.2 L 9.5,12.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 550 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Photos: a framed landscape with a sun. Stock themes only have
image-x-generic, a file type icon, which reads as "an image file"
rather than "your photo library". -->
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
<path fill="#222222" d="M 3.5,11.5 L 6.5,7 L 9,10.2 L 10.5,8.6 L 12.5,11.5 Z"/>
<circle fill="#222222" cx="10.5" cy="5.8" r="1.3"/>
</svg>

After

Width:  |  Height:  |  Size: 673 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Slideshow: a play mark inside a photo frame, so it is not mistaken for
playing the video on screen. -->
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
<path fill="#222222" d="M 6.5,5.2 L 11,8 L 6.5,10.8 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 527 B

@@ -15,6 +15,9 @@
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-photos-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-album-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-slideshow-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-synced-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-synced-symbolic.svg</file>
+9 -3
View File
@@ -53,7 +53,7 @@ button.sidebar-account { padding: 4px 8px; font-weight: normal; }
/* The folder on screen, last in the breadcrumb trail: a flat button's side /* The folder on screen, last in the breadcrumb trail: a flat button's side
* padding, so the "›" before it is spaced like the ones between buttons. */ * padding, so the "›" before it is spaced like the ones between buttons. */
.crumb-current { padding: 0 10px; } .crumb-current { padding: 0 10px; }
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; } scale.browser-status-meter trough { min-width: 140px; min-height: 6px; }
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); } .bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; } .dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); } .dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
@@ -80,7 +80,12 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); } .viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
/* Photos grid */ /* Photos grid */
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 2px 2px 6px 2px; } .gallery-day { font-size: 1.1rem; font-weight: 700; padding: 4px 2px 8px 2px; }
/* The check that picks a whole section: on hover, and always while selecting. */
.gallery-day-check { opacity: 0; transition: opacity 160ms ease; min-width: 24px; min-height: 24px; margin-bottom: 4px; }
.gallery-heading:hover .gallery-day-check, .gallery-heading.selecting .gallery-day-check, .gallery-day-check:focus-visible { opacity: 1; }
/* The section at the top of the grid, floating once its heading scrolled off. */
.gallery-float-heading { font-weight: 700; padding: 4px 12px; border-radius: 999px; background-color: color-mix(in srgb, var(--popover-bg-color) 92%, transparent); color: var(--popover-fg-color); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); }
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; } .photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; }
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; } .photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
.photo-thumb { transition: transform 220ms ease; } .photo-thumb { transition: transform 220ms ease; }
@@ -94,6 +99,7 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; } .photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }
.photo-video-badge-small { padding: 3px; min-width: 0; min-height: 0; } .photo-video-badge-small { padding: 3px; min-width: 0; min-height: 0; }
.photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); } .photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); }
.photo-favorite-badge { color: white; -gtk-icon-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
.photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); } .photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }
.memory-card { padding: 0; border-radius: 12px; } .memory-card { padding: 0; border-radius: 12px; }
.memory-cover { border-radius: 12px; background: alpha(currentColor, 0.06); } .memory-cover { border-radius: 12px; background: alpha(currentColor, 0.06); }
@@ -103,7 +109,7 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; } .album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
.album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); } .album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
.album-card:hover .photo-thumb { transform: scale(1.05); } .album-card:hover .photo-thumb { transform: scale(1.05); }
.view-switch button, .view-switch toggle { padding-left: 18px; padding-right: 18px; } .view-switch button, .view-switch toggle { padding-left: 12px; padding-right: 12px; }
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */ /* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); } .role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
+30 -11
View File
@@ -71,7 +71,7 @@ use gtk4::glib::BoxedAnyObject;
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState}; use pdfs_core::config::{AppDirs, FileSort, FilesView, PhotosView, WindowState};
use pdfs_core::control::{ use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo, ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
@@ -488,6 +488,7 @@ fn build_window(app: &adw::Application) {
empty_actions: browser_widgets.empty_actions.clone(), empty_actions: browser_widgets.empty_actions.clone(),
summary: browser_widgets.summary.clone(), summary: browser_widgets.summary.clone(),
zoom: browser_widgets.zoom.clone(), zoom: browser_widgets.zoom.clone(),
zoom_save: RefCell::new(None),
}, },
details: DetailsState { details: DetailsState {
details: browser_widgets.details, details: browser_widgets.details,
@@ -587,10 +588,13 @@ fn build_window(app: &adw::Application) {
selected: RefCell::new(HashSet::new()), selected: RefCell::new(HashSet::new()),
select_anchor: RefCell::new(None), select_anchor: RefCell::new(None),
select_btn: gallery_widgets.select_btn.clone(), select_btn: gallery_widgets.select_btn.clone(),
select_bar: gallery_widgets.select_bar.clone(), selection: gallery_widgets.selection.clone(),
select_label: gallery_widgets.select_label.clone(), more: gallery_widgets.more.clone(),
select_trash: gallery_widgets.select_trash.clone(), upload_face: gallery_widgets.upload_face.clone(),
select_album: gallery_widgets.select_album.clone(), float_heading: gallery_widgets.float_heading.clone(),
float_label: gallery_widgets.float_label.clone(),
square: Cell::new(false),
zoom_save: RefCell::new(None),
}, },
shared: SharedState::new(&shared_widgets), shared: SharedState::new(&shared_widgets),
shared_by_me: SharedByMeState::new(&shared_by_me_widgets), shared_by_me: SharedByMeState::new(&shared_by_me_widgets),
@@ -693,7 +697,7 @@ fn build_window(app: &adw::Application) {
&ui, &ui,
&gallery_widgets.list, &gallery_widgets.list,
&gallery_widgets.scroll, &gallery_widgets.scroll,
&gallery_widgets.select_done, &gallery_widgets.square_action,
); );
wire_gallery_empty( wire_gallery_empty(
&ui, &ui,
@@ -795,6 +799,7 @@ fn build_window(app: &adw::Application) {
/// split collapsed whatever the width. /// split collapsed whatever the width.
fn set_narrow(ui: &Ui, narrow: bool) { fn set_narrow(ui: &Ui, narrow: bool) {
ui.narrow.set(narrow); ui.narrow.set(narrow);
set_photos_narrow(ui, narrow);
if ui.session.borrow().is_some() { if ui.session.borrow().is_some() {
ui.nav.set_collapsed(narrow); ui.nav.set_collapsed(narrow);
} }
@@ -858,11 +863,7 @@ fn window_hidden(window: &adw::ApplicationWindow) -> bool {
/// sync, set off by a separator. /// sync, set off by a separator.
const DESTINATIONS: [(&str, &str, &str); 7] = [ const DESTINATIONS: [(&str, &str, &str); 7] = [
("browser", gettext_noop("My files"), "folder-symbolic"), ("browser", gettext_noop("My files"), "folder-symbolic"),
( ("gallery", gettext_noop("Photos"), "pdfs-photos-symbolic"),
"gallery",
gettext_noop("Photos"),
"image-x-generic-symbolic",
),
( (
"shared", "shared",
gettext_noop("Shared with me"), gettext_noop("Shared with me"),
@@ -1523,6 +1524,9 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
("<Alt>Left <Alt>Right", gettext_noop("Back / forward")), ("<Alt>Left <Alt>Right", gettext_noop("Back / forward")),
("<Alt>Up", gettext_noop("Parent folder")), ("<Alt>Up", gettext_noop("Parent folder")),
("<Control>1 <Control>2", gettext_noop("Grid / list")), ("<Control>1 <Control>2", gettext_noop("Grid / list")),
("<Control>plus", gettext_noop("Larger thumbnails")),
("<Control>minus", gettext_noop("Smaller thumbnails")),
("<Control>0", gettext_noop("Reset thumbnail size")),
("F2", gettext_noop("Rename")), ("F2", gettext_noop("Rename")),
("Delete", gettext_noop("Move to Trash")), ("Delete", gettext_noop("Move to Trash")),
("Escape", gettext_noop("Clear the selection")), ("Escape", gettext_noop("Clear the selection")),
@@ -1588,6 +1592,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
ui.browser.search.grab_focus(); ui.browser.search.grab_focus();
} }
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui), gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
gtk4::gdk::Key::plus | gtk4::gdk::Key::equal | gtk4::gdk::Key::KP_Add
if ctrl && on_browser && !ui.browser.files.shows_list() =>
{
zoom_files(&ui, 1)
}
gtk4::gdk::Key::minus | gtk4::gdk::Key::KP_Subtract
if ctrl && on_browser && !ui.browser.files.shows_list() =>
{
zoom_files(&ui, -1)
}
gtk4::gdk::Key::_0 | gtk4::gdk::Key::KP_0
if ctrl && on_browser && !ui.browser.files.shows_list() =>
{
zoom_files(&ui, 0)
}
gtk4::gdk::Key::_1 | gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view( gtk4::gdk::Key::_1 | gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view(
&ui, &ui,
FilesView { FilesView {
+5 -9
View File
@@ -40,7 +40,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") { if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") {
albums_status( albums_status(
ui, ui,
"view-grid-symbolic", "pdfs-album-symbolic",
&gettext("Loading albums…"), &gettext("Loading albums…"),
&gettext("Reading your Proton Drive albums."), &gettext("Reading your Proton Drive albums."),
); );
@@ -62,7 +62,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
), ),
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status( Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
&ui, &ui,
"view-grid-symbolic", "pdfs-album-symbolic",
&gettext("No albums"), &gettext("No albums"),
&gettext("Create one with New Album, or in Proton Photos."), &gettext("Create one with New Album, or in Proton Photos."),
), ),
@@ -136,7 +136,7 @@ fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
picture.add_css_class("photo-thumb"); picture.add_css_class("photo-thumb");
let placeholder = gtk4::Image::builder() let placeholder = gtk4::Image::builder()
.icon_name("view-grid-symbolic") .icon_name("pdfs-album-symbolic")
.pixel_size(32) .pixel_size(32)
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
@@ -618,9 +618,7 @@ pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
// would silently do something else. The Photos/Albums switcher goes too: an // would silently do something else. The Photos/Albums switcher goes too: an
// album is a level below it, and back is the way out. // album is a level below it, and back is the way out.
ui.gallery.filters.set_visible(false); ui.gallery.filters.set_visible(false);
ui.gallery.view_switch.widget().set_visible(false); sync_header(ui);
ui.gallery.upload.set_visible(false);
ui.gallery.back.set_visible(true);
// A stale kind/date filter would otherwise be sent with the album request. // A stale kind/date filter would otherwise be sent with the album request.
ui.gallery.kind.set(None); ui.gallery.kind.set(None);
ui.gallery.range.set(None); ui.gallery.range.set(None);
@@ -636,9 +634,7 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
return; return;
} }
ui.gallery.title.set_title(&gettext("Photos")); ui.gallery.title.set_title(&gettext("Photos"));
ui.gallery.view_switch.widget().set_visible(true); sync_header(ui);
ui.gallery.upload.set_visible(true);
ui.gallery.back.set_visible(false);
} }
/// Show the album grid's own status page (loading, empty, or an error). /// Show the album grid's own status page (loading, empty, or an error).
+116 -13
View File
@@ -77,6 +77,8 @@ pub(crate) struct BrowserState {
/// zoom value, and Proton account storage usage. /// zoom value, and Proton account storage usage.
pub(crate) summary: gtk4::Label, pub(crate) summary: gtk4::Label,
pub(crate) zoom: gtk4::Scale, pub(crate) zoom: gtk4::Scale,
/// Saves the zoom once the slider rests, not on every step.
pub(crate) zoom_save: RefCell<Option<glib::SourceId>>,
} }
/// Idle pause after the last keystroke before a search query is sent, so typing /// Idle pause after the last keystroke before a search query is sent, so typing
@@ -87,9 +89,11 @@ pub(crate) const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250);
pub(crate) const SEARCH_LIMIT: usize = 200; pub(crate) const SEARCH_LIMIT: usize = 200;
/// Browser grid thumbnail size controlled by the bottom zoom slider. The /// Browser grid thumbnail size controlled by the bottom zoom slider. The
/// default preserves the former fixed 72 px presentation. /// default preserves the former fixed 72 px presentation. The largest size is
/// big enough to tell photos apart by their thumbnail, and thumbnails are
/// 512 px, so they stay sharp up to it on a 2× screen.
pub(crate) const GRID_THUMB_MIN: i32 = 48; pub(crate) const GRID_THUMB_MIN: i32 = 48;
pub(crate) const GRID_THUMB_MAX: i32 = 144; pub(crate) const GRID_THUMB_MAX: i32 = 256;
pub(crate) const GRID_THUMB_DEFAULT: i32 = 72; pub(crate) const GRID_THUMB_DEFAULT: i32 = 72;
pub(crate) const GRID_THUMB_STEP: i32 = 8; pub(crate) const GRID_THUMB_STEP: i32 = 8;
@@ -801,7 +805,35 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>) {
&[], &[],
))); )));
ui_zoom.browser.files.set_thumbnail_size(size); ui_zoom.browser.files.set_thumbnail_size(size);
save_icon_size(&ui_zoom, size);
}); });
// Ctrl+scroll steps the icon size, as in Photos. Capture phase so the
// scrolled window does not scroll the grid as well. A touchpad sends many
// small deltas, so they add up to a whole notch before each step.
if let Some(grid_scroll) = files.views.child_by_name("grid") {
let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
wheel.set_propagation_phase(gtk4::PropagationPhase::Capture);
let ui_wheel = ui.clone();
let pending = Cell::new(0.0_f64);
wheel.connect_scroll(move |controller, _dx, dy| {
if !controller
.current_event_state()
.contains(gtk4::gdk::ModifierType::CONTROL_MASK)
{
pending.set(0.0);
return glib::Propagation::Proceed;
}
let total = pending.get() + dy;
let steps = total.trunc();
pending.set(total - steps);
if steps != 0.0 {
// Scrolling up (negative dy) makes the icons bigger.
zoom_files(&ui_wheel, -(steps as i32));
}
glib::Propagation::Stop
});
grid_scroll.add_controller(wheel);
}
files files
.column_view .column_view
@@ -1306,6 +1338,11 @@ pub(crate) fn wire_browser_actions(ui: &Rc<Ui>, build_thumbnails: &gtk4::Button)
let view = ui.dirs.load_config().files_view; let view = ui.dirs.load_config().files_view;
ui.browser.view.set(view); ui.browser.view.set(view);
if let Some(size) = view.icon_size {
ui.browser
.zoom
.set_value(f64::from(size.clamp(GRID_THUMB_MIN, GRID_THUMB_MAX)));
}
let layout = gio::SimpleAction::new_stateful( let layout = gio::SimpleAction::new_stateful(
"view", "view",
Some(glib::VariantTy::STRING), Some(glib::VariantTy::STRING),
@@ -1407,6 +1444,41 @@ pub(crate) fn wire_browser_actions(ui: &Rc<Ui>, build_thumbnails: &gtk4::Button)
ui.browser.content.add_controller(drop); ui.browser.content.add_controller(drop);
} }
/// Remember the grid's icon size, once the slider has rested for a moment.
fn save_icon_size(ui: &Rc<Ui>, size: i32) {
let mut view = ui.browser.view.get();
if view.icon_size == Some(size) {
return;
}
view.icon_size = Some(size);
ui.browser.view.set(view);
if let Some(source) = ui.browser.zoom_save.borrow_mut().take() {
source.remove();
}
let ui_save = ui.clone();
let source = glib::timeout_add_local_once(Duration::from_millis(500), move || {
ui_save.browser.zoom_save.borrow_mut().take();
let mut config = ui_save.dirs.load_config();
config.files_view.icon_size = ui_save.browser.view.get().icon_size;
if let Err(e) = ui_save.dirs.save_config(&config) {
tracing::warn!("cannot save the icon size: {e}");
}
});
*ui.browser.zoom_save.borrow_mut() = Some(source);
}
/// Step the grid's icon size by `steps` slider notches; `0` resets it.
pub(crate) fn zoom_files(ui: &Rc<Ui>, steps: i32) {
let zoom = &ui.browser.zoom;
let size = if steps == 0 {
GRID_THUMB_DEFAULT
} else {
(zoom.value().round() as i32 + steps * GRID_THUMB_STEP)
.clamp(GRID_THUMB_MIN, GRID_THUMB_MAX)
};
zoom.set_value(f64::from(size));
}
fn layout_name(view: FilesView) -> &'static str { fn layout_name(view: FilesView) -> &'static str {
if view.list { "list" } else { "grid" } if view.list { "list" } else { "grid" }
} }
@@ -1728,8 +1800,9 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
let text = if status.scanning { let text = if status.scanning {
ui.browser.thumbnail_progress.pulse(); ui.browser.thumbnail_progress.pulse();
let folders = ngettext_f("{n} folder", "{n} folders", status.folders_scanned, &[]); let folders = ngettext_f("{n} folder", "{n} folders", status.folders_scanned, &[]);
let images = ngettext_f("{n} image", "{n} images", status.images_found, &[]); // Translators: a count of images and videos that can have a thumbnail.
// Translators: {root} is a folder path or "Proton Drive"; {folders} and {images} are counts such as "3 folders" and "12 images". let images = ngettext_f("{n} file", "{n} files", status.images_found, &[]);
// Translators: {root} is a folder path or "Proton Drive"; {folders} and {images} are counts such as "3 folders" and "12 files".
gettext_f( gettext_f(
"Scanning {root}… {folders}, {images}", "Scanning {root}… {folders}, {images}",
&[("root", root), ("folders", &folders), ("images", &images)], &[("root", root), ("folders", &folders), ("images", &images)],
@@ -1743,7 +1816,7 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
ui.browser ui.browser
.thumbnail_progress .thumbnail_progress
.set_fraction(fraction.clamp(0.0, 1.0)); .set_fraction(fraction.clamp(0.0, 1.0));
// Translators: {root} is a folder path or "Proton Drive"; {done} and {total} are image counts. // Translators: {root} is a folder path or "Proton Drive"; {done} and {total} are counts of images and videos.
gettext_f( gettext_f(
"Building thumbnails in {root}… {done} of {total}", "Building thumbnails in {root}… {done} of {total}",
&[ &[
@@ -1756,17 +1829,17 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
ui.browser.thumbnail_progress.set_fraction(1.0); ui.browser.thumbnail_progress.set_fraction(1.0);
let available = status.completed.saturating_sub(status.failed); let available = status.completed.saturating_sub(status.failed);
match status.failed { match status.failed {
// Translators: {root} is a folder path or "Proton Drive". // Translators: {root} is a folder path or "Proton Drive"; {n} counts images and videos.
0 => ngettext_f( 0 => ngettext_f(
"Thumbnails ready for {n} image in {root}", "Thumbnails ready for {n} file in {root}",
"Thumbnails ready for {n} images in {root}", "Thumbnails ready for {n} files in {root}",
available, available,
&[("root", root)], &[("root", root)],
), ),
// Translators: {root} is a folder path or "Proton Drive"; {failed} is how many images could not get a thumbnail. // Translators: {root} is a folder path or "Proton Drive"; {failed} is how many images and videos could not get a thumbnail.
failed => ngettext_f( failed => ngettext_f(
"Thumbnails ready for {n} image in {root}; {failed} unavailable", "Thumbnails ready for {n} file in {root}; {failed} unavailable",
"Thumbnails ready for {n} images in {root}; {failed} unavailable", "Thumbnails ready for {n} files in {root}; {failed} unavailable",
available, available,
&[("root", root), ("failed", &failed.to_string())], &[("root", root), ("failed", &failed.to_string())],
), ),
@@ -2765,6 +2838,11 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
if entry.is_dir { if entry.is_dir {
return "folder"; return "folder";
} }
// Every video type, including a `.ts` recording, which by its name alone
// is TypeScript.
if pdfs_core::control::is_video_file_name(&entry.name, Some(entry.size)) {
return "video-x-generic";
}
let ext = entry let ext = entry
.name .name
.rsplit('.') .rsplit('.')
@@ -2775,7 +2853,6 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "heic" | "tiff" => { "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "heic" | "tiff" => {
"image-x-generic" "image-x-generic"
} }
"mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" => "video-x-generic",
"mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" => "audio-x-generic", "mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" => "audio-x-generic",
"pdf" | "doc" | "docx" | "odt" => "x-office-document", "pdf" | "doc" | "docx" | "odt" => "x-office-document",
"xls" | "xlsx" | "ods" | "csv" => "x-office-spreadsheet", "xls" | "xlsx" | "ods" | "csv" => "x-office-spreadsheet",
@@ -3122,7 +3199,7 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
MoveTarget, badge_for, copy_target_allowed, hit_location, listing_summary, MoveTarget, badge_for, copy_target_allowed, hit_location, icon_base_for, listing_summary,
move_target_allowed, show_thumbnail_build_progress, sort_entries, move_target_allowed, show_thumbnail_build_progress, sort_entries,
}; };
use pdfs_core::config::{FileSort, FilesView}; use pdfs_core::config::{FileSort, FilesView};
@@ -3149,6 +3226,32 @@ mod tests {
} }
} }
#[test]
fn every_video_type_gets_the_video_icon() {
const MIB: u64 = 1024 * 1024;
for name in [
"clip.mp4",
"clip.MKV",
"broadcast.m2ts",
"camera.mts",
"old.flv",
] {
assert_eq!(
icon_base_for(&entry(name, false, 10, 0)),
"video-x-generic",
"{name}"
);
}
assert_eq!(
icon_base_for(&entry("recording.ts", false, 900 * MIB, 0)),
"video-x-generic"
);
assert_eq!(
icon_base_for(&entry("app.ts", false, 4096, 0)),
"text-x-generic"
);
}
fn names(entries: &[DirEntry]) -> Vec<&str> { fn names(entries: &[DirEntry]) -> Vec<&str> {
entries.iter().map(|e| e.name.as_str()).collect() entries.iter().map(|e| e.name.as_str()).collect()
} }
+35 -23
View File
@@ -40,7 +40,9 @@ pub(crate) struct Viewer {
/// Details panel: the toggle that reveals it, the rows it fills in, and the /// Details panel: the toggle that reveals it, the rows it fills in, and the
/// "Show on map" button (hidden when the photo carries no GPS tags). /// "Show on map" button (hidden when the photo carries no GPS tags).
pub(crate) info_toggle: gtk4::ToggleButton, pub(crate) info_toggle: gtk4::ToggleButton,
pub(crate) info_revealer: gtk4::Revealer, /// Lays the details panel beside the photo, or over it in a narrow
/// window.
pub(crate) info_split: adw::OverlaySplitView,
pub(crate) info_rows: gtk4::Box, pub(crate) info_rows: gtk4::Box,
pub(crate) info_map: gtk4::Button, pub(crate) info_map: gtk4::Button,
/// Coordinates behind `info_map`, once a photo with GPS tags is shown. /// Coordinates behind `info_map`, once a photo with GPS tags is shown.
@@ -358,9 +360,7 @@ fn stop_slideshow(viewer: &Rc<Viewer>) {
if viewer.slide_fullscreened.replace(false) { if viewer.slide_fullscreened.replace(false) {
viewer.window.unfullscreen(); viewer.window.unfullscreen();
} }
viewer viewer.slideshow.set_icon_name("pdfs-slideshow-symbolic");
.slideshow
.set_icon_name("media-playback-start-symbolic");
if viewer.slideshow.is_active() { if viewer.slideshow.is_active() {
viewer.slideshow.set_active(false); viewer.slideshow.set_active(false);
} }
@@ -1004,13 +1004,16 @@ fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_tim
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) { pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap(); let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
let window = gtk4::Window::builder() // An `adw::Window` for its breakpoints: the details panel lies over the
// photo once the window is too narrow to show both side by side.
let adw_window = adw::Window::builder()
.title(gettext("Photo")) .title(gettext("Photo"))
.modal(true) .modal(true)
.transient_for(&parent) .transient_for(&parent)
.default_width(1100) .default_width(1100)
.default_height(760) .default_height(760)
.build(); .build();
let window: gtk4::Window = adw_window.clone().upcast();
window.add_css_class("photo-viewer-window"); window.add_css_class("photo-viewer-window");
let overlay = gtk4::Overlay::new(); let overlay = gtk4::Overlay::new();
@@ -1120,7 +1123,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
favorite_btn.add_css_class("viewer-action-btn"); favorite_btn.add_css_class("viewer-action-btn");
let slideshow_btn = gtk4::ToggleButton::builder() let slideshow_btn = gtk4::ToggleButton::builder()
.icon_name("media-playback-start-symbolic") .icon_name("pdfs-slideshow-symbolic")
.tooltip_text(gettext("Slideshow (F5)")) .tooltip_text(gettext("Slideshow (F5)"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
@@ -1205,17 +1208,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 0); let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
info_panel.add_css_class("viewer-info-panel"); info_panel.add_css_class("viewer-info-panel");
info_panel.set_width_request(320);
info_panel.append(&info_scroll); info_panel.append(&info_scroll);
// Set explicitly: the title's hexpand would otherwise propagate up and have
// the revealer take half the window even while it is closed.
let info_revealer = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
.hexpand(false)
.child(&info_panel)
.build();
let spinner = spinner(); let spinner = spinner();
spinner.set_halign(gtk4::Align::Center); spinner.set_halign(gtk4::Align::Center);
spinner.set_valign(gtk4::Align::Center); spinner.set_valign(gtk4::Align::Center);
@@ -1232,10 +1226,23 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
status_label.add_css_class("viewer-status"); status_label.add_css_class("viewer-status");
overlay.add_overlay(&status_label); overlay.add_overlay(&status_label);
let body = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); // The panel docks beside the photo, or lies over it in a narrow window,
body.append(&overlay); // where a click on the photo closes it again.
body.append(&info_revealer); let info_split = adw::OverlaySplitView::builder()
window.set_child(Some(&body)); .content(&overlay)
.sidebar(&info_panel)
.sidebar_position(gtk4::PackType::End)
.min_sidebar_width(300.0)
.max_sidebar_width(360.0)
.show_sidebar(false)
.build();
let narrow = adw::Breakpoint::new(
adw::BreakpointCondition::parse("max-width: 800sp").expect("static breakpoint"),
);
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 viewer = Rc::new(Viewer { let viewer = Rc::new(Viewer {
window: window.clone(), window: window.clone(),
@@ -1250,7 +1257,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
prev: prev_btn.clone(), prev: prev_btn.clone(),
next: next_btn.clone(), next: next_btn.clone(),
info_toggle: info_toggle.clone(), info_toggle: info_toggle.clone(),
info_revealer: info_revealer.clone(), info_split: info_split.clone(),
info_rows, info_rows,
info_map: info_map.clone(), info_map: info_map.clone(),
favorite: favorite_btn.clone(), favorite: favorite_btn.clone(),
@@ -1435,9 +1442,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let Some(viewer_info) = viewer_info.upgrade() else { let Some(viewer_info) = viewer_info.upgrade() else {
return; return;
}; };
viewer_info viewer_info.info_split.set_show_sidebar(toggle.is_active());
.info_revealer });
.set_reveal_child(toggle.is_active()); // A collapsed panel also closes on its own, by a click on the photo.
let toggle_info = info_toggle.clone();
info_split.connect_show_sidebar_notify(move |split| {
if toggle_info.is_active() != split.shows_sidebar() {
toggle_info.set_active(split.shows_sidebar());
}
}); });
let ui_slides = ui.clone(); let ui_slides = ui.clone();
File diff suppressed because it is too large. Load diff
+2 -6
View File
@@ -422,9 +422,7 @@ fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
// As in an album: no filters, no Upload, no view switch — back leads out. // As in an album: no filters, no Upload, no view switch — back leads out.
ui.gallery.filters.set_visible(false); ui.gallery.filters.set_visible(false);
ui.gallery.view_switch.widget().set_visible(false); sync_header(ui);
ui.gallery.upload.set_visible(false);
ui.gallery.back.set_visible(true);
ui.gallery.kind.set(None); ui.gallery.kind.set(None);
ui.gallery.range.set(None); ui.gallery.range.set(None);
@@ -439,9 +437,7 @@ pub(crate) fn close_place(ui: &Rc<Ui>) {
return; return;
} }
ui.gallery.title.set_title(&gettext("Photos")); ui.gallery.title.set_title(&gettext("Photos"));
ui.gallery.view_switch.widget().set_visible(true); sync_header(ui);
ui.gallery.upload.set_visible(true);
ui.gallery.back.set_visible(false);
} }
/// Show the place grid's own status page (loading, empty, or an error). /// Show the place grid's own status page (loading, empty, or an error).
+81 -2
View File
@@ -10,6 +10,12 @@ use crate::*;
pub(crate) struct ToggleGroup { pub(crate) struct ToggleGroup {
widget: gtk4::Widget, widget: gtk4::Widget,
options: Options, options: Options,
/// Each option's label, kept while [`Self::set_compact`] shows the icons
/// alone.
labels: Rc<RefCell<Vec<String>>>,
/// Each option's own tooltip, as given.
tooltips: Rc<[Option<String>]>,
compact: Rc<Cell<bool>>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -47,6 +53,9 @@ impl ToggleGroup {
Self { Self {
widget, widget,
options: Options::Toggles(toggles), options: Options::Toggles(toggles),
labels: Rc::new(RefCell::new(labels_of(options))),
tooltips: tooltips_of(options),
compact: Rc::default(),
} }
} else { } else {
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
@@ -71,6 +80,9 @@ impl ToggleGroup {
Self { Self {
widget: bar.upcast(), widget: bar.upcast(),
options: Options::Buttons(buttons), options: Options::Buttons(buttons),
labels: Rc::new(RefCell::new(labels_of(options))),
tooltips: tooltips_of(options),
compact: Rc::default(),
} }
} }
} }
@@ -115,15 +127,69 @@ impl ToggleGroup {
} }
pub(crate) fn set_label(&self, index: u32, label: &str) { pub(crate) fn set_label(&self, index: u32, label: &str) {
if let Some(stored) = self.labels.borrow_mut().get_mut(index as usize) {
*stored = label.to_string();
}
self.show_label(index, label);
}
/// Put an icon before each option's label, in option order.
pub(crate) fn set_icons(&self, icons: &[&str]) {
match &self.options {
Options::Toggles(toggles) => {
for (toggle, icon) in toggles.iter().zip(icons) {
toggle.set_property("icon-name", *icon);
}
}
Options::Buttons(buttons) => {
let labels = self.labels.borrow();
for ((button, icon), label) in buttons.iter().zip(icons).zip(labels.iter()) {
let face = adw::ButtonContent::builder()
.icon_name(*icon)
.label(label.as_str())
.build();
button.set_child(Some(&face));
}
}
}
}
/// Show the icons alone, for a narrow window, with each label moved to
/// the option's tooltip; or bring the labels back.
pub(crate) fn set_compact(&self, compact: bool) {
if self.compact.replace(compact) == compact {
return;
}
let labels = self.labels.borrow().clone();
for (index, label) in labels.iter().enumerate() {
self.show_label(index as u32, label);
}
}
/// Show `label` on option `index`, or only in its tooltip while compact.
fn show_label(&self, index: u32, label: &str) {
let compact = self.compact.get();
let shown = if compact { "" } else { label };
let given = self.tooltips.get(index as usize).cloned().flatten();
let tooltip = if compact {
Some(label.to_string())
} else {
given
};
match &self.options { match &self.options {
Options::Toggles(toggles) => { Options::Toggles(toggles) => {
if let Some(toggle) = toggles.get(index as usize) { if let Some(toggle) = toggles.get(index as usize) {
toggle.set_property("label", label); toggle.set_property("label", shown);
toggle.set_property("tooltip", tooltip.unwrap_or_default());
} }
} }
Options::Buttons(buttons) => { Options::Buttons(buttons) => {
if let Some(button) = buttons.get(index as usize) { if let Some(button) = buttons.get(index as usize) {
button.set_label(label); match button.child().and_downcast::<adw::ButtonContent>() {
Some(face) => face.set_label(shown),
None => button.set_label(shown),
}
button.set_tooltip_text(tooltip.as_deref());
} }
} }
} }
@@ -271,6 +337,19 @@ fn shortcuts_dialog(groups: &[(String, Vec<(&str, String)>)]) -> adw::Dialog {
.build() .build()
} }
/// The labels of `(label, tooltip)` options.
fn labels_of(options: &[(&str, Option<&str>)]) -> Vec<String> {
options.iter().map(|(label, _)| label.to_string()).collect()
}
/// The tooltips of `(label, tooltip)` options.
fn tooltips_of(options: &[(&str, Option<&str>)]) -> Rc<[Option<String>]> {
options
.iter()
.map(|(_, tooltip)| tooltip.map(str::to_string))
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+1 -1
View File
@@ -478,7 +478,7 @@ pub(crate) fn text_column(
fn resize_grid_tile(thumbnail: &gtk4::Overlay, label: &gtk4::Label, size: i32) { fn resize_grid_tile(thumbnail: &gtk4::Overlay, label: &gtk4::Label, size: i32) {
resize_file_thumbnail(thumbnail, size, grid_fallback_size(size)); resize_file_thumbnail(thumbnail, size, grid_fallback_size(size));
let name_width = (size / 6 + 1).clamp(8, 24); let name_width = (size / 6 + 1).clamp(8, 40);
label.set_width_chars(name_width); label.set_width_chars(name_width);
label.set_max_width_chars(name_width); label.set_max_width_chars(name_width);
} }
+48 -10
View File
@@ -1,4 +1,4 @@
//! Shared thumbnails for ordinary Drive image files. //! Shared thumbnails for ordinary Drive image and video files.
//! //!
//! The Photos gallery has timeline-specific batching and aspect-ratio state. //! The Photos gallery has timeline-specific batching and aspect-ratio state.
//! File listings need a smaller, reusable surface: one loader backs My files, //! File listings need a smaller, reusable surface: one loader backs My files,
@@ -6,7 +6,7 @@
//! so a replaced image never flashes its previous contents. //! so a replaced image never flashes its previous contents.
use crate::*; use crate::*;
use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name}; use pdfs_core::control::{FileThumbRequest, is_thumbnail_media_name, is_video_file_name};
const THUMB_BATCH: usize = 32; const THUMB_BATCH: usize = 32;
const THUMB_CACHE_MAX: usize = 256; const THUMB_CACHE_MAX: usize = 256;
@@ -125,6 +125,18 @@ pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Over
.build(); .build();
picture.add_css_class("file-thumbnail"); picture.add_css_class("file-thumbnail");
// Over a video's frame, so it does not pass for a photo.
let play = gtk4::Image::builder()
.icon_name("media-playback-start-symbolic")
.pixel_size(16)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.can_target(false)
.visible(false)
.build();
play.add_css_class("photo-video-badge");
play.add_css_class("photo-video-badge-small");
let overlay = gtk4::Overlay::new(); let overlay = gtk4::Overlay::new();
overlay.set_width_request(size); overlay.set_width_request(size);
overlay.set_height_request(size); overlay.set_height_request(size);
@@ -133,9 +145,21 @@ pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Over
overlay.set_overflow(gtk4::Overflow::Hidden); overlay.set_overflow(gtk4::Overflow::Hidden);
overlay.set_child(Some(&fallback)); overlay.set_child(Some(&fallback));
overlay.add_overlay(&picture); overlay.add_overlay(&picture);
overlay.add_overlay(&play);
overlay overlay
} }
/// The play badge [`file_thumbnail_widget`] puts after `picture`.
fn play_badge(picture: &gtk4::Picture) -> Option<gtk4::Image> {
picture
.next_sibling()
.and_downcast::<gtk4::Image>()
.filter(|image| image.has_css_class("photo-video-badge"))
}
/// Marks a play badge to show once its video's frame is painted.
const VIDEO_BADGE: &str = "video";
/// Resize an existing thumbnail surface. Grid rows are recycled, so changing /// Resize an existing thumbnail surface. Grid rows are recycled, so changing
/// these requests in place and rebinding the model is enough to implement the /// these requests in place and rebinding the model is enough to implement the
/// status-bar zoom without downloading or decoding an image again. /// status-bar zoom without downloading or decoding an image again.
@@ -179,6 +203,16 @@ pub(crate) fn bind_file_thumbnail(
fallback.set_visible(true); fallback.set_visible(true);
picture.set_visible(false); picture.set_visible(false);
picture.set_paintable(gtk4::gdk::Paintable::NONE); picture.set_paintable(gtk4::gdk::Paintable::NONE);
// List rows are too small for a badge; their icon says video already.
let video = !entry.is_dir && is_video_file_name(&entry.name, Some(entry.size));
if let Some(play) = play_badge(&picture) {
play.set_visible(false);
play.set_widget_name(if video && !symbolic_fallback {
VIDEO_BADGE
} else {
""
});
}
// The widget name is a cheap binding token. List factories recycle their // The widget name is a cheap binding token. List factories recycle their
// children; a reply for the previous entry must not paint into the new one. // children; a reply for the previous entry must not paint into the new one.
@@ -186,7 +220,10 @@ pub(crate) fn bind_file_thumbnail(
picture.set_widget_name(&key); picture.set_widget_name(&key);
remove_waiter(ui, &picture); remove_waiter(ui, &picture);
if entry.is_dir || !is_image_name(&entry.name) || entry.uid.is_empty() { if entry.is_dir
|| !is_thumbnail_media_name(&entry.name, Some(entry.size))
|| entry.uid.is_empty()
{
return; return;
} }
if let Some(texture) = ui.file_thumbs.textures.borrow().get(&key) { if let Some(texture) = ui.file_thumbs.textures.borrow().get(&key) {
@@ -214,12 +251,6 @@ pub(crate) fn bind_file_thumbnail(
schedule_file_thumbs(ui); schedule_file_thumbs(ui);
} }
/// Whether the file name denotes an image format for which Proton or the local
/// decoder can reasonably provide a thumbnail.
pub(crate) fn is_image_name(name: &str) -> bool {
is_thumbnail_image_name(name)
}
fn thumbnail_key(uid: &str, modified: i64) -> String { fn thumbnail_key(uid: &str, modified: i64) -> String {
format!("{uid}:{modified}") format!("{uid}:{modified}")
} }
@@ -238,6 +269,9 @@ fn paint(picture: &gtk4::Picture, key: &str, texture: &gtk4::gdk::Texture) {
} }
picture.set_paintable(Some(texture)); picture.set_paintable(Some(texture));
picture.set_visible(true); picture.set_visible(true);
if let Some(play) = play_badge(picture) {
play.set_visible(play.widget_name() == VIDEO_BADGE);
}
if let Some(overlay) = picture.parent().and_downcast::<gtk4::Overlay>() if let Some(overlay) = picture.parent().and_downcast::<gtk4::Overlay>()
&& let Some(fallback) = overlay.child().and_downcast::<gtk4::Image>() && let Some(fallback) = overlay.child().and_downcast::<gtk4::Image>()
{ {
@@ -413,7 +447,11 @@ fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ReplyDisposition, is_image_name, reply_disposition}; use super::{ReplyDisposition, reply_disposition};
fn is_image_name(name: &str) -> bool {
pdfs_core::control::is_thumbnail_image_name(name)
}
#[test] #[test]
fn image_extensions_are_case_insensitive_and_specific() { fn image_extensions_are_case_insensitive_and_specific() {
+2 -2
View File
@@ -10,7 +10,7 @@ use std::time::Duration;
use adw::prelude::*; use adw::prelude::*;
use gtk4::{gio, glib}; use gtk4::{gio, glib};
use super::row::{fill_slot, thumb_slot}; use super::row::{content_type_for, fill_slot, thumb_slot};
use super::thumbs::Thumbs; use super::thumbs::Thumbs;
use crate::{Hit, dates, gettext, human_bytes, pgettext}; use crate::{Hit, dates, gettext, human_bytes, pgettext};
@@ -235,7 +235,7 @@ impl Preview {
let content_type = if hit.is_dir() { let content_type = if hit.is_dir() {
glib::GString::from("inode/directory") glib::GString::from("inode/directory")
} else { } else {
gio::functions::content_type_guess(Some(hit.name()), None).0 content_type_for(hit.name(), hit.size())
}; };
self.kind self.kind
.set_label(&gio::functions::content_type_get_description(&content_type)); .set_label(&gio::functions::content_type_get_description(&content_type));
+18 -5
View File
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
use adw::prelude::*; use adw::prelude::*;
use gtk4::{gdk, gio}; use gtk4::{gdk, gio, glib};
use super::highlight; use super::highlight;
use super::thumbs::Thumbs; use super::thumbs::Thumbs;
@@ -53,7 +53,7 @@ pub(crate) fn fill_slot(slot: &gtk4::Overlay, hit: &Hit, thumbs: &Rc<Thumbs>, mo
let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() else { let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() else {
return; return;
}; };
fallback.set_from_gicon(&icon_for(hit.name(), hit.is_dir())); fallback.set_from_gicon(&icon_for(hit.name(), hit.is_dir(), hit.size()));
fallback.set_visible(true); fallback.set_visible(true);
picture.set_visible(false); picture.set_visible(false);
picture.set_paintable(gdk::Paintable::NONE); picture.set_paintable(gdk::Paintable::NONE);
@@ -63,7 +63,7 @@ pub(crate) fn fill_slot(slot: &gtk4::Overlay, hit: &Hit, thumbs: &Rc<Thumbs>, mo
/// The desktop's own full-colour icon for a file name, so results look like /// The desktop's own full-colour icon for a file name, so results look like
/// the user's file manager. Folders are Proton's purple ones while the Proton /// the user's file manager. Folders are Proton's purple ones while the Proton
/// theme is on, as in the app's browser. /// theme is on, as in the app's browser.
pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon { pub(crate) fn icon_for(name: &str, is_dir: bool, size: u64) -> gio::Icon {
if is_dir { if is_dir {
let folder = if proton_theme_active() { let folder = if proton_theme_active() {
"pdfs-proton-folder" "pdfs-proton-folder"
@@ -72,8 +72,21 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
}; };
return gio::ThemedIcon::new(folder).upcast(); return gio::ThemedIcon::new(folder).upcast();
} }
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None); gio::functions::content_type_get_icon(&content_type_for(name, size))
gio::functions::content_type_get_icon(&content_type) }
/// The content type of a file named `name` of `size` bytes, guessed from the
/// name. GIO takes every `.ts` for TypeScript (or Qt translations); a
/// recording's size says otherwise (see
/// [`is_video_file_name`](pdfs_core::control::is_video_file_name)).
pub(crate) fn content_type_for(name: &str, size: u64) -> glib::GString {
let transport_stream = std::path::Path::new(name)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("ts"));
if transport_stream && pdfs_core::control::is_video_file_name(name, Some(size)) {
return glib::GString::from("video/mp2t");
}
gio::functions::content_type_guess(Some(name), None).0
} }
/// The sync badge of a Drive file, as in the app's browser: pinned, cached, /// The sync badge of a Drive file, as in the app's browser: pinned, cached,
+12 -4
View File
@@ -9,8 +9,8 @@
//! matches the file's; //! matches the file's;
//! 2. for local images, decoding the file itself, and writing the result back //! 2. for local images, decoding the file itself, and writing the result back
//! to that cache for the next time and for every other application; //! to that cache for the next time and for every other application;
//! 3. for Drive images, the daemon's `FileThumbs`, batched after a short quiet //! 3. for Drive images and videos, the daemon's `FileThumbs`, batched after a
//! period. //! short quiet period.
//! //!
//! Workers hand back `gdk::Texture`s, never a `Pixbuf`, which may not cross //! Workers hand back `gdk::Texture`s, never a `Pixbuf`, which may not cross
//! threads. Each job carries the render epoch it was queued in, and workers //! threads. Each job carries the render epoch it was queued in, and workers
@@ -30,7 +30,9 @@ use gtk4::gdk_pixbuf::Pixbuf;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{gdk, glib}; use gtk4::{gdk, glib};
use pdfs_core::control::{FileThumbRequest, Request, Response, is_thumbnail_image_name}; use pdfs_core::control::{
FileThumbRequest, Request, Response, is_thumbnail_image_name, is_thumbnail_media_name,
};
use crate::{Hit, spawn_request}; use crate::{Hit, spawn_request};
@@ -165,7 +167,13 @@ impl Thumbs {
// The widget name binds the picture to one key: a late reply for // The widget name binds the picture to one key: a late reply for
// another hit must not paint into it. // another hit must not paint into it.
picture.set_widget_name(&key); picture.set_widget_name(&key);
if hit.is_dir() || !is_thumbnail_image_name(hit.name()) { // A video's frame comes from the daemon, which has ffmpeg's help; a
// local video has only what another application cached.
let thumbnailed = match hit {
Hit::Drive(drive) => is_thumbnail_media_name(&drive.name, Some(drive.size)),
Hit::Local(_) => is_thumbnail_image_name(hit.name()),
};
if hit.is_dir() || !thumbnailed {
return; return;
} }
if matches!(hit, Hit::Drive(drive) if drive.uid.is_empty()) { if matches!(hit, Hit::Drive(drive) if drive.uid.is_empty()) {
+1 -1
View File
@@ -94,7 +94,7 @@ pub(crate) fn icon_name(hit: &Hit) -> String {
let name = hit.name(); let name = hit.name();
if is_image(name) { if is_image(name) {
"image-x-generic" "image-x-generic"
} else if is_media(name) { } else if is_media(name) || pdfs_core::control::is_video_file_name(name, Some(hit.size())) {
"video-x-generic" "video-x-generic"
} else if is_document(name) { } else if is_document(name) {
"text-x-generic" "text-x-generic"
+3
View File
@@ -52,6 +52,7 @@ const STOCK: &[&str] = &[
"image-missing-symbolic", "image-missing-symbolic",
"image-x-generic-symbolic", "image-x-generic-symbolic",
"list-add-symbolic", "list-add-symbolic",
"list-remove-symbolic",
"mark-location-symbolic", "mark-location-symbolic",
"media-playback-pause-symbolic", "media-playback-pause-symbolic",
"media-playback-start-symbolic", "media-playback-start-symbolic",
@@ -61,10 +62,12 @@ const STOCK: &[&str] = &[
"object-select-symbolic", "object-select-symbolic",
"preferences-system-symbolic", "preferences-system-symbolic",
"process-stop-symbolic", "process-stop-symbolic",
"selection-mode-symbolic",
"starred-symbolic", "starred-symbolic",
"system-search-symbolic", "system-search-symbolic",
"text-x-generic-symbolic", "text-x-generic-symbolic",
"user-trash-symbolic", "user-trash-symbolic",
"video-x-generic-symbolic",
"view-grid-symbolic", "view-grid-symbolic",
"view-list-symbolic", "view-list-symbolic",
"view-more-symbolic", "view-more-symbolic",
-1
View File
@@ -467,7 +467,6 @@ both with `pdfs_core::search::relevance_score`, so the prompt can merge them int
| `pdfs-sync`, `pdfs-sync-poll` | Mirror passes and the remote poll (§7) | | `pdfs-sync`, `pdfs-sync-poll` | Mirror passes and the remote poll (§7) |
| Tokio runtime | Remote calls, and the Drive and photos event streams (§6) | | Tokio runtime | Remote calls, and the Drive and photos event streams (§6) |
| `pdfs-online-probe` | Probes Drive while offline | | `pdfs-online-probe` | Probes Drive while offline |
| `pdfs-pin-keeper` | Checks pins once a second for work: every pin at startup, when the link returns and after the event feed loses continuity, and the pins a batch of remote events changed. Downloads files kept offline that are missing or stale; failures retry with a backoff of 30 s to 30 min (`docs/BUGS.md` B282) |
| `pdfs-supervisor` | Logs workers that hold one job for minutes, a queue that stops draining, and control requests that never return; samples memory; pings the systemd watchdog only after a round trip over the control socket succeeds (`WatchdogSec=120`) | | `pdfs-supervisor` | Logs workers that hold one job for minutes, a queue that stops draining, and control requests that never return; samples memory; pings the systemd watchdog only after a round trip over the control socket succeeds (`WatchdogSec=120`) |
| `pdfs-conflict-sweep` | Optional. After 30 s, then every 5 minutes, finds `(sync-conflict …)` copies identical (size and SHA-1) to their original. `conflict_sweep` decides: `off`, `report` (default) or `enforce`, which trashes them after re-checking each one | | `pdfs-conflict-sweep` | Optional. After 30 s, then every 5 minutes, finds `(sync-conflict …)` copies identical (size and SHA-1) to their original. `conflict_sweep` decides: `off`, `report` (default) or `enforce`, which trashes them after re-checking each one |
| Smaller helpers | `pdfs-localindex` (home index for search), `pdfs-pause` (ends a timed pause), `pdfs-similar` (similar photos, on request), `pdfs-diagnostics` | | Smaller helpers | `pdfs-localindex` (home index for search), `pdfs-pause` (ends a timed pause), `pdfs-similar` (similar photos, on request), `pdfs-diagnostics` |
+37 -65
View File
@@ -109,8 +109,7 @@ a live account since.
| Entry | Title | | Entry | Title |
|---|---| |---|---|
| [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 | | [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 |
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
| [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed | | [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed |
| [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back | | [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back |
| [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds | | [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds |
@@ -385,75 +384,48 @@ a live account since.
Newest first. Newest first.
### B282 — A file kept offline is downloaded again after a change made elsewhere ### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
**Status:** Fixed, not verified live **Status:** Fixed, not verified live
**Found:** 2026-11-13, while looking into B281. Nothing downloaded a pinned file again after the **Found:** 2026-11-14, from a user report: large `.ts` video recordings in My files showed a text
pin had downloaded it once. icon, and no video in My files had a thumbnail although the same videos had one in Photos.
**Where:** `apply_event` in `crates/pdfs-fuse/src/background.rs`; the new pin keeper in **Where:** `is_video_file_name` in `crates/pdfs-core/src/control.rs`; `generate_thumbs` and
`crates/pdfs-fuse/src/keeper.rs` `sample_video` in `crates/pdfs-fuse/src/photos.rs`; `bind_file_thumbnail` in
`crates/pdfs-gui/src/app/widgets/thumbnails.rs`
**Repro:** Pin a file, then change it from another device. The event deletes the cached copy. **Repro:** Put an `.mp4` and a recorded `.ts` of a few GB in My files and open the folder in the
The file stays listed as kept offline, `cached` is false, and the next read downloads it, or app's grid view. Both show a generic icon, and the `.ts` shows a text one.
fails without a network. Rename or move it elsewhere and the same happens, though its content
did not change. Add a file to a pinned folder from another device and it is never downloaded.
A folder pin whose downloads failed while the link was down never retries them.
**Cause:** A remote change to a known node called `ContentCache::evict`, which does not check **Cause.** Four things:
pins, and only the pin request itself downloaded anything. The copy was also stored under the - File views asked the daemon for thumbnails of images only (`is_thumbnail_image_name`), so a
size the listing held at pin time. That is the ciphertext size until the size upgrade lands video was never asked for.
(B12), and a copy stored under it never matches the plaintext it holds. - `.ts` is TypeScript as often as it is an MPEG transport stream. Drive stores both as
`application/octet-stream`, and every place that names a file's type went by the extension:
the browser's icon list had no `.ts`, GIO guesses text, and the opener's `@text` class listed
`.ts`.
- The daemon downloaded a video whole into memory for one frame, so it refused anything over
256 MiB, and the size check behind that refusal asked the Photos client, which knows nothing
of a My files node.
- `file_thumbs` never asked for the thumbnail an uploader attached, on the belief that only
Photos nodes have one. This client attaches one to every upload it can make one for.
**Fix:** A remote change no longer evicts a pinned file's copy. The copy is checked against the **Fix.** `is_video_file_name(name, size)` is the one rule: a video extension, and for `.ts` a
revision's `(mtime, size)`, so a read of a newer revision does not take it. A rename keeps the size of at least 1 MiB. The browser icon, the thumbnail gate, the launcher's icon and type, and
revision's mtime (B123), so the copy stays valid. A pin keeper thread checks pins again and the opener's classes use it. For a file view, the daemon first asks Drive for the file's own
downloads files that are missing or stale: thumbnail. Without one, a video of up to 24 MiB is downloaded whole. A larger one brings its first
16 MiB, and for an MP4 or MOV whose `moov` box lies further in, the box headers on the way and
that box. The parts are laid out at their offsets in a sparse staging file, which ffmpeg reads.
A `.ts` whose head lacks the 0x47 sync byte is recorded as having no thumbnail.
- the pins a batch of remote events changed, after the batch's listings are dropped; **Test:** `a_ts_file_is_a_video_only_at_the_size_of_one` and
- a pinned folder that gets a new file; `listings_thumbnail_images_and_videos` in `crates/pdfs-core/src/control.rs`;
- every pin at startup, when the link comes back, and after the event feed loses continuity; `a_ts_file_opens_as_media_or_text_by_its_size` in `crates/pdfs-core/src/opener.rs`;
- a file whose version is restored. `a_large_mp4_downloads_its_head_and_its_index` and the other `sample_video` tests in
`crates/pdfs-fuse/src/photos.rs`; the simulation
Failed downloads are tried again with a backoff from 30 s to 30 min. Pinning settles a `a_video_thumbnail_downloads_a_small_part_of_the_video`, which reads under 34 MiB for two
provisional size before it stores the copy. videos of over 70 MiB and takes nothing from a video with an uploader's thumbnail.
`real_video_fixture_yields_a_frame` passed on 2026-11-14 with ffmpeg 9.0 for a 46 MB MP4 with its
**Test:** `a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere` in index at the end, an MPEG-TS and an MKV.
`crates/pdfs-fuse/src/sim/daemon.rs`. Another device edits a pinned file and a file in a pinned
folder, and adds a file to that folder. The test waits until all three are cached, then reads
them with the link down. With the keeper disabled, it fails with all three uncached.
---
### B281 — A file in a synced folder cannot be kept offline
**Status:** Fixed, not verified live
**Found:** 2026-11-13. An image was made available offline in the app, and it opened from the app
with no download. `feh` opened it from a terminal and downloaded it each time. The two were not
the same file. The app had kept `~/ProtonDrive/Pictures/photo.png`, in My files. The
terminal opened `~/Pictures/photo.png`, in the on-demand synced folder `~/Pictures`. That
copy is a separate node under Computers, uploaded on its own, with the same name and size but a
different mtime. The daemon treats every program the same way: it does not look at the process
that reads.
**Where:** the `Pin` and `Unpin` handlers in `crates/pdfs-fuse/src/control.rs`
**Repro:** Add a synced folder in on-demand mode and put a file in it. `pdfs pin` on its absolute
path fails with "is not under the mountpoint". A relative path is resolved against My files, so
it keeps a file of the same name there, if one exists, or fails. Reading the file in the synced
folder then downloads it.
**Cause:** Both handlers turned the path into a mount-relative one with `rel_to_mount`, which only
knows the primary mount. B86 moved `ls`, `refresh`, `rm` and `mv` to `route_to_mount`, which finds
the mount that serves an absolute path. Pin and unpin were left out.
**Fix:** Pin and unpin route through `route_to_mount` and run on the mount that owns the path. A
path in a mirror folder gets an error that says its files are already on this computer. The pin
registry records the absolute local path for a pin made through an on-demand folder, so
`pdfs pins`, the prompt and the HUD open the file through the folder it was kept from. Pins
under the primary mount still record the path relative to it.
**Test:** `pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy` in
`crates/pdfs-fuse/src/sim/daemon.rs`. It pins a file through an on-demand folder's absolute path,
takes the link down, and reads the file through that folder.
--- ---
+55 -47
View File
@@ -11,60 +11,68 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
## [3.8.0] — 2026-11-12 ### Changed
Schema: **45**. Synced folders now tell device backups apart from folders attached to My files. - Update `proton-sdk` and `proton-drive-rs` to 0.11.0.
Existing folders stay device backups. Older builds cannot open the upgraded database. Convert epoch timestamps at the SDK boundary and explicitly preserve photo modification
times now that they are independent of capture times. See [the SDK review](SDK-0.11.0.md)
for features and breaking changes.
Schema: **45**. Synced folders now distinguish device backups from My files attachments.
Existing folders remain device backups; older builds cannot open the upgraded database.
### Added ### Added
- **Sync a local folder with a folder you already have in My files** (#67, #77). - Shell completion for `pdfs` in bash, zsh and fish, installed by every package, plus elvish
`pdfs sync attach ~/Work --to Work` keeps `~/Work` an ordinary local folder and syncs it both and PowerShell through `pdfs completions SHELL`. Besides commands and options it completes
ways with My files/Work. The first sync uploads files that are only on this computer, downloads paths in My files and the ids and uids the running service knows (synced folders, the queue,
files that are only on Proton Drive, and keeps both versions of a file that differs: the one on computers, trash, albums, shares, invitations, bookmarks, conflicts and versions), each with
Proton Drive keeps its name, and the local one becomes a conflict copy next to it and is a description. Share roles are now checked by the CLI (`viewer`, `editor`, `admin`) before
uploaded too. Attached folders are always kept on this computer, never online only. They are the request.
listed apart from device backups and are not part of the device profile used to restore another - `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing
computer. Stopping one keeps both copies, and `--delete-remote` is refused for it. If the folder My files folder. The first sync preserves one-sided files and differing versions (#67).
on Proton Drive is trashed or moved out of My files, syncing stops with an error and the local Attachments stay in mirror mode, are excluded from device profiles, and cannot delete
files stay. Attaching a folder is done from a terminal for now. the remote root when syncing is stopped.
- **Video thumbnails in My files, Shared, Trash and the launcher**, as in Photos, with a play
- **Tab completion for `pdfs`** in bash, zsh and fish, installed by every package. Besides badge in the grid. A video's thumbnail is the one its uploader attached when it has one.
commands and options it completes paths in My files (`pdfs pin Doc<Tab>`) and what the running Otherwise the daemon takes a frame with ffmpeg from the start of the file, plus an MP4's
service knows, each with a description: synced folders, queued changes, computers, trash, albums, index, and never downloads more than about 16 MiB of a video for it, however large the
items shared with you, invitations, bookmarks and conflict copies, and once a path is typed, the video is. **Build Thumbnails** covers videos too (B283).
file's versions or the share's members and link. Without the service it still completes commands - **A new layout for Photos.**
and options. Elvish and PowerShell load it from `pdfs completions SHELL`, and so does a source - The grid shows every photo at its own shape, in rows that fill the width (portraits
build; the [command-line reference](CLI.md#shell-completion) has the line for each shell. are no longer cropped to squares). **Square Tiles** in the page menu brings the even
`pdfs share` and `pdfs share-role` now check the role (`viewer`, `editor` or `admin`) before grid back, and the Year zoom stays square.
sending the request, and `pdfs public-link` does the same for `viewer` and `editor`. - Timeline, Albums and Places switch in the header. The filters take one row, which
scrolls sideways in a narrow window.
- The section you are in floats over the top of the grid once its heading has scrolled
away. Headings are larger, and a check beside each one selects the whole day, month or
year.
- Selection mode works as in other GNOME apps: the header shows the count and
**Cancel**, and a bar along the bottom has these actions:
- **Add to Favorites**, or remove them when every selected photo is already one;
- **Add to Album**;
- **Remove from Album**;
- **Save Copies**, into a folder you pick, never overwriting a file;
- **Move to Trash**.
- Favorites carry a star on their tile, and a video waiting for its thumbnail shows a
video glyph.
- New icons for Photos, albums and the slideshow.
- **Upload** takes several photos and videos at once, and you can drop files onto the
timeline to upload them.
- The zoom level and the tile shape are remembered between runs.
- In the photo viewer, the details panel lies over the photo when the window is
narrow instead of squeezing it.
- **Larger icons in My files.** The icon size slider goes up to 256 px, big enough to
tell photos apart by their thumbnail. Ctrl+scroll, Ctrl+plus, Ctrl+minus and Ctrl+0
change it too, and the size is remembered between runs.
### Fixed ### Fixed
- **My files comes back after the service was killed for hanging** (B279). A killed service left - Large `.ts` recordings (MPEG transport streams) show and open as videos: the video icon, a
its folders behind as dead mounts, and every restart then failed with `File exists` and retried thumbnail, and the `@media` open rule rather than `@text`. A `.ts` of 1 MiB or more counts
forever without opening anything, until the mounts were cleared by hand. The service now clears as a video, a smaller one stays TypeScript source, and the daemon checks the stream's sync
a dead mount before it opens the folder. On a large account a restart that did open My files was byte before it takes a frame (B283).
killed again, because loading the account took longer than the 2 minutes the service has to - A video over 256 MiB never got a thumbnail, and a smaller one was downloaded whole into
report in. Startup now gets 10 minutes, and the 2 minutes apply again once the service answers. memory for one frame (B283).
- **Uploading many large photos no longer runs the computer out of memory** (B280). Every image
upload decodes the whole picture to make its thumbnail, and up to 16 did so at once. A batch of
100-megapixel photos took about 9 GiB, filled the swap, and the service was killed on every
restart. Thumbnails now share 512 MiB between them: phone photos are still made side by side,
and a very large photo waits and is made on its own.
- **Keep a file offline in an on-demand synced folder** (B281). `pdfs pin` and `pdfs unpin` now
accept a path in a synced folder such as `~/Pictures`. Before, they only took paths under the
Proton Drive mount, so the copy in the synced folder was downloaded again by every program that
read it.
- **Files kept offline stay on this computer when they change elsewhere** (B282). A change from
another device, even a rename, deleted the copy of a file kept offline, and nothing downloaded
it again, so the next read downloaded it or failed without a network. Files added to a folder
kept offline from another device were never downloaded. The copy now stays until the new
version is downloaded, which happens on its own. Files kept offline are also checked at
startup and when the connection returns, and failed downloads are tried again.
## [3.7.0] — 2026-11-02 ## [3.7.0] — 2026-11-02
+1 -1
View File
@@ -102,7 +102,7 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). PATH can be under the Proton Drive mount or in an on-demand synced folder. A file in a mirror folder is already on this computer. | | `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. | | `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
| `pdfs pins` | List files and folders kept available offline. | | `pdfs pins` | List files and folders kept available offline. |
+1 -1
View File
@@ -102,7 +102,7 @@ That URL follows `main`. To stay on releases, pin the input to a release tag ins
the tag to update: the tag to update:
```nix ```nix
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.8.0"; inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.7.0";
``` ```
Each release tag is built with Nix before the release is published. Each release tag is built with Nix before the release is published.
+60
View File
@@ -0,0 +1,60 @@
# SDK 0.11.0 review
Reviewed the sibling `../proton-sdk-rs` checkout at `84039a1` against 0.10.1
(`48dd410`), including its changelog and source diff.
## Features
- Folder creation, streaming uploads, draft replacement, and revision uploads
preserve the supplied timestamp's UTC offset and fractional seconds.
- `ModificationTime` is exported as an alias for
`chrono::DateTime<chrono::FixedOffset>`.
- Photo modification time can be set independently of capture time. Downloaded
claimed modification timestamps remain verbatim strings.
- Neither SDK crate defines Cargo feature flags. No feature opt-in is needed.
The Drive crate now depends directly on `chrono` 0.4; the SDK already used it.
The minimum Rust version remains 1.96, matching this workspace.
## Breaking changes and adaptation
The modification-time argument changes from `Option<i64>` to
`Option<ModificationTime>` on `create_folder`, `upload_file_from`,
`upload_file_replacing_draft_from`, `upload_new_revision`, and
`upload_new_revision_from`. `None` remains valid.
The real `DriveApi` adapter converts the client's stored epoch seconds to UTC
through `pdfs_core::timestamp::modification_time`. Internal storage, the trait,
and the simulated Drive continue using epoch seconds. Out-of-range timestamps
are omitted rather than panicking. Existing upload paths still supply whole
seconds; this bump alone does not add subsecond storage or offset tracking.
`PhotoUploadMetadata` gains `last_modification_time`, and the SDK no longer
copies `capture_time` into it. Our literals already use `Default`, so this would
compile while silently changing uploaded metadata. Control uploads, Takeout
imports (including the EXIF fallback), and redating now set both explicitly to
preserve the previous behavior when a capture time is known. Without a known
capture time, modification time stays omitted as before.
No other public API changes were found in the reviewed diff. No database or
recovery migration is required by this SDK bump.
## Dependencies
The workspace requires the published 0.11.0 versions of both crates from
crates.io. No local patches or sibling checkout are needed to build it.
## Verification
The published crates' source files match the reviewed sibling checkout at
`84039a1`. On the final branch based on `main`, these checks passed:
```sh
cargo test --workspace --locked
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
```
The test suite reported 1,135 passed and 67 ignored; ignored tests were not run.
Tests ran outside the sandbox because control-socket tests require socket
permissions. No live account or mounted-filesystem acceptance testing was
performed.
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=3.8.0 pkgver=3.7.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')
+1 -1
View File
@@ -10,7 +10,7 @@
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice. # Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
%global _lto_cflags %{nil} %global _lto_cflags %{nil}
%{!?version: %global version 3.8.0} %{!?version: %global version 3.7.0}
%global forge https://github.com/narrrl/proton-drive-linux %global forge https://github.com/narrrl/proton-drive-linux
# Files come from the checkout, or from the unpacked tag archive. # Files come from the checkout, or from the unpacked tag archive.
%global src %{?git_dir}%{!?git_dir:.} %global src %{?git_dir}%{!?git_dir:.}
+905 -796
View File
File diff suppressed because it is too large. Load diff
+916 -807
View File
File diff suppressed because it is too large. Load diff
+904 -795
View File
File diff suppressed because it is too large. Load diff
+905 -796
View File
File diff suppressed because it is too large. Load diff
+903 -796
View File
File diff suppressed because it is too large. Load diff
+905 -796
View File
File diff suppressed because it is too large. Load diff
+887 -789
View File
File diff suppressed because it is too large. Load diff
+919 -804
View File
File diff suppressed because it is too large. Load diff
+904 -795
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. # Too long or too internal for the wiki; links to them go to GitHub.
EXCLUDED = {"BUGS.md", "CHANGELOG.md"} EXCLUDED = {"BUGS.md", "CHANGELOG.md", "SDK-0.11.0.md"}
# Files outside docs/ whose links are checked but not published. # Files outside docs/ whose links are checked but not published.
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"] CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]