mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 09:27:04 +01:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57acb0b849 | ||
|
|
68ff8c9e80 | ||
|
|
4552093c53 | ||
|
|
f2a9a0f64b | ||
|
|
e571f22d5d | ||
|
|
1c50797772 | ||
|
|
0183f17435 | ||
|
|
14a663cee9 | ||
|
|
3f45e89391 | ||
|
|
b49631e18f | ||
|
|
ad648ab5cc | ||
|
|
2b95708260 |
No files matched your search
Generated
+4
-4
@@ -3541,7 +3541,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3558,7 +3558,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3586,7 +3586,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3608,7 +3608,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|||||||
+1
-1
@@ -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.7.0"
|
version = "3.8.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -157,6 +157,17 @@ 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 {
|
||||||
@@ -172,9 +183,12 @@ 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 {
|
if had_node && kept.is_none() {
|
||||||
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`].
|
||||||
@@ -205,6 +219,8 @@ 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();
|
||||||
@@ -296,6 +312,21 @@ 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
|
||||||
@@ -476,6 +507,9 @@ 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
|
||||||
|
|||||||
@@ -204,18 +204,42 @@ 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 mirrored = core
|
let in_mirror = 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 mirrored {
|
if in_mirror {
|
||||||
CoreError::invalid(format!(
|
CoreError::invalid(mirrored(path))
|
||||||
"{path} is in a mirrored folder; change the local file there instead"
|
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
e
|
e
|
||||||
}
|
}
|
||||||
@@ -231,6 +255,18 @@ fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
@@ -521,8 +557,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 rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Pin { path }) => match route_keep(core, mountpoint, &path) {
|
||||||
Ok(rel) => match core.pin(&rel) {
|
Ok((routed, rel)) => match routed.pin(&rel, &pin_record(&routed, &rel, &path)) {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
core.touch_profile();
|
core.touch_profile();
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -533,8 +569,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 rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Unpin { path }) => match route_keep(core, mountpoint, &path) {
|
||||||
Ok(rel) => match core.unpin(&rel) {
|
Ok((routed, rel)) => match routed.unpin(&rel) {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
core.touch_profile();
|
core.touch_profile();
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -445,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,
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
//! 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-17
@@ -91,6 +91,7 @@ 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;
|
||||||
@@ -589,6 +590,8 @@ 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>,
|
||||||
@@ -4270,7 +4273,12 @@ 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();
|
||||||
@@ -4294,7 +4302,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, rel, true)
|
.add_pin(&uid, recorded, 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();
|
||||||
@@ -4304,9 +4312,15 @@ 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, rel, false)
|
.add_pin(&uid, recorded, false)
|
||||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||||
Ok(name)
|
Ok(name)
|
||||||
}
|
}
|
||||||
@@ -4324,14 +4338,20 @@ 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 (uid, file, mtime, size) in files {
|
for ino in files {
|
||||||
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job.detail(file.clone());
|
// A file with a queued write, or no readable size, is
|
||||||
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
|
// left to the drain and the keeper (`keeper::kept_file`).
|
||||||
warn!(%uid, error = %e, "pin folder: download failed");
|
if let Some(file) = self.kept_file(ino) {
|
||||||
failed += 1;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
job.step();
|
job.step();
|
||||||
}
|
}
|
||||||
@@ -4365,9 +4385,9 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every file in the subtree rooted at folder `ino`.
|
/// The inode of every file in the subtree rooted at folder `ino`.
|
||||||
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
|
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<u64>> {
|
||||||
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
|
let mut files: Vec<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() {
|
||||||
@@ -4382,12 +4402,7 @@ impl Core {
|
|||||||
if e.node.is_folder() {
|
if e.node.is_folder() {
|
||||||
stack.push(k);
|
stack.push(k);
|
||||||
} else {
|
} else {
|
||||||
files.push((
|
files.push(k);
|
||||||
e.uid.clone(),
|
|
||||||
e.node.name.clone(),
|
|
||||||
e.node.modification_time,
|
|
||||||
node_size(&e.node),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,9 @@ 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.
|
||||||
|
|||||||
@@ -511,6 +511,7 @@ 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())),
|
||||||
@@ -621,6 +622,17 @@ 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) {
|
||||||
|
|||||||
+428
-68
@@ -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,
|
),
|
||||||
)
|
Fetched::Whole(bytes) => scale_thumbnail(&bytes, &name, &staging_dir),
|
||||||
} else {
|
Fetched::Nothing => ScaleAttempt::Unavailable,
|
||||||
scale_thumbnail(&bytes, &name, &staging_dir)
|
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"),
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ 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,
|
||||||
|
|||||||
@@ -3447,6 +3447,118 @@ 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) {
|
||||||
@@ -3619,4 +3731,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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: >k4::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: >k4::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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+1064
-217
File diff suppressed because it is too large.
Load diff
@@ -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).
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ pub(crate) fn text_column(
|
|||||||
|
|
||||||
fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) {
|
fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: >k4::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: >k4::Picture, key: &str, texture: >k4::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() {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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: >k4::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: >k4::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,
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -467,6 +467,7 @@ 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` |
|
||||||
|
|||||||
+118
@@ -109,6 +109,9 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
|
||||||
|
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
|
||||||
|
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
|
||||||
| [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 |
|
||||||
@@ -383,6 +386,121 @@ a live account since.
|
|||||||
|
|
||||||
Newest first.
|
Newest first.
|
||||||
|
|
||||||
|
### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-14, from a user report: large `.ts` video recordings in My files showed a text
|
||||||
|
icon, and no video in My files had a thumbnail although the same videos had one in Photos.
|
||||||
|
**Where:** `is_video_file_name` in `crates/pdfs-core/src/control.rs`; `generate_thumbs` and
|
||||||
|
`sample_video` in `crates/pdfs-fuse/src/photos.rs`; `bind_file_thumbnail` in
|
||||||
|
`crates/pdfs-gui/src/app/widgets/thumbnails.rs`
|
||||||
|
|
||||||
|
**Repro:** Put an `.mp4` and a recorded `.ts` of a few GB in My files and open the folder in the
|
||||||
|
app's grid view. Both show a generic icon, and the `.ts` shows a text one.
|
||||||
|
|
||||||
|
**Cause.** Four things:
|
||||||
|
- File views asked the daemon for thumbnails of images only (`is_thumbnail_image_name`), so a
|
||||||
|
video was never asked for.
|
||||||
|
- `.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.** `is_video_file_name(name, size)` is the one rule: a video extension, and for `.ts` a
|
||||||
|
size of at least 1 MiB. The browser icon, the thumbnail gate, the launcher's icon and type, and
|
||||||
|
the opener's classes use it. For a file view, the daemon first asks Drive for the file's own
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Test:** `a_ts_file_is_a_video_only_at_the_size_of_one` and
|
||||||
|
`listings_thumbnail_images_and_videos` in `crates/pdfs-core/src/control.rs`;
|
||||||
|
`a_ts_file_opens_as_media_or_text_by_its_size` in `crates/pdfs-core/src/opener.rs`;
|
||||||
|
`a_large_mp4_downloads_its_head_and_its_index` and the other `sample_video` tests in
|
||||||
|
`crates/pdfs-fuse/src/photos.rs`; the simulation
|
||||||
|
`a_video_thumbnail_downloads_a_small_part_of_the_video`, which reads under 34 MiB for two
|
||||||
|
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
|
||||||
|
index at the end, an MPEG-TS and an MKV.
|
||||||
|
|
||||||
|
### B282 — A file kept offline is downloaded again after a change made elsewhere
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-13, while looking into B281. Nothing downloaded a pinned file again after the
|
||||||
|
pin had downloaded it once.
|
||||||
|
**Where:** `apply_event` in `crates/pdfs-fuse/src/background.rs`; the new pin keeper in
|
||||||
|
`crates/pdfs-fuse/src/keeper.rs`
|
||||||
|
|
||||||
|
**Repro:** Pin a file, then change it from another device. The event deletes the cached copy.
|
||||||
|
The file stays listed as kept offline, `cached` is false, and the next read downloads it, or
|
||||||
|
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
|
||||||
|
pins, and only the pin request itself downloaded anything. The copy was also stored under the
|
||||||
|
size the listing held at pin time. That is the ciphertext size until the size upgrade lands
|
||||||
|
(B12), and a copy stored under it never matches the plaintext it holds.
|
||||||
|
|
||||||
|
**Fix:** A remote change no longer evicts a pinned file's copy. The copy is checked against the
|
||||||
|
revision's `(mtime, size)`, so a read of a newer revision does not take it. A rename keeps the
|
||||||
|
revision's mtime (B123), so the copy stays valid. A pin keeper thread checks pins again and
|
||||||
|
downloads files that are missing or stale:
|
||||||
|
|
||||||
|
- the pins a batch of remote events changed, after the batch's listings are dropped;
|
||||||
|
- a pinned folder that gets a new file;
|
||||||
|
- every pin at startup, when the link comes back, and after the event feed loses continuity;
|
||||||
|
- a file whose version is restored.
|
||||||
|
|
||||||
|
Failed downloads are tried again with a backoff from 30 s to 30 min. Pinning settles a
|
||||||
|
provisional size before it stores the copy.
|
||||||
|
|
||||||
|
**Test:** `a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere` in
|
||||||
|
`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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
|
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
|
|||||||
+99
-17
@@ -11,27 +11,109 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
## [3.8.0] — 2026-11-14
|
||||||
|
|
||||||
- Update `proton-sdk` and `proton-drive-rs` to 0.11.0.
|
Schema: **45**. Synced folders now tell device backups apart from folders attached to My files.
|
||||||
Convert epoch timestamps at the SDK boundary and explicitly preserve photo modification
|
Existing folders stay device backups. Older builds cannot open the upgraded database.
|
||||||
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
|
||||||
|
|
||||||
- Shell completion for `pdfs` in bash, zsh and fish, installed by every package, plus elvish
|
- **Sync a local folder with a folder you already have in My files** (#67, #77).
|
||||||
and PowerShell through `pdfs completions SHELL`. Besides commands and options it completes
|
`pdfs sync attach ~/Work --to Work` keeps `~/Work` an ordinary local folder and syncs it both
|
||||||
paths in My files and the ids and uids the running service knows (synced folders, the queue,
|
ways with My files/Work. The first sync uploads files that are only on this computer, downloads
|
||||||
computers, trash, albums, shares, invitations, bookmarks, conflicts and versions), each with
|
files that are only on Proton Drive, and keeps both versions of a file that differs: the one on
|
||||||
a description. Share roles are now checked by the CLI (`viewer`, `editor`, `admin`) before
|
Proton Drive keeps its name, and the local one becomes a conflict copy next to it and is
|
||||||
the request.
|
uploaded too. Attached folders are always kept on this computer, never online only. They are
|
||||||
- `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing
|
listed apart from device backups and are not part of the device profile used to restore another
|
||||||
My files folder. The first sync preserves one-sided files and differing versions (#67).
|
computer. Stopping one keeps both copies, and `--delete-remote` is refused for it. If the folder
|
||||||
Attachments stay in mirror mode, are excluded from device profiles, and cannot delete
|
on Proton Drive is trashed or moved out of My files, syncing stops with an error and the local
|
||||||
the remote root when syncing is stopped.
|
files stay. Attaching a folder is done from a terminal for now.
|
||||||
|
|
||||||
|
- **Tab completion for `pdfs`** in bash, zsh and fish, installed by every package. Besides
|
||||||
|
commands and options it completes paths in My files (`pdfs pin Doc<Tab>`) and what the running
|
||||||
|
service knows, each with a description: synced folders, queued changes, computers, trash, albums,
|
||||||
|
items shared with you, invitations, bookmarks and conflict copies, and once a path is typed, the
|
||||||
|
file's versions or the share's members and link. Without the service it still completes commands
|
||||||
|
and options. Elvish and PowerShell load it from `pdfs completions SHELL`, and so does a source
|
||||||
|
build; the [command-line reference](CLI.md#shell-completion) has the line for each shell.
|
||||||
|
`pdfs share` and `pdfs share-role` now check the role (`viewer`, `editor` or `admin`) before
|
||||||
|
sending the request, and `pdfs public-link` does the same for `viewer` and `editor`.
|
||||||
|
|
||||||
|
- **Video thumbnails in My files, Shared, Trash and the launcher**, as in Photos, with a play
|
||||||
|
badge in the grid. A video's thumbnail is the one its uploader attached when it has one.
|
||||||
|
Otherwise the daemon takes a frame with ffmpeg from the start of the file, plus an MP4's
|
||||||
|
index, and never downloads more than about 16 MiB of a video for it, however large the
|
||||||
|
video is. **Build Thumbnails** covers videos too (B283).
|
||||||
|
|
||||||
|
- **A new layout for Photos.**
|
||||||
|
- The grid shows every photo at its own shape, in rows that fill the width (portraits
|
||||||
|
are no longer cropped to squares). **Square Tiles** in the page menu brings the even
|
||||||
|
grid back, and the Year zoom stays square.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
|
||||||
|
from the SDK, and a photo keeps its own modification time now that it no longer follows the
|
||||||
|
capture time. [The SDK review](SDK-0.11.0.md) lists the new features and breaking changes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **My files comes back after the service was killed for hanging** (B279). A killed service left
|
||||||
|
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
|
||||||
|
forever without opening anything, until the mounts were cleared by hand. The service now clears
|
||||||
|
a dead mount before it opens the folder. On a large account a restart that did open My files was
|
||||||
|
killed again, because loading the account took longer than the 2 minutes the service has to
|
||||||
|
report in. Startup now gets 10 minutes, and the 2 minutes apply again once the service answers.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
- **Large `.ts` recordings show and open as videos** (B283). An MPEG transport stream got the
|
||||||
|
text icon, no thumbnail, and the `@text` open rule. It now gets the video icon, a thumbnail
|
||||||
|
and the `@media` rule. A `.ts` of 1 MiB or more counts as a video, a smaller one stays
|
||||||
|
TypeScript source, and the daemon checks the stream's sync byte before it takes a frame.
|
||||||
|
|
||||||
|
- **Videos over 256 MiB get a thumbnail** (B283). They never got one, and a smaller video was
|
||||||
|
downloaded whole into memory for one frame.
|
||||||
|
|
||||||
## [3.7.0] — 2026-11-02
|
## [3.7.0] — 2026-11-02
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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). |
|
| `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 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
@@ -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.7.0";
|
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.8.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.
|
||||||
|
|||||||
+1
-1
@@ -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.7.0
|
pkgver=3.8.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')
|
||||||
|
|||||||
@@ -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.7.0}
|
%{!?version: %global version 3.8.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:.}
|
||||||
|
|||||||
+916
-807
File diff suppressed because it is too large.
Load diff
+887
-789
File diff suppressed because it is too large.
Load diff
+904
-795
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user