Compare commits

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

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

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

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

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

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

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

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

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

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

Share roles are now checked by the CLI before the request. The package
recipes install the bash, zsh and fish scripts via packaging/completions.sh.
2026-11-12 11:43:53 +01:00
nvrl ce35a83a04 Merge pull request #79 from narrrl/chore/sdk-0.11.0
build(sdk): bump to 0.11.0
2026-11-12 10:25:00 +01:00
nvrl 762d06765e Merge pull request #80 from narrrl/fix/stale-mount-watchdog-startup
fix(fuse,cli): recover the mount after a watchdog kill and bound thumbnail memory
2026-11-12 10:24:43 +01:00
nvrl c78bb529bc fix(fuse): bound memory held by thumbnail decodes
Every image upload decodes the whole file to build its thumbnail, and
nothing limited how many decodes ran at once. With a queue of large
photos all 16 drain workers decoded together; 100-megapixel JPEGs held
about 9 GiB, swapped the machine, and the watchdog killed the daemon on
every restart.

Each decode now takes a share of a process-wide 512 MiB budget, sized
from the encoded length and the header's pixel count. Phone photos still
decode side by side; a photo larger than the budget waits and runs alone.

Records B280.
2026-11-12 00:55:19 +01:00
nvrl 8d824af656 fix(fuse,cli): recover the mount after a watchdog kill
A daemon killed by the watchdog left its FUSE mounts dead. A dead mount
answers ENOTCONN, which create_dir_all took for an absent directory, so
every restart failed with EEXIST and retried forever without reaching the
code that clears stale mounts. mount_once now goes through
prepare_mountpoint, which clears a dead mount first.

Startup on a large account (70 s hydrating 48k nodes) plus the supervisor's
first ping one full period later outlasted WatchdogSec=120, so a restart
that did mount was aborted again. The daemon now asks for a 10-minute
watchdog before startup, the supervisor pings on its first tick, and the
unit's own timeout is restored once the control socket answers.

Records B279.
2026-11-12 00:38:50 +01:00
56 changed files with 11750 additions and 7592 deletions

No files matched your search

+1
View File
@@ -160,6 +160,7 @@ jobs:
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
po/build.sh deb/usr/share/locale
packaging/completions.sh target/release/pdfs deb/usr/share
# Stops the running daemon before removal, so the mount stops mirroring deletes
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
Generated
+22
View File
@@ -754,6 +754,18 @@ dependencies = [
"strsim",
]
[[package]]
name = "clap_complete"
version = "4.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f"
dependencies = [
"clap",
"clap_lex",
"is_executable",
"shlex",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
@@ -2551,6 +2563,15 @@ version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "is_executable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -3524,6 +3545,7 @@ version = "3.7.0"
dependencies = [
"anyhow",
"clap",
"clap_complete",
"pdfs-core",
"pdfs-fuse",
"rpassword",
+3
View File
@@ -57,6 +57,9 @@ rusqlite = { version = "0.41", features = ["bundled"] }
keyring-core = "1"
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
clap = { version = "4", features = ["derive"] }
# `unstable-dynamic`: Tab calls `pdfs` back, so arguments complete from the
# running service. Only `pdfs-cli/src/complete.rs` uses it.
clap_complete = { version = "4.6", features = ["unstable-dynamic"] }
rpassword = "7"
async-trait = "0.1"
futures = "0.3"
+1 -1
View File
@@ -125,7 +125,7 @@ The documentation is in [`docs/`](docs/README.md) and on the
| [Getting started](docs/GETTING_STARTED.md) | From installing to your first synced folder |
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
| [FAQ](docs/FAQ.md) | Short answers to common questions |
| [Command-line reference](docs/CLI.md) | Every `pdfs` command and scripting with `--json` |
| [Command-line reference](docs/CLI.md) | Every `pdfs` command, Tab completion and scripting with `--json` |
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
+1
View File
@@ -21,6 +21,7 @@ anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
clap_complete.workspace = true
rpassword.workspace = true
serde_json.workspace = true
zeroize.workspace = true
+731
View File
@@ -0,0 +1,731 @@
//! Shell completion for `pdfs`.
//!
//! Built on clap_complete's dynamic engine: the script a shell loads is a thin
//! stub that calls `pdfs` back on every Tab with the words typed so far, and
//! `pdfs` answers from its own clap definition. That lets an argument complete
//! from the running service as well as from the command tree — `pdfs sync rm
//! <Tab>` offers the synced-folder ids with their paths, `pdfs pin Doc<Tab>`
//! walks My files.
//!
//! Every value that comes from the service is best effort. With no service, a
//! slow one or an error, a completer offers nothing: a Tab that hangs or prints
//! an error into the prompt is worse than one that does nothing.
//!
//! The engine sits behind clap_complete's `unstable-dynamic` feature, so all of
//! this crate's use of it is kept to this module.
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::CommandFactory;
use clap_complete::engine::{CompletionCandidate, PathCompleter, ValueCompleter};
use clap_complete::env::{Bash, CompleteEnv, Elvish, EnvCompleter, Fish, Powershell, Zsh};
use pdfs_core::control::{
AlbumInfo, BookmarkInfo, ConflictInfo, DeviceInfo, DirEntry, InvitationInfo, PendingOpInfo,
PublicLinkInfo, Request, Response, RevisionInfo, ShareEntry, ShareEntryKind, SyncFolderInfo,
};
/// The environment variable that turns a run of `pdfs` into a completion
/// request. Not clap's generic `COMPLETE`: the same binary is the systemd
/// daemon, and an unrelated `COMPLETE` in its environment must not stop it.
pub const VAR: &str = "PDFS_COMPLETE";
/// How long a completer waits for the service. A Tab is interactive, so a
/// listing the service cannot give soon is better skipped. Long enough for the
/// listings that ask Proton (computers take over a second).
const SERVICE_TIMEOUT: Duration = Duration::from_secs(2);
/// How long a listing that asks Proton is reused. Every Tab is a new `pdfs`, so
/// without it `pdfs devices rm <Tab>`, a letter and another Tab would wait for
/// Proton twice. A uid that went away in the meantime only fails the command.
const CACHE_TTL: Duration = Duration::from_secs(30);
/// Answer a completion request and exit, if this run is one. Must run before
/// anything writes to stdout.
pub fn handle_request() {
CompleteEnv::with_factory(crate::Cli::command)
.var(VAR)
.complete();
}
/// The shells `pdfs completions` writes a script for.
#[derive(Clone, Copy, clap::ValueEnum)]
pub enum ShellArg {
Bash,
Zsh,
Fish,
Elvish,
Powershell,
}
impl ShellArg {
fn completer(self) -> &'static dyn EnvCompleter {
match self {
ShellArg::Bash => &Bash,
ShellArg::Zsh => &Zsh,
ShellArg::Fish => &Fish,
ShellArg::Elvish => &Elvish,
ShellArg::Powershell => &Powershell,
}
}
}
/// Write the script that registers `pdfs` completion with `shell`.
///
/// The script calls `pdfs` from `PATH`, not this binary's path, so one written
/// at packaging time keeps working once installed.
pub fn write_script(shell: ShellArg, out: &mut dyn std::io::Write) -> std::io::Result<()> {
shell
.completer()
.write_registration(VAR, "pdfs", "pdfs", "pdfs", out)?;
if let ShellArg::Zsh = shell {
// Installed as `_pdfs` in site-functions, the script is autoloaded as
// that function's body on the first Tab. The body above only defines
// and registers the completer, so this line also runs it, or the first
// Tab would complete nothing. Sourced, `funcstack` names no `_pdfs`.
writeln!(
out,
"if [ \"$funcstack[1]\" = \"_pdfs\" ]; then\n _clap_dynamic_completer_pdfs \"$@\"\nfi"
)?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Talking to the service
/// One request to the service, or `None` if there is no answer worth showing.
fn ask(request: Request) -> Option<Response> {
let socket = pdfs_core::config::AppDirs::new().ok()?.control_socket();
ask_at(&socket, &request)
}
fn ask_at(socket: &Path, request: &Request) -> Option<Response> {
match pdfs_core::control::send_with_timeout(socket, request, SERVICE_TIMEOUT) {
Ok(Response::Error { .. }) | Err(_) => None,
Ok(response) => Some(response),
}
}
/// [`ask`] for a listing that comes from Proton rather than the local
/// database, reusing an answer younger than [`CACHE_TTL`]. `key` names the
/// listing's cache file.
fn ask_cached(key: &str, request: Request) -> Option<Response> {
let file = cache_file(key);
if let Some(hit) = file.as_deref().and_then(read_fresh) {
return Some(hit);
}
let response = ask(request)?;
if let Some(file) = &file {
write_cache(file, &response);
}
Some(response)
}
/// In `$XDG_RUNTIME_DIR`: private to the user, in memory and gone at logout,
/// which suits names from the account. With no runtime dir, nothing is cached.
fn cache_file(key: &str) -> Option<PathBuf> {
let runtime = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
Some(
runtime
.join("proton-drive-linux")
.join(format!("completion-{key}.json")),
)
}
fn read_fresh(file: &Path) -> Option<Response> {
let age = std::fs::metadata(file)
.ok()?
.modified()
.ok()?
.elapsed()
.ok()?;
if age > CACHE_TTL {
return None;
}
serde_json::from_slice(&std::fs::read(file).ok()?).ok()
}
/// Best effort: a cache that cannot be written only costs the next Tab a wait.
fn write_cache(file: &Path, response: &Response) {
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
let (Some(dir), Ok(json)) = (file.parent(), serde_json::to_vec(response)) else {
return;
};
let _ = std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir);
let staging = file.with_extension(format!("{}.tmp", std::process::id()));
let written = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&staging)
.and_then(|mut f| std::io::Write::write_all(&mut f, &json));
if written.is_err() || std::fs::rename(&staging, file).is_err() {
let _ = std::fs::remove_file(&staging);
}
}
fn candidate(value: impl Into<OsString>, help: impl Into<String>) -> CompletionCandidate {
let help = help.into();
let candidate = CompletionCandidate::new(value);
if help.is_empty() {
candidate
} else {
candidate.help(Some(help.into()))
}
}
// ---------------------------------------------------------------------------
// Paths in Proton Drive
/// A path in the mount: an absolute path completes from the local filesystem
/// (through the mount), anything else from the service's listing of My files,
/// which is what the commands resolve a relative path against.
pub fn drive_path(current: &OsStr) -> Vec<CompletionCandidate> {
complete_drive(current, false)
}
/// [`drive_path`] for an argument that must be a folder.
pub fn drive_dir(current: &OsStr) -> Vec<CompletionCandidate> {
complete_drive(current, true)
}
/// `pdfs refresh`: a folder, or one of the two named caches.
pub fn refresh_target(current: &OsStr) -> Vec<CompletionCandidate> {
let typed = current.to_string_lossy();
let mut candidates: Vec<_> = [
("trash", "the trash listing"),
("photos", "the photos timeline"),
]
.into_iter()
.filter(|(name, _)| name.starts_with(typed.as_ref()))
.map(|(name, help)| candidate(name, help))
.collect();
candidates.extend(drive_dir(current));
candidates
}
fn complete_drive(current: &OsStr, dirs_only: bool) -> Vec<CompletionCandidate> {
let Some(word) = current.to_str() else {
return Vec::new();
};
if word.starts_with('/') || word.starts_with('~') {
let local = if dirs_only {
PathCompleter::dir()
} else {
PathCompleter::any()
};
return local.complete(current);
}
let (parent, prefix) = split_drive_word(word);
let listing = Request::ListDir {
path: parent.trim_end_matches('/').to_owned(),
};
match ask(listing) {
Some(Response::Entries { entries }) => {
drive_candidates(parent, prefix, &entries, dirs_only)
}
_ => Vec::new(),
}
}
/// Split a typed drive path into the folder to list (with its trailing `/`)
/// and the start of the name being completed in it.
fn split_drive_word(word: &str) -> (&str, &str) {
match word.rfind('/') {
Some(at) => word.split_at(at + 1),
None => ("", word),
}
}
/// The entries of `parent` whose name starts with `prefix`. A folder ends in
/// `/`, which tells every shell not to add a space after it, so the next Tab
/// goes on into it.
fn drive_candidates(
parent: &str,
prefix: &str,
entries: &[DirEntry],
dirs_only: bool,
) -> Vec<CompletionCandidate> {
entries
.iter()
.filter(|e| e.name.starts_with(prefix) && (e.is_dir || !dirs_only))
.map(|e| {
let slash = if e.is_dir { "/" } else { "" };
CompletionCandidate::new(format!("{parent}{}{slash}", e.name))
.hide(e.name.starts_with('.') && !prefix.starts_with('.'))
})
.collect()
}
// ---------------------------------------------------------------------------
// Ids and uids from the service's listings
pub fn sync_folder_id() -> Vec<CompletionCandidate> {
match ask(Request::ListSyncFolders) {
Some(Response::SyncFolders { items }) => sync_folder_candidates(&items),
_ => Vec::new(),
}
}
fn sync_folder_candidates(items: &[SyncFolderInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|f| candidate(f.id.to_string(), format!("{} ({})", f.local_path, f.mode)))
.collect()
}
pub fn queue_id() -> Vec<CompletionCandidate> {
match ask(Request::ListPendingOps) {
Some(Response::PendingOps { items }) => queue_candidates(&items),
_ => Vec::new(),
}
}
fn queue_candidates(items: &[PendingOpInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|op| candidate(op.id.to_string(), format!("{} {}", op.kind, op.path)))
.collect()
}
pub fn device_uid() -> Vec<CompletionCandidate> {
match ask_cached("devices", Request::ListDevices) {
Some(Response::Devices { items }) => device_candidates(&items),
_ => Vec::new(),
}
}
fn device_candidates(items: &[DeviceInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|d| {
let this = if d.this_device {
" (this computer)"
} else {
""
};
candidate(d.uid.as_str(), format!("{}{this}", d.name))
})
.collect()
}
pub fn trash_uid() -> Vec<CompletionCandidate> {
match ask_cached("trash", Request::ListTrash) {
Some(Response::Entries { entries }) => trash_candidates(&entries),
_ => Vec::new(),
}
}
fn trash_candidates(entries: &[DirEntry]) -> Vec<CompletionCandidate> {
entries
.iter()
.map(|e| {
let help = match e.trashed_from.as_deref() {
Some(from) if !from.is_empty() => format!("{} (from {from})", e.name),
_ => e.name.clone(),
};
candidate(e.uid.as_str(), help)
})
.collect()
}
pub fn album_uid() -> Vec<CompletionCandidate> {
match ask_cached("albums", Request::PhotoAlbums) {
Some(Response::Albums { items, .. }) => album_candidates(&items),
_ => Vec::new(),
}
}
fn album_candidates(items: &[AlbumInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|a| candidate(a.uid.as_str(), a.name.as_str()))
.collect()
}
pub fn shared_uid() -> Vec<CompletionCandidate> {
match ask_cached("shared-with-me", Request::ListSharedWithMe) {
Some(Response::Entries { entries }) => shared_candidates(&entries),
_ => Vec::new(),
}
}
fn shared_candidates(entries: &[DirEntry]) -> Vec<CompletionCandidate> {
entries
.iter()
.map(|e| {
let help = if e.shared_by.is_empty() {
e.name.clone()
} else {
format!("{} (from {})", e.name, e.shared_by)
};
candidate(e.uid.as_str(), help)
})
.collect()
}
pub fn invitation_id() -> Vec<CompletionCandidate> {
match ask_cached("invitations", Request::ListInvitations) {
Some(Response::Invitations { items }) => invitation_candidates(&items),
_ => Vec::new(),
}
}
fn invitation_candidates(items: &[InvitationInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|i| {
let help = match i.name.as_deref() {
Some(name) => format!("{name} from {}", i.inviter_email),
None => format!("from {}", i.inviter_email),
};
candidate(i.id.as_str(), help)
})
.collect()
}
pub fn bookmark_token() -> Vec<CompletionCandidate> {
match ask_cached("bookmarks", Request::ListBookmarks) {
Some(Response::Bookmarks { items }) => bookmark_candidates(&items),
_ => Vec::new(),
}
}
fn bookmark_candidates(items: &[BookmarkInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|b| candidate(b.token.as_str(), b.name.as_deref().unwrap_or(&b.url)))
.collect()
}
pub fn conflict_path() -> Vec<CompletionCandidate> {
match ask(Request::ListConflicts) {
Some(Response::Conflicts { items }) => conflict_candidates(&items),
_ => Vec::new(),
}
}
fn conflict_candidates(items: &[ConflictInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|c| candidate(c.path.as_str(), format!("copy of {}", c.original_path)))
.collect()
}
// ---------------------------------------------------------------------------
// Ids that belong to the path typed before them
/// A version of the file named earlier on the line (`pdfs versions restore
/// <path> <Tab>`).
pub fn version_id(current: &OsStr) -> Vec<CompletionCandidate> {
let Some(path) = typed_path() else {
return Vec::new();
};
match ask(Request::ListRevisions { path }) {
Some(Response::Revisions { items }) => starting_with(current, revision_candidates(&items)),
_ => Vec::new(),
}
}
fn revision_candidates(items: &[RevisionInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|r| {
let current = if r.is_active { ", current" } else { "" };
let size = r.claimed_size.unwrap_or(r.size_on_storage).max(0);
candidate(
r.id.as_str(),
format!(
"{}, {}{current}",
crate::format_epoch_utc(r.created),
crate::human_bytes(size as u64)
),
)
})
.collect()
}
/// A member or invitation on the share of the path typed earlier on the line.
pub fn share_entry_id(current: &OsStr) -> Vec<CompletionCandidate> {
match share_of_typed_path() {
Some((entries, _)) => starting_with(current, share_entry_candidates(&entries)),
None => Vec::new(),
}
}
fn share_entry_candidates(entries: &[ShareEntry]) -> Vec<CompletionCandidate> {
entries
.iter()
.map(|e| {
// The kind is the next argument, so the help names it in the words
// that argument takes.
let kind = match e.kind {
ShareEntryKind::Member => "member",
ShareEntryKind::ProtonInvite => "proton",
ShareEntryKind::ExternalInvite => "external",
};
candidate(e.id.as_str(), format!("{} ({}, {kind})", e.email, e.role))
})
.collect()
}
/// The public link of the path typed earlier on the line.
pub fn public_link_id(current: &OsStr) -> Vec<CompletionCandidate> {
match share_of_typed_path() {
Some((_, Some(link))) => starting_with(current, vec![public_link_candidate(&link)]),
_ => Vec::new(),
}
}
/// Described without its URL: the fragment is the link's password, and a Tab
/// menu is on screen for anyone watching.
fn public_link_candidate(link: &PublicLinkInfo) -> CompletionCandidate {
let help = match link.expires {
Some(expires) => format!(
"{} link, expires {}",
link.role,
crate::format_epoch_utc(expires)
),
None => format!("{} link", link.role),
};
candidate(link.id.as_str(), help)
}
fn share_of_typed_path() -> Option<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
match ask(Request::ListShare {
path: typed_path()?,
})? {
Response::Share { entries, link } => Some((entries, link)),
_ => None,
}
}
/// [`clap_complete::engine::ArgValueCompleter`] candidates are not filtered by
/// the engine, unlike [`clap_complete::engine::ArgValueCandidates`].
fn starting_with(
current: &OsStr,
candidates: Vec<CompletionCandidate>,
) -> Vec<CompletionCandidate> {
let typed = current.to_string_lossy();
candidates
.into_iter()
.filter(|c| c.get_value().to_string_lossy().starts_with(typed.as_ref()))
.collect()
}
/// The `path` argument of the command being completed, as the service takes
/// it. A completer only receives the word under the cursor, so this reads the
/// line from the process arguments, where the shell's stub passes it.
fn typed_path() -> Option<String> {
let args: Vec<OsString> = std::env::args_os().collect();
let index = std::env::var("_CLAP_COMPLETE_INDEX")
.ok()
.and_then(|i| i.parse().ok());
path_in(words_before_cursor(&args, index))
}
/// The words of the line before the one being completed. The stub runs
/// `pdfs -- <words>`; bash, zsh and elvish say which word the cursor is on,
/// fish and PowerShell pass the line cut at the cursor, so it is the last.
fn words_before_cursor(args: &[OsString], index: Option<usize>) -> Vec<OsString> {
let start = args
.iter()
.position(|a| a == "--")
.map_or(args.len(), |at| at + 1);
let mut words = args[start..].to_vec();
let index = index.unwrap_or(words.len().saturating_sub(1));
words.truncate(index);
words
}
/// Parse `words` as far as they go and take the innermost command's `path`.
fn path_in(words: Vec<OsString>) -> Option<String> {
let matches = crate::Cli::command()
.ignore_errors(true)
.try_get_matches_from(words)
.ok()?;
let mut leaf = &matches;
while let Some((_, sub)) = leaf.subcommand() {
leaf = sub;
}
let path = leaf.try_get_one::<PathBuf>("path").ok().flatten()?;
crate::path_arg(path).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn values(candidates: &[CompletionCandidate]) -> Vec<String> {
candidates
.iter()
.map(|c| c.get_value().to_string_lossy().into_owned())
.collect()
}
fn help(candidate: &CompletionCandidate) -> String {
candidate
.get_help()
.map(ToString::to_string)
.unwrap_or_default()
}
fn entry(name: &str, is_dir: bool) -> DirEntry {
serde_json::from_value(serde_json::json!({
"name": name,
"is_dir": is_dir,
"size": 0,
"modified": 0,
"pinned": false,
"uid": format!("vol~{name}"),
}))
.unwrap()
}
fn words(line: &[&str]) -> Vec<OsString> {
line.iter().map(OsString::from).collect()
}
#[test]
fn the_command_definition_is_valid() {
crate::Cli::command().debug_assert();
}
#[test]
fn completions_script_is_written_for_every_shell() {
for shell in [
ShellArg::Bash,
ShellArg::Zsh,
ShellArg::Fish,
ShellArg::Elvish,
ShellArg::Powershell,
] {
let mut script = Vec::new();
write_script(shell, &mut script).unwrap();
let script = String::from_utf8(script).unwrap();
assert!(script.contains(VAR), "{script}");
assert!(script.contains("pdfs"), "{script}");
}
}
#[test]
fn drive_path_splits_parent_and_prefix() {
assert_eq!(split_drive_word(""), ("", ""));
assert_eq!(split_drive_word("Doc"), ("", "Doc"));
assert_eq!(split_drive_word("Documents/"), ("Documents/", ""));
assert_eq!(split_drive_word("Documents/Rep"), ("Documents/", "Rep"));
assert_eq!(split_drive_word("a/b/c"), ("a/b/", "c"));
}
#[test]
fn drive_dirs_end_in_a_slash() {
let entries = [
entry("Reports", true),
entry("Receipt.pdf", false),
entry("Other", true),
];
let all = drive_candidates("Documents/", "Re", &entries, false);
assert_eq!(
values(&all),
["Documents/Reports/", "Documents/Receipt.pdf"]
);
let dirs = drive_candidates("Documents/", "", &entries, true);
assert_eq!(values(&dirs), ["Documents/Reports/", "Documents/Other/"]);
}
#[test]
fn hidden_drive_entries_need_a_typed_dot() {
let entries = [entry(".git", true)];
assert!(drive_candidates("", "", &entries, false)[0].is_hide_set());
assert!(!drive_candidates("", ".", &entries, false)[0].is_hide_set());
}
#[test]
fn sync_folder_ids_complete_with_their_local_path() {
let folders: Vec<SyncFolderInfo> = serde_json::from_value(serde_json::json!([{
"id": 3,
"local_path": "/home/me/Documents",
"remote_uid": "vol~docs",
"mode": "mirror",
"state": "idle",
"last_sync": 0,
}]))
.unwrap();
let candidates = sync_folder_candidates(&folders);
assert_eq!(values(&candidates), ["3"]);
assert_eq!(help(&candidates[0]), "/home/me/Documents (mirror)");
}
#[test]
fn trash_uids_complete_with_their_name() {
let mut from_docs = entry("a.pdf", false);
from_docs.trashed_from = Some("Documents".into());
let candidates = trash_candidates(&[from_docs, entry("b", true)]);
assert_eq!(values(&candidates), ["vol~a.pdf", "vol~b"]);
assert_eq!(help(&candidates[0]), "a.pdf (from Documents)");
assert_eq!(help(&candidates[1]), "b");
}
#[test]
fn completer_without_a_service_offers_nothing() {
let dir = std::env::temp_dir().join(format!("pdfs-complete-{}", std::process::id()));
let socket = dir.join("control.sock");
assert!(ask_at(&socket, &Request::ListSyncFolders).is_none());
}
#[test]
fn cached_listing_is_reused_until_it_is_stale() {
let dir = std::env::temp_dir().join(format!("pdfs-complete-cache-{}", std::process::id()));
let file = dir.join("completion-devices.json");
write_cache(&file, &Response::Devices { items: Vec::new() });
assert!(matches!(read_fresh(&file), Some(Response::Devices { .. })));
let mode = std::os::unix::fs::PermissionsExt::mode(
&std::fs::metadata(&file).unwrap().permissions(),
);
assert_eq!(mode & 0o777, 0o600);
let stale = std::time::SystemTime::now() - CACHE_TTL - Duration::from_secs(1);
std::fs::File::options()
.write(true)
.open(&file)
.unwrap()
.set_modified(stale)
.unwrap();
assert!(read_fresh(&file).is_none());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn words_before_cursor_stop_at_the_cursor() {
let args = words(&[
"pdfs", "--", "pdfs", "versions", "restore", "a.txt", "", "x",
]);
assert_eq!(
words_before_cursor(&args, Some(4)),
words(&["pdfs", "versions", "restore", "a.txt"])
);
// fish: the line arrives cut at the cursor, the current word last.
let fish = words(&["pdfs", "--", "pdfs", "versions", "restore", "a.txt", "ab"]);
assert_eq!(
words_before_cursor(&fish, None),
words(&["pdfs", "versions", "restore", "a.txt"])
);
}
#[test]
fn completing_words_finds_the_path_before_the_id() {
let line = words(&["pdfs", "versions", "restore", "Documents/a.txt"]);
assert_eq!(path_in(line).as_deref(), Some("Documents/a.txt"));
let line = words(&["pdfs", "--json", "share-role", "Shared/x"]);
assert_eq!(path_in(line).as_deref(), Some("Shared/x"));
let line = words(&["pdfs", "public-link", "update", "--role", "editor", "a"]);
assert_eq!(path_in(line).as_deref(), Some("a"));
assert_eq!(path_in(words(&["pdfs", "versions", "restore"])), None);
}
}
+120 -23
View File
@@ -8,7 +8,8 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, anyhow, bail};
use clap::{Parser, Subcommand};
use clap::{Parser, Subcommand, ValueHint};
use clap_complete::engine::{ArgValueCandidates, ArgValueCompleter};
use pdfs_core::auth;
use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs;
@@ -20,6 +21,7 @@ use pdfs_core::control::{
use pdfs_core::db::Db;
use pdfs_core::service;
mod complete;
mod journal;
/// `println!` for this binary, shadowing std's, which panics when stdout is
@@ -165,6 +167,7 @@ enum Command {
/// Mount Proton Drive at the given (or default) path. Blocks until unmounted.
Mount {
/// Mountpoint; defaults to ~/ProtonDrive.
#[arg(value_hint = ValueHint::DirPath)]
mountpoint: Option<PathBuf>,
},
/// Run the auto-mount daemon: wait for login, mount, and keep it mounted
@@ -172,6 +175,7 @@ enum Command {
/// don't invoke it by hand.
Daemon {
/// Mountpoint; defaults to ~/ProtonDrive.
#[arg(value_hint = ValueHint::DirPath)]
mountpoint: Option<PathBuf>,
},
/// Keep a file or folder on this device (download + cache it). A folder's
@@ -179,11 +183,13 @@ enum Command {
/// Needs a running mount.
Pin {
/// File or folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
},
/// Stop keeping a file or folder on this device, evicting its cached content.
Unpin {
/// File or folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
},
/// List files and folders pinned to this device.
@@ -191,6 +197,7 @@ enum Command {
/// List a directory via the running daemon (in-app browser backend).
Ls {
/// Directory path, inside the mountpoint or relative to it. Root if omitted.
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
path: Option<PathBuf>,
},
/// List the photos timeline (newest first) via the running daemon.
@@ -224,7 +231,7 @@ enum Command {
/// so an interrupted import can simply be run again.
ImportGooglePhotos {
/// The export's `.zip` files (e.g. `takeout-*.zip`).
#[arg(required = true)]
#[arg(required = true, value_hint = ValueHint::FilePath)]
archives: Vec<PathBuf>,
/// Report what would be imported without uploading anything.
#[arg(long)]
@@ -277,6 +284,7 @@ enum Command {
/// List the photos in one album (newest capture first).
Album {
/// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String,
/// Max photos to list.
#[arg(long, default_value_t = 50)]
@@ -293,6 +301,7 @@ enum Command {
/// Rename a photo album.
RenameAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String,
/// New name.
name: String,
@@ -300,6 +309,7 @@ enum Command {
/// Delete a photo album. Its photos stay in the timeline.
DeleteAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String,
/// Also delete photos that exist only in this album (saved from a
/// shared album); without it the server refuses to delete such an album.
@@ -309,6 +319,7 @@ enum Command {
/// Add photos to an album.
AddToAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String,
/// Photo node uids (from `pdfs photos`).
#[arg(required = true)]
@@ -317,6 +328,7 @@ enum Command {
/// Take photos out of an album. They stay in the timeline.
RemoveFromAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`).
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
uid: String,
/// Photo node uids (from `pdfs album <uid>`).
#[arg(required = true)]
@@ -335,13 +347,14 @@ enum Command {
#[arg(long, default_value_t = 50)]
limit: usize,
/// Only search below this folder (under the mountpoint).
#[arg(long = "in", value_name = "FOLDER")]
#[arg(long = "in", value_name = "FOLDER", add = ArgValueCompleter::new(complete::drive_dir))]
scope: Option<PathBuf>,
},
/// Rename a file or folder via the running daemon.
Rename {
/// File/folder path, inside My files or an on-demand synced folder, or
/// relative to the mountpoint.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// New name (a single path component).
new_name: String,
@@ -355,7 +368,7 @@ enum Command {
Move {
/// Files/folders to move, then the destination folder: each inside a
/// location, or relative to the mountpoint.
#[arg(required = true, num_args = 2.., value_name = "PATH")]
#[arg(required = true, num_args = 2.., value_name = "PATH", add = ArgValueCompleter::new(complete::drive_path))]
paths: Vec<PathBuf>,
},
/// Copy files or folders into another folder in My files via the running
@@ -367,7 +380,7 @@ enum Command {
Copy {
/// Files/folders to copy, then the destination folder: inside My files,
/// or relative to the mountpoint.
#[arg(required = true, num_args = 2.., value_name = "PATH")]
#[arg(required = true, num_args = 2.., value_name = "PATH", add = ArgValueCompleter::new(complete::drive_path))]
paths: Vec<PathBuf>,
},
/// Show how much a folder holds on Proton Drive.
@@ -376,18 +389,21 @@ enum Command {
/// encrypted bytes on storage and includes items in the trash.
Du {
/// Folder path, inside My files or relative to the mountpoint.
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
path: PathBuf,
},
/// Trash a file or folder via the running daemon.
Rm {
/// File/folder path, inside My files or an on-demand synced folder, or
/// relative to the mountpoint.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
},
/// Create a folder via the running daemon.
Mkdir {
/// Parent folder path, inside My files or an on-demand synced folder,
/// or relative to the mountpoint.
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
parent: PathBuf,
/// New folder name (a single path component).
name: String,
@@ -397,10 +413,10 @@ enum Command {
/// background; watch progress with `pdfs transfers`.
Upload {
/// Local files and/or folders to upload.
#[arg(required = true)]
#[arg(required = true, value_hint = ValueHint::AnyPath)]
sources: Vec<PathBuf>,
/// Destination folder, inside the mountpoint or relative to it.
#[arg(short = 't', long = "to", default_value = ".")]
#[arg(short = 't', long = "to", default_value = ".", add = ArgValueCompleter::new(complete::drive_dir))]
parent: PathBuf,
},
/// Show the daemon's in-flight transfers (active uploads/downloads).
@@ -415,13 +431,13 @@ enum Command {
/// Restore trashed items, by uid, to the folders they were trashed from.
Restore {
/// Node uids (`volume~link`), as printed by `pdfs trash`.
#[arg(required = true)]
#[arg(required = true, add = ArgValueCandidates::new(complete::trash_uid))]
uids: Vec<String>,
},
/// Permanently delete trashed items by uid. This cannot be undone.
DeleteForever {
/// Node uids (`volume~link`), as printed by `pdfs trash`.
#[arg(required = true)]
#[arg(required = true, add = ArgValueCandidates::new(complete::trash_uid))]
uids: Vec<String>,
},
/// Permanently delete everything in the trash. This cannot be undone.
@@ -433,6 +449,7 @@ enum Command {
Refresh {
/// What to refresh: a folder path (inside the mountpoint or relative to
/// it), `trash`, or `photos`. The mount root if omitted.
#[arg(add = ArgValueCompleter::new(complete::refresh_target))]
target: Option<String>,
/// With `photos`: read every photo's metadata again instead of only what
/// changed. Minutes on a large library; for a gallery that disagrees with
@@ -459,12 +476,13 @@ enum Command {
/// Share a file or folder with Proton and/or external email addresses.
Share {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// One or more invitee emails (Proton or external, auto-detected).
#[arg(required = true)]
emails: Vec<String>,
/// Role to grant: viewer, editor or admin.
#[arg(long, default_value = "viewer")]
#[arg(long, default_value = "viewer", value_parser = ["viewer", "editor", "admin"], ignore_case = true)]
role: String,
/// Optional message included in the invitation email.
#[arg(long)]
@@ -473,17 +491,21 @@ enum Command {
/// List a node's members, pending invitations and public link.
Members {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
},
/// Change the role of a member or pending invitation on a node's share.
ShareRole {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Entry id (membership or invitation id), from `pdfs members`.
#[arg(add = ArgValueCompleter::new(complete::share_entry_id))]
id: String,
/// Entry kind: member, proton or external.
kind: ShareKindArg,
/// New role: viewer, editor or admin.
#[arg(value_parser = ["viewer", "editor", "admin"], ignore_case = true)]
role: String,
},
/// Stop sharing a node, or remove one member or pending invitation.
@@ -492,9 +514,10 @@ enum Command {
/// member. With an id and kind (from `pdfs members`), removes that entry.
Unshare {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Entry id (membership or invitation id), from `pdfs members`.
#[arg(requires = "kind")]
#[arg(requires = "kind", add = ArgValueCompleter::new(complete::share_entry_id))]
id: Option<String>,
/// Entry kind: member, proton or external.
kind: Option<ShareKindArg>,
@@ -515,18 +538,22 @@ enum Command {
SharedWithMe {
/// Folder uid to list into, in `volume~link` form (from a previous run).
/// Omit for the top level.
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
uid: Option<String>,
},
/// Download a file shared with me by its uid (from `pdfs shared-with-me`).
SharedGet {
/// Node uid in `volume~link` form.
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
uid: String,
/// Where to write it. Omit to leave it in the cache and print the path.
#[arg(value_hint = ValueHint::AnyPath)]
dest: Option<PathBuf>,
},
/// Leave a shared node by its uid (from `pdfs shared-with-me`).
Leave {
/// Node uid in `volume~link` form.
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
uid: String,
},
/// Manage invitations addressed to me.
@@ -557,6 +584,17 @@ enum Command {
/// Runs without a daemon on purpose: the state worth diagnosing is usually
/// the state where the daemon will not start.
Diagnose,
/// Print the shell completion script for bash, zsh, fish, elvish or
/// PowerShell.
///
/// Besides commands and options, it completes values from the running
/// service: paths in My files, synced-folder and queue ids, computers,
/// trash, albums, shares and versions. Load it with, for example,
/// `pdfs completions fish | source` or `source <(pdfs completions bash)`.
Completions {
/// The shell to write the script for.
shell: complete::ShellArg,
},
}
#[derive(Subcommand)]
@@ -604,6 +642,7 @@ enum VersionCmd {
/// List a file's versions, newest first.
List {
/// File path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
},
/// Make an earlier version the file's current content.
@@ -612,25 +651,32 @@ enum VersionCmd {
/// moment to show up in the mount.
Restore {
/// File path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Version id, from `pdfs versions list`.
#[arg(add = ArgValueCompleter::new(complete::version_id))]
id: String,
},
/// Write an earlier version to a local file, leaving the file untouched.
Save {
/// File path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Version id, from `pdfs versions list`.
#[arg(add = ArgValueCompleter::new(complete::version_id))]
id: String,
/// Where to write it. Must not already exist.
#[arg(value_hint = ValueHint::AnyPath)]
dest: PathBuf,
},
/// Permanently delete an earlier version. The content is unrecoverable, and
/// the version that is currently active cannot be deleted.
Rm {
/// File path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Version id, from `pdfs versions list`.
#[arg(add = ArgValueCompleter::new(complete::version_id))]
id: String,
},
}
@@ -640,13 +686,21 @@ enum DeviceCmd {
/// List registered devices.
List,
/// Rename a device by uid.
Rename { uid: String, name: String },
Rename {
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: String,
name: String,
},
/// Delete (deregister) a device by uid.
Rm { uid: String },
Rm {
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: String,
},
/// Adopt an existing device as this machine's, so a hostname change or a
/// reinstall re-attaches to it instead of registering a duplicate.
Adopt {
/// Device uid (from `devices list`). Omit with `--clear`.
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: Option<String>,
/// Drop the adoption and go back to matching by hostname.
#[arg(long, conflicts_with = "uid")]
@@ -657,6 +711,7 @@ enum DeviceCmd {
/// here. Both computers keep them in step.
Join {
/// Device uid (from `devices list`).
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: String,
/// Skip the confirmation and accept every proposed local path.
#[arg(long)]
@@ -666,6 +721,7 @@ enum DeviceCmd {
/// them here. The other computer stays registered, without those folders.
Migrate {
/// Device uid (from `devices list`).
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
uid: String,
/// Skip the confirmation and accept every proposed local path.
#[arg(long)]
@@ -678,6 +734,7 @@ enum ConflictCmd {
/// Decide which side of a conflict to keep. Whatever is removed goes to Trash.
Resolve {
/// The conflict copy, absolute or relative to the mountpoint.
#[arg(add = ArgValueCandidates::new(complete::conflict_path))]
path: PathBuf,
/// `original` trashes the copy; `copy` trashes the original and gives the
/// copy its name; `both` renames the copy to `--name`.
@@ -694,15 +751,17 @@ enum SyncCmd {
/// Add a local folder to this machine's device and upload its contents.
Add {
/// Local directory to sync.
#[arg(value_hint = ValueHint::DirPath)]
path: PathBuf,
},
/// Sync a real local folder with an existing folder in My files.
/// The first pass preserves local-only files and keeps differing versions.
Attach {
/// Existing local directory; it remains a real local directory.
#[arg(value_hint = ValueHint::DirPath)]
path: PathBuf,
/// Existing folder in My files, relative to the cloud mount or inside it.
#[arg(long)]
#[arg(long, add = ArgValueCompleter::new(complete::drive_dir))]
to: PathBuf,
},
/// List this machine's synced folders.
@@ -710,6 +769,7 @@ enum SyncCmd {
/// Remove a synced folder by id.
Rm {
/// Synced-folder id (from `sync list`).
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
id: i64,
/// Also delete the folder's copy in Proton Drive.
#[arg(long)]
@@ -718,6 +778,7 @@ enum SyncCmd {
/// Force a sync pass now (all folders, or one by id).
Now {
/// Optional synced-folder id; omit to reconcile every folder.
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
id: Option<i64>,
},
/// Forget what a synced folder synced before and compare it with Proton
@@ -725,6 +786,7 @@ enum SyncCmd {
/// that differs keeps both versions. Nothing is deleted.
Reset {
/// Synced-folder id (from `sync list`).
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
id: i64,
},
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
@@ -734,13 +796,13 @@ enum SyncCmd {
#[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")]
duration: Option<u64>,
/// Pause only this mirror folder (id from `sync list`) until resumed.
#[arg(long)]
#[arg(long, add = ArgValueCandidates::new(complete::sync_folder_id))]
folder: Option<i64>,
},
/// Resume syncing after `pdfs sync pause`.
Resume {
/// Resume only this folder, paused with `sync pause --folder`.
#[arg(long)]
#[arg(long, add = ArgValueCandidates::new(complete::sync_folder_id))]
folder: Option<i64>,
},
/// Cap upload and download speed, or show the caps with no options.
@@ -759,8 +821,10 @@ enum SyncCmd {
/// Save the content of a queued upload (by id from `sync queue`) to a file.
Export {
/// Queue id.
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
id: i64,
/// Where to save it: a new file, or an existing folder to save it in.
#[arg(value_hint = ValueHint::AnyPath)]
dest: PathBuf,
},
/// Drop a queued change (by id from `sync queue`) and undo it here: a new file
@@ -768,6 +832,7 @@ enum SyncCmd {
/// content is lost unless saved with `sync export` first.
Discard {
/// Queue id.
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
id: i64,
/// Do not ask first.
#[arg(long)]
@@ -776,11 +841,13 @@ enum SyncCmd {
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
Retry {
/// Queue id; omit to retry every failed operation.
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
id: Option<i64>,
},
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
Mode {
/// Synced-folder id (from `sync list`).
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
id: i64,
/// `mirror` (full local copy) or `ondemand` (FUSE mount, reclaims disk).
#[arg(value_parser = ["mirror", "ondemand"])]
@@ -794,7 +861,7 @@ enum SyncCmd {
yes: bool,
/// Restore another computer's folders instead, by device uid (from
/// `pdfs devices list`).
#[arg(long)]
#[arg(long, add = ArgValueCandidates::new(complete::device_uid))]
device: Option<String>,
},
}
@@ -804,9 +871,10 @@ enum PublicLinkCmd {
/// Create a public link on a node.
Create {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Role: viewer or editor.
#[arg(long, default_value = "viewer")]
#[arg(long, default_value = "viewer", value_parser = ["viewer", "editor"], ignore_case = true)]
role: String,
/// Optional custom password protecting the link.
#[arg(long)]
@@ -819,11 +887,13 @@ enum PublicLinkCmd {
/// given is kept.
Update {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Public-link id, from `pdfs members`.
#[arg(add = ArgValueCompleter::new(complete::public_link_id))]
id: String,
/// New role: viewer or editor.
#[arg(long)]
#[arg(long, value_parser = ["viewer", "editor"], ignore_case = true)]
role: Option<String>,
/// New custom password protecting the link.
#[arg(long, conflicts_with = "no_password")]
@@ -841,8 +911,10 @@ enum PublicLinkCmd {
/// Remove a node's public link.
Remove {
/// File/folder path, inside the mountpoint or relative to it.
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
path: PathBuf,
/// Public-link id, from `pdfs members`.
#[arg(add = ArgValueCompleter::new(complete::public_link_id))]
id: String,
},
}
@@ -852,9 +924,15 @@ enum InvitationCmd {
/// List invitations addressed to me.
List,
/// Accept an invitation by id.
Accept { id: String },
Accept {
#[arg(add = ArgValueCandidates::new(complete::invitation_id))]
id: String,
},
/// Reject an invitation by id.
Reject { id: String },
Reject {
#[arg(add = ArgValueCandidates::new(complete::invitation_id))]
id: String,
},
}
#[derive(Subcommand)]
@@ -870,7 +948,10 @@ enum BookmarkCmd {
password: Option<String>,
},
/// Remove a saved bookmark by its token.
Rm { token: String },
Rm {
#[arg(add = ArgValueCandidates::new(complete::bookmark_token))]
token: String,
},
}
/// Render a daemon failure for a terminal.
@@ -890,6 +971,9 @@ fn cli_error(kind: ErrorKind, message: &str) -> String {
}
fn main() -> Result<()> {
// First: a completion request answers on stdout and exits, so nothing may
// write there before it.
complete::handle_request();
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
// `pgp` logs a benign WARN when it re-serializes a Proton secret-key
// packet with a longer (but equivalent) length encoding than the
@@ -1030,6 +1114,7 @@ fn main() -> Result<()> {
Command::Activity { limit } => cmd_activity(limit),
Command::Quota => cmd_quota(),
Command::Cache { action } => cmd_cache(action),
Command::Completions { shell } => cmd_completions(shell),
Command::Diagnose => cmd_diagnose(),
}
}
@@ -2520,7 +2605,10 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
// An explicit `--mountpoint` wins; otherwise honor the Settings-page choice
// (config), falling back to the default location.
let mountpoint = mountpoint.unwrap_or_else(|| dirs.resolved_mountpoint(&config));
std::fs::create_dir_all(&mountpoint)
// Hydrating a large account and remounting its folders can outlast the
// unit's watchdog before the supervisor sends its first ping (B279).
pdfs_fuse::stretch_watchdog_for_startup();
pdfs_fuse::prepare_mountpoint(&mountpoint)
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
let username = auth::load().map(|s| s.username).unwrap_or_default();
@@ -3803,6 +3891,15 @@ fn writable_probe(dir: &Path) -> std::io::Result<()> {
result.and(remove)
}
/// Print the completion script for `shell`. Touches no account state, so
/// packaging can run it at build time.
fn cmd_completions(shell: complete::ShellArg) -> Result<()> {
let mut script = Vec::new();
complete::write_script(shell, &mut script)?;
print!("{}", String::from_utf8_lossy(&script));
Ok(())
}
/// Self-check the installation, reporting each finding and exiting non-zero if
/// any check failed.
///
+38
View File
@@ -163,6 +163,10 @@ pub struct AppConfig {
/// Defaulted for configs predating the field.
#[serde(default)]
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
/// for configs predating the field.
#[serde(default)]
@@ -220,6 +224,10 @@ pub struct FilesView {
pub descending: bool,
/// Folders ahead of files, whatever the sort.
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 {
@@ -229,10 +237,23 @@ impl Default for FilesView {
sort: FileSort::Name,
descending: false,
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.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(default)]
@@ -761,6 +782,11 @@ mod tests {
sort: FileSort::Modified,
descending: true,
folders_first: false,
icon_size: Some(192),
},
photos_view: PhotosView {
row_height: Some(240),
square: true,
},
window: WindowState {
width: 1200,
@@ -792,6 +818,7 @@ mod tests {
assert_eq!(decoded.upload_limit, Some(500_000));
assert_eq!(decoded.download_limit, None);
assert_eq!(decoded.files_view, config.files_view);
assert_eq!(decoded.photos_view, config.photos_view);
assert_eq!(decoded.window, config.window);
}
@@ -835,6 +862,17 @@ mod tests {
let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap();
assert_eq!(partial.sort, FileSort::Size);
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]
+73 -2
View File
@@ -1851,6 +1851,34 @@ pub fn is_thumbnail_image_name(name: &str) -> bool {
|| 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
/// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose
/// preview availability is not dependable.
@@ -2405,9 +2433,17 @@ impl Request {
///
/// Shared by the CLI and GUI so both speak the wire format identically.
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
send_with_timeout(socket, req, req.read_timeout())
}
/// [`send`] with a caller-chosen limit on how long the daemon may take to
/// answer, which also bounds the write. For a caller that must never keep a
/// person waiting, such as shell completion, where a slow answer is worth less
/// than none.
pub fn send_with_timeout(socket: &Path, req: &Request, timeout: Duration) -> Result<Response> {
let mut stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(req.read_timeout()))?;
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout.min(WRITE_TIMEOUT)))?;
let mut line = serde_json::to_vec(req)?;
line.push(b'\n');
stream.write_all(&line)?;
@@ -2667,6 +2703,41 @@ mod tests {
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
/// activation uses this classification before it knows whether opening a
/// file should stream through FUSE or materialise a complete cache entry;
+70 -7
View File
@@ -27,6 +27,7 @@ use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Command, Stdio};
use crate::control::is_video_file_name;
use serde::{Deserialize, Serialize};
/// 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
/// 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> {
let kind = Kind::of(path, name, is_dir);
for rule in &self.rules {
if rule.command.is_empty() {
continue;
}
if !rule.patterns.iter().any(|p| matches(p, name, is_dir)) {
if !rule.patterns.iter().any(|p| matches(p, name, kind)) {
continue;
}
let argv = expand(&rule.command, path);
@@ -325,16 +327,46 @@ fn expand_env(token: &str) -> Option<String> {
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.
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 file = kind == Kind::File;
match pattern.to_lowercase().as_str() {
"@any" => true,
"@dir" => is_dir,
"@text" => !is_dir && class_text(&name_lower),
"@document" => !is_dir && class_document(&name_lower),
"@image" => !is_dir && class_image(&name_lower),
"@media" => !is_dir && class_media(&name_lower),
"@dir" => kind == Kind::Dir,
"@text" => file && class_text(&name_lower),
"@document" => file && class_document(&name_lower),
"@image" => file && class_image(&name_lower),
"@media" => kind == Kind::Video || (file && class_media(&name_lower)),
other if other.starts_with('@') => false,
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]
fn an_empty_policy_is_xdg_open() {
let policy = OpenWith::default();
+17 -1
View File
@@ -29,7 +29,7 @@ use proton_drive_rs::proton_sdk::error::Result;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid, ShareId};
use proton_drive_rs::{
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`].
@@ -118,6 +118,14 @@ pub(crate) trait DriveApi: Send + Sync {
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(
&self,
uid: &NodeUid,
@@ -445,6 +453,14 @@ impl DriveApi for ProtonDriveClient {
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(
&self,
uid: &NodeUid,
+2 -1
View File
@@ -119,13 +119,14 @@ mod workers;
use background::{forget_and_notify, run_event_sync, run_local_index, run_photos_event_sync};
use link::BATCH_CALL_TIMEOUT;
pub(crate) use mount::is_stale_mount;
pub use mount::prepare_mountpoint;
pub use mount::{MountOptions, MountOutcome, mount};
use mount::{
SecondaryInsertRejection, SecondaryMount, clear_stale_mount, fuse_connection_id, spawn_session,
};
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
pub use systemd::{idle_sleep, status as notify_status};
pub use systemd::{idle_sleep, status as notify_status, stretch_watchdog_for_startup};
use tracing::{debug, error, info, warn};
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
use r#virtual::{
+14
View File
@@ -196,6 +196,20 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
}
}
/// Make sure `mountpoint` is a directory a mount can go on, clearing a dead
/// mount a killed daemon left there first.
///
/// A dead mount answers `ENOTCONN`, which `create_dir_all` takes for "absent";
/// it then tries `mkdir` and fails with `EEXIST`. The daemon retried that error
/// forever and never got as far as [`check_mountpoint`], which would have
/// cleared the mount (`docs/BUGS.md` B279).
pub fn prepare_mountpoint(mountpoint: &Path) -> std::io::Result<()> {
if is_stale_mount(mountpoint) {
clear_stale_mount(mountpoint);
}
std::fs::create_dir_all(mountpoint)
}
/// Refuse to mount My files over a folder that already has files in it: FUSE
/// would hide them, unsent and out of sight, until the service stops
/// (`docs/BUGS.md` B277). Runs before the root fetch and before any worker
+606 -71
View File
@@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::fs::OpenOptions;
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::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -20,14 +20,17 @@ use base64::Engine as _;
use pdfs_core::control::{
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name,
is_thumbnail_media_name,
};
use pdfs_core::db::{self, StoredPhoto};
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::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType};
use tracing::{debug, info, warn};
use super::drive::RevisionRead;
use super::{
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority,
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 EXIFTOOL_MISSING_WARNING: Once = Once::new();
static FFMPEG_MISSING_WARNING: Once = Once::new();
/// Largest video downloaded whole to grab one frame for its thumbnail. The
/// download lands in memory, so a long 4K recording is left without one.
const VIDEO_THUMB_MAX_BYTES: u64 = 256 * 1024 * 1024;
/// How much of a video's start a thumbnail downloads. The first frame, and
/// the one a second in, sit inside it at any common bitrate. A video is never
/// 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
/// ffmpeg from holding a thumbnail permit.
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
@@ -72,6 +84,105 @@ const UPLOAD_THUMB_QUALITIES: [u8; 4] = [65, 50, 35, 20];
/// Largest still image read whole to render the thumbnail its upload carries.
const UPLOAD_IMAGE_MAX_BYTES: u64 = 256 * 1024 * 1024;
/// How much memory every thumbnail decode in the daemon may hold at once:
/// the encoded file plus its decoded pixels.
///
/// Without a limit, each of the 16 drain workers decoded its own full-size
/// photo. A batch of 100-megapixel JPEGs then held about 9 GiB, which swapped the
/// machine until the watchdog killed the daemon, on every restart
/// (`docs/BUGS.md` B280). With this budget, a phone photo still decodes beside
/// several others, and a 100-megapixel one decodes alone.
const DECODE_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
/// The process-wide [`DecodeBudget`] every thumbnail decode draws from.
static DECODE_BUDGET: DecodeBudget = DecodeBudget::new(DECODE_BUDGET_BYTES);
/// A counting gate over bytes: [`acquire`](Self::acquire) waits until the cost
/// fits beside what is already held.
struct DecodeBudget {
limit: u64,
used: std::sync::Mutex<u64>,
freed: std::sync::Condvar,
}
/// A share of a [`DecodeBudget`], given back on drop.
struct DecodePermit<'a> {
budget: &'a DecodeBudget,
cost: u64,
}
impl DecodeBudget {
const fn new(limit: u64) -> Self {
Self {
limit,
used: std::sync::Mutex::new(0),
freed: std::sync::Condvar::new(),
}
}
/// Wait until `cost` bytes fit, and hold them. A cost above the limit is
/// cut down to the limit, so the largest image waits for an idle budget
/// and then runs alone, instead of waiting forever.
fn acquire(&self, cost: u64) -> DecodePermit<'_> {
let cost = cost.clamp(1, self.limit);
let mut used = self
.used
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while *used + cost > self.limit {
used = self
.freed
.wait(used)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
*used += cost;
DecodePermit { budget: self, cost }
}
}
impl Drop for DecodePermit<'_> {
fn drop(&mut self) {
let mut used = self
.budget
.used
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*used -= self.cost;
self.budget.freed.notify_all();
}
}
/// What decoding an image of `encoded_len` bytes and `dimensions` costs in
/// memory: the encoded bytes, the decoded pixels at up to 4 bytes each, and a
/// second copy of the pixels when [`decode_upright`] rotates them. When the
/// header gives no dimensions, assume the image is compressed 16 to 1.
fn decode_cost(encoded_len: u64, dimensions: Option<(u32, u32)>) -> u64 {
let pixels = match dimensions {
Some((width, height)) => u64::from(width) * u64::from(height) * 4 * 2,
None => encoded_len.saturating_mul(16),
};
encoded_len.saturating_add(pixels)
}
/// The pixel dimensions in the header of an encoded image, without decoding it.
fn dimensions_of(bytes: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()
}
/// [`dimensions_of`] for an image file. Reads only the header.
fn dimensions_of_file(path: &Path) -> Option<(u32, u32)> {
image::ImageReader::open(path)
.ok()?
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()
}
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no
/// pixels are decoded. `None` when the format is unknown or the header is torn.
fn ratio_of(bytes: &[u8]) -> Option<f64> {
@@ -146,8 +257,8 @@ struct RawPreviewUnavailable;
/// the photo will never have a thumbnail, and only that verdict is persisted.
enum ThumbAttempt {
Made(GeneratedThumb),
/// Decoded nothing — a format this build has no decoder for, or a video
/// too large to fetch for one frame. Permanent.
/// Decoded nothing: a format this build has no decoder for, a video
/// ffmpeg takes no frame from, or a `.ts` that is not one. Permanent.
Undecodable,
/// The photo couldn't be downloaded. Transient: try again next time.
Unavailable,
@@ -209,7 +320,14 @@ fn thumbnail_build_may_start(status: &ThumbnailBuildStatus, path: &str) -> CoreR
/// formats return an undecodable verdict.
///
/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool.
/// Waits for its share of [`DECODE_BUDGET`] first.
fn scale_thumbnail(bytes: &[u8], name: &str, staging_dir: &Path) -> ScaleAttempt {
let _permit = DECODE_BUDGET.acquire(decode_cost(bytes.len() as u64, dimensions_of(bytes)));
scale_thumbnail_unbudgeted(bytes, name, staging_dir)
}
/// [`scale_thumbnail`] for a caller that already holds its [`DecodePermit`].
fn scale_thumbnail_unbudgeted(bytes: &[u8], name: &str, staging_dir: &Path) -> ScaleAttempt {
scale_thumbnail_with_exiftool(
bytes,
name,
@@ -471,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
/// 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
@@ -518,6 +621,151 @@ fn video_file_thumbnail(path: &Path, ffmpeg: &OsStr, timeout: Duration) -> Scale
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
/// 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.
@@ -544,11 +792,20 @@ fn upload_thumbnails_with(
}
video_file_thumbnail(path, ffmpeg, FFMPEG_TIMEOUT)
} else if is_thumbnail_image_name(name) {
let fits = std::fs::metadata(path).is_ok_and(|meta| meta.len() <= UPLOAD_IMAGE_MAX_BYTES);
let Some(bytes) = fits.then(|| std::fs::read(path).ok()).flatten() else {
let Some(len) = std::fs::metadata(path)
.ok()
.map(|meta| meta.len())
.filter(|len| *len <= UPLOAD_IMAGE_MAX_BYTES)
else {
return Vec::new();
};
scale_thumbnail(&bytes, name, staging_dir)
// Taken before the read, so the encoded bytes count against the
// budget too.
let _permit = DECODE_BUDGET.acquire(decode_cost(len, dimensions_of_file(path)));
let Ok(bytes) = std::fs::read(path) else {
return Vec::new();
};
scale_thumbnail_unbudgeted(&bytes, name, staging_dir)
} else {
return Vec::new();
};
@@ -1291,10 +1548,10 @@ impl Core {
.map(|item| (item.uid.clone(), item.name.clone()))
.collect();
// Proton only stores server-generated thumbnails for nodes in Photos.
// These requests come exclusively from ordinary Drive, Shared and Trash
// views, so downloading a remote thumbnail here would be a guaranteed
// miss. Generate directly from the full image and cache the result.
// Drive makes no thumbnails itself; a file has one only when its
// uploader attached it, as this client and Proton's apps do. The
// generation step asks for that first, and only makes one from the
// file's content without it.
let mut wanted = Vec::new();
let mut seen = HashSet::new();
for (raw, uid, modified) in &parsed {
@@ -1445,7 +1702,10 @@ impl Core {
return (uid, ThumbAttempt::Cancelled);
}
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);
}
@@ -1494,21 +1754,34 @@ impl Core {
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 = 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);
let downloaded = match job {
ThumbJob::Files(_) => loop {
@@ -1546,10 +1819,10 @@ impl Core {
let Some(downloaded) = downloaded else {
return (uid, ThumbAttempt::Cancelled);
};
let bytes = match downloaded {
Ok(bytes) => bytes,
let fetched = match downloaded {
Ok(fetched) => fetched,
Err(e) => {
warn!(%uid, error = %e, "image download for thumbnail failed");
warn!(%uid, error = %e, "download for a thumbnail failed");
return (uid, ThumbAttempt::Unavailable);
}
};
@@ -1559,18 +1832,20 @@ impl Core {
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
// stall the runtime's worker; hand it to the blocking pool.
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
let made = tokio::task::spawn_blocking(move || {
if video {
video_thumbnail(
&bytes,
&name,
&staging_dir,
OsStr::new("ffmpeg"),
FFMPEG_TIMEOUT,
)
} else {
scale_thumbnail(&bytes, &name, &staging_dir)
}
let made = tokio::task::spawn_blocking(move || match fetched {
Fetched::Video(sample) => video_sample_thumbnail(
&sample,
&name,
&staging_dir,
OsStr::new("ffmpeg"),
FFMPEG_TIMEOUT,
),
Fetched::Whole(bytes) => scale_thumbnail(&bytes, &name, &staging_dir),
Fetched::Nothing => ScaleAttempt::Unavailable,
Fetched::Ready(bytes) => match ratio_of(&bytes) {
Some(ratio) => ScaleAttempt::Made(GeneratedThumb { bytes, ratio }),
None => ScaleAttempt::Undecodable,
},
})
.await
.unwrap_or(ScaleAttempt::Unavailable);
@@ -1754,7 +2029,7 @@ impl Core {
if seen_folders.insert(entry.uid) {
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) {
Some(uid) if seen_images.insert(uid.clone()) => {
found_here += 1;
@@ -2116,19 +2391,83 @@ impl Core {
}
}
#[cfg(test)]
mod decode_budget_tests {
use super::{DecodeBudget, decode_cost};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
/// A decode that does not fit beside the ones running waits for them,
/// instead of every drain worker decoding at once (`docs/BUGS.md` B280).
#[test]
fn a_decode_over_the_budget_waits_for_the_running_one() {
let budget = Arc::new(DecodeBudget::new(100));
let first = budget.acquire(60);
let started = Arc::new(AtomicBool::new(false));
let waiter = {
let (budget, started) = (budget.clone(), started.clone());
std::thread::spawn(move || {
let _second = budget.acquire(60);
started.store(true, Ordering::SeqCst);
})
};
std::thread::sleep(Duration::from_millis(100));
assert!(
!started.load(Ordering::SeqCst),
"second decode ran over budget"
);
drop(first);
waiter.join().expect("waiter");
assert!(started.load(Ordering::SeqCst));
}
/// Decodes that fit together run together.
#[test]
fn small_decodes_share_the_budget() {
let budget = DecodeBudget::new(100);
let _a = budget.acquire(30);
let _b = budget.acquire(30);
let _c = budget.acquire(30);
}
/// An image bigger than the whole budget still decodes, alone, rather than
/// waiting forever.
#[test]
fn an_image_over_the_whole_budget_still_decodes() {
let budget = DecodeBudget::new(100);
drop(budget.acquire(10_000));
let _again = budget.acquire(100);
}
/// The cost of the 102-megapixel JPEG that set off B280 fills the real
/// budget, so it never decodes beside another one.
#[test]
fn a_hundred_megapixel_photo_costs_the_whole_budget() {
let cost = decode_cost(97_266_141, Some((11_656, 8_742)));
assert!(cost >= super::DECODE_BUDGET_BYTES);
let phone = decode_cost(4_000_000, Some((4_000, 3_000)));
assert!(phone * 4 < super::DECODE_BUDGET_BYTES);
}
}
#[cfg(test)]
mod thumb_tests {
use super::{
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,
ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool, similar_hashes,
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
VIDEO_HEAD_BYTES, VIDEO_WHOLE_MAX_BYTES, VideoSample, can_make_thumbnail, exiftool_binary,
ffmpeg_available, fit_upload_thumb, is_video_name, ratio_of, sample_video, scale_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 proton_drive_rs::ThumbnailType;
use proton_drive_rs::proton_sdk::error::Result as DriveResult;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt as _;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
/// A `width`×`height` JPEG, standing in for a camera photo the server never
@@ -2361,6 +2700,199 @@ mod thumb_tests {
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]
fn a_video_frame_becomes_a_thumbnail() {
let tmp = scratch("video-frame");
@@ -2369,8 +2901,8 @@ mod thumb_tests {
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
let ScaleAttempt::Made(thumb) = video_thumbnail(
b"video bytes",
let ScaleAttempt::Made(thumb) = video_sample_thumbnail(
&whole(b"video bytes"),
"clip.mp4",
&tmp,
ffmpeg.as_os_str(),
@@ -2386,8 +2918,8 @@ mod thumb_tests {
fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() {
let tmp = scratch("video-broken");
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
let attempt = video_thumbnail(
b"not a video",
let attempt = video_sample_thumbnail(
&whole(b"not a video"),
"clip.mp4",
&tmp,
ffmpeg.as_os_str(),
@@ -2399,8 +2931,8 @@ mod thumb_tests {
#[test]
fn missing_ffmpeg_is_retryable() {
let attempt = video_thumbnail(
b"video bytes",
let attempt = video_sample_thumbnail(
&whole(b"video bytes"),
"clip.mp4",
&std::env::temp_dir(),
OsStr::new("pdfs-ffmpeg-deliberately-absent"),
@@ -2416,9 +2948,12 @@ mod thumb_tests {
let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_FIXTURE");
let path = std::path::PathBuf::from(path);
let name = path.file_name().unwrap().to_string_lossy();
let bytes = std::fs::read(&path).unwrap();
let ScaleAttempt::Made(thumb) = video_thumbnail(
&bytes,
// Through the same partial download a Drive video gets.
let revision = FakeRevision::new(std::fs::read(&path).unwrap().len() as u64)
.with(0, std::fs::read(&path).unwrap());
let sample = block_on(sample_video(&revision)).unwrap();
let ScaleAttempt::Made(thumb) = video_sample_thumbnail(
&sample,
&name,
&std::env::temp_dir(),
OsStr::new("ffmpeg"),
+106
View File
@@ -3619,4 +3619,110 @@ mod tests {
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// A video in My files gets a thumbnail from a frame of its own, but a
/// recording can be gigabytes: only its start, and an MP4's index, are
/// downloaded for it. A video with a thumbnail from its uploader is not
/// downloaded at all, and a `.ts` that is TypeScript gets no thumbnail.
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_video_thumbnail_downloads_a_small_part_of_the_video() {
if std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_err()
{
eprintln!("skipped: needs ffmpeg");
return;
}
let dir = scratch("video-thumbs");
std::fs::create_dir_all(&dir).unwrap();
// Twenty seconds at 30 Mbit/s: larger than a thumbnail downloads whole.
// An MP4 written this way has its index at the end.
let encode = |name: &str| {
let path = dir.join(name);
let status = std::process::Command::new("ffmpeg")
.args(["-nostdin", "-loglevel", "error", "-y", "-f", "lavfi"])
.args(["-i", "testsrc2=size=1280x720:rate=30", "-t", "20"])
.args(["-c:v", "libx264", "-preset", "ultrafast"])
.args(["-b:v", "30M", "-maxrate", "30M", "-bufsize", "30M"])
.arg(&path)
.status()
.unwrap();
assert!(status.success());
let bytes = std::fs::read(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert!(bytes.len() > 30 << 20, "{name} is {} bytes", bytes.len());
bytes
};
let (recording, clip) = (encode("recording.ts"), encode("clip.mp4"));
let source = b"export const answer = 42;\n".repeat(64 * 1024);
let drive = FakeDrive::new();
drive.device().write("recording.ts", &recording);
drive.device().write("clip.mp4", &clip);
drive.device().write("app.ts", &source);
let phone = drive.device().write("phone.mp4", &clip);
let mut frame = Vec::new();
image::codecs::jpeg::JpegEncoder::new(&mut frame)
.encode_image(&image::RgbImage::new(320, 180))
.unwrap();
let client = drive.client(1, Faults::lan());
client.set_thumbnail(&phone, frame);
let daemon = Daemon::start(&dir.join("daemon"), client.clone()).unwrap();
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir {
path: String::new(),
}) else {
panic!("listing");
};
let items: Vec<control::FileThumbRequest> = entries
.iter()
.filter(|entry| !entry.is_dir)
.map(|entry| control::FileThumbRequest {
uid: entry.uid.clone(),
modified: entry.modified,
name: entry.name.clone(),
})
.collect();
let Ok(Response::FileThumbGeneration { generation }) =
daemon.request(&Request::ReserveFileThumbGeneration)
else {
panic!("generation");
};
let read_before = client.content_read();
let mut paths = std::collections::HashMap::new();
let settled = wait_until(Duration::from_secs(120), || {
let Ok(Response::Thumbs { items: replies }) = daemon.request(&Request::FileThumbs {
items: items.clone(),
generation,
}) else {
return false;
};
paths = replies
.iter()
.filter_map(|reply| {
let name = &items.iter().find(|item| item.uid == reply.uid)?.name;
Some((name.clone(), (reply.path.clone(), reply.pending)))
})
.collect();
paths.values().all(|(_, pending)| !pending) && paths.len() == 4
});
assert!(settled, "{paths:?}");
let read = client.content_read() - read_before;
for name in ["recording.ts", "clip.mp4", "phone.mp4"] {
assert!(paths[name].0.is_some(), "{name} has a thumbnail: {paths:?}");
}
assert_eq!(paths["app.ts"].0, None, "TypeScript is no video");
// Two 16 MiB heads, one MP4 index and the TypeScript file, and nothing
// of the video that came with a thumbnail.
assert!(
read < 34 << 20,
"downloaded {} MiB for three thumbnails",
read >> 20
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
}
+43 -3
View File
@@ -62,7 +62,7 @@ use proton_drive_rs::proton_sdk::ids::{
};
use proton_drive_rs::{
AvailableNameTarget, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node,
NodeKind, NodeMoveItem, RevisionState, Thumbnail,
NodeKind, NodeMoveItem, RevisionState, Thumbnail, ThumbnailType,
};
use sha1::{Digest as _, Sha1};
@@ -396,6 +396,8 @@ impl FakeDrive {
hold_answer: Mutex::new(None),
hold_trash: 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_landing: Mutex::new(None),
drop_after_create: AtomicBool::new(false),
@@ -677,6 +679,10 @@ struct ClientInner {
hold_trash: Mutex<Option<Arc<HoldState>>>,
/// The download [`FakeClient::hold_next_download`] is waiting for.
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.
hold_landing: Mutex<Option<Arc<HoldState>>>,
/// 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,
/// 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 {
let state = Arc::new(HoldState {
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(
&self,
uid: &NodeUid,
@@ -1490,6 +1521,9 @@ impl DriveApi for FakeClient {
.await?;
self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid))
.await;
self.0
.content_read
.fetch_add(content.len() as u64, Ordering::SeqCst);
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>> {
let content = self.content.clone();
self.client
let read = self
.client
.request(false, move |_| {
let start = (offset as usize).min(content.len());
let end = (offset.saturating_add(length) as usize).min(content.len());
Ok(content[start..end].to_vec())
})
.await
.await?;
self.client
.0
.content_read
.fetch_add(read.len() as u64, Ordering::SeqCst);
Ok(read)
}
}
+29 -3
View File
@@ -57,7 +57,10 @@ pub(crate) fn run(core: Core, control_socket: PathBuf) {
}
let mut last_rss = Instant::now();
let mut last_ping = Instant::now();
// `None` until the first probe, which runs on the first tick: startup has
// already used up much of the watchdog's time (`docs/BUGS.md` B279).
let mut last_ping: Option<Instant> = None;
let mut restored = false;
// Per-lane completion counts from the previous tick, to tell a queue that is
// being worked through from one that is not moving at all.
let mut last_completed = (0_u64, 0_u64);
@@ -84,16 +87,22 @@ pub(crate) fn run(core: Core, control_socket: PathBuf) {
// throw away in-flight uploads. The probe decides that; a stall only
// gets said out loud.
if let Some(period) = watchdog
&& last_ping.elapsed() >= period
&& ping_due(last_ping, period)
{
if probe(&control_socket) {
if !restored {
// Startup ran on the longer timeout the daemon asked for.
// The socket answers now, so the unit's own one applies.
systemd::restore_watchdog();
restored = true;
}
systemd::watchdog_ping();
} else {
warn!(
"daemon did not answer its own control socket; withholding the watchdog ping"
);
}
last_ping = Instant::now();
last_ping = Some(Instant::now());
}
}
}
@@ -165,6 +174,11 @@ fn report_stalls(report: &pdfs_core::control::Diagnostics, last_completed: (u64,
stalled
}
/// Whether the watchdog wants its next ping. The first one is due at once.
fn ping_due(last_ping: Option<Instant>, period: Duration) -> bool {
last_ping.is_none_or(|at| at.elapsed() >= period)
}
/// One round trip over the control socket: connect, ask for the cheapest
/// request there is, read the answer.
///
@@ -205,6 +219,18 @@ mod tests {
}
}
/// The first ping goes out on the first tick, not a full period after the
/// supervisor starts: startup has already spent most of the watchdog's time
/// (`docs/BUGS.md` B279).
#[test]
fn first_watchdog_ping_is_due_at_once() {
let period = Duration::from_secs(60);
assert!(ping_due(None, period));
assert!(!ping_due(Some(Instant::now()), period));
let long_ago = Instant::now().checked_sub(period).expect("uptime");
assert!(ping_due(Some(long_ago), period));
}
/// A worker doing normal work must not be reported: a WARN every tick is a
/// WARN nobody reads.
#[test]
+31
View File
@@ -102,6 +102,37 @@ pub fn idle_sleep(duration: Duration) {
}
}
/// How long a mount may take to start before the watchdog counts it as hung.
///
/// Startup has no control socket to probe yet, and on a large account it is
/// long: hydrating 48 000 nodes took 70 s on 2026-11-12, and the secondary
/// mounts followed. Under the 2-minute `WatchdogSec` that run was aborted just
/// before its first ping, and left six dead mounts behind (`docs/BUGS.md` B279).
const STARTUP_WATCHDOG: Duration = Duration::from_secs(600);
/// Give the mount about to start [`STARTUP_WATCHDOG`] before the watchdog fires.
///
/// Sends `WATCHDOG_USEC=`, which replaces the unit's timeout and restarts its
/// timer. The supervisor puts the configured timeout back with
/// [`restore_watchdog`] once it can prove the daemon answers. A startup that
/// wedges for good is still aborted, only later.
pub fn stretch_watchdog_for_startup() {
if watchdog_period().is_some() {
notify(&format!("WATCHDOG_USEC={}\n", STARTUP_WATCHDOG.as_micros()));
}
}
/// Put back the timeout the unit configured, undoing
/// [`stretch_watchdog_for_startup`].
pub(crate) fn restore_watchdog() {
if watchdog_period().is_none() {
return;
}
if let Ok(usec) = std::env::var("WATCHDOG_USEC") {
notify(&format!("WATCHDOG_USEC={usec}\n"));
}
}
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
/// for a hang by the watchdog it is racing.
pub(crate) fn stopping() {
@@ -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-map-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-sync-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-synced-symbolic.svg</file>
+9 -3
View File
@@ -53,7 +53,7 @@ button.sidebar-account { padding: 4px 8px; font-weight: normal; }
/* The folder on screen, last in the breadcrumb trail: a flat button's side
* padding, so the "›" before it is spaced like the ones between buttons. */
.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); }
.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); }
@@ -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); }
/* 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:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
.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-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-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); }
.memory-card { padding: 0; border-radius: 12px; }
.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-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
.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. */
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
+30 -11
View File
@@ -71,7 +71,7 @@ use gtk4::glib::BoxedAnyObject;
use pdfs_core::auth;
use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState};
use pdfs_core::config::{AppDirs, FileSort, FilesView, PhotosView, WindowState};
use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
@@ -488,6 +488,7 @@ fn build_window(app: &adw::Application) {
empty_actions: browser_widgets.empty_actions.clone(),
summary: browser_widgets.summary.clone(),
zoom: browser_widgets.zoom.clone(),
zoom_save: RefCell::new(None),
},
details: DetailsState {
details: browser_widgets.details,
@@ -587,10 +588,13 @@ fn build_window(app: &adw::Application) {
selected: RefCell::new(HashSet::new()),
select_anchor: RefCell::new(None),
select_btn: gallery_widgets.select_btn.clone(),
select_bar: gallery_widgets.select_bar.clone(),
select_label: gallery_widgets.select_label.clone(),
select_trash: gallery_widgets.select_trash.clone(),
select_album: gallery_widgets.select_album.clone(),
selection: gallery_widgets.selection.clone(),
more: gallery_widgets.more.clone(),
upload_face: gallery_widgets.upload_face.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_by_me: SharedByMeState::new(&shared_by_me_widgets),
@@ -693,7 +697,7 @@ fn build_window(app: &adw::Application) {
&ui,
&gallery_widgets.list,
&gallery_widgets.scroll,
&gallery_widgets.select_done,
&gallery_widgets.square_action,
);
wire_gallery_empty(
&ui,
@@ -795,6 +799,7 @@ fn build_window(app: &adw::Application) {
/// split collapsed whatever the width.
fn set_narrow(ui: &Ui, narrow: bool) {
ui.narrow.set(narrow);
set_photos_narrow(ui, narrow);
if ui.session.borrow().is_some() {
ui.nav.set_collapsed(narrow);
}
@@ -858,11 +863,7 @@ fn window_hidden(window: &adw::ApplicationWindow) -> bool {
/// sync, set off by a separator.
const DESTINATIONS: [(&str, &str, &str); 7] = [
("browser", gettext_noop("My files"), "folder-symbolic"),
(
"gallery",
gettext_noop("Photos"),
"image-x-generic-symbolic",
),
("gallery", gettext_noop("Photos"), "pdfs-photos-symbolic"),
(
"shared",
gettext_noop("Shared with me"),
@@ -1523,6 +1524,9 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
("<Alt>Left <Alt>Right", gettext_noop("Back / forward")),
("<Alt>Up", gettext_noop("Parent folder")),
("<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")),
("Delete", gettext_noop("Move to Trash")),
("Escape", gettext_noop("Clear the selection")),
@@ -1588,6 +1592,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
ui.browser.search.grab_focus();
}
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(
&ui,
FilesView {
+5 -9
View File
@@ -40,7 +40,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") {
albums_status(
ui,
"view-grid-symbolic",
"pdfs-album-symbolic",
&gettext("Loading 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(
&ui,
"view-grid-symbolic",
"pdfs-album-symbolic",
&gettext("No albums"),
&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");
let placeholder = gtk4::Image::builder()
.icon_name("view-grid-symbolic")
.icon_name("pdfs-album-symbolic")
.pixel_size(32)
.halign(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
// album is a level below it, and back is the way out.
ui.gallery.filters.set_visible(false);
ui.gallery.view_switch.widget().set_visible(false);
ui.gallery.upload.set_visible(false);
ui.gallery.back.set_visible(true);
sync_header(ui);
// A stale kind/date filter would otherwise be sent with the album request.
ui.gallery.kind.set(None);
ui.gallery.range.set(None);
@@ -636,9 +634,7 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
return;
}
ui.gallery.title.set_title(&gettext("Photos"));
ui.gallery.view_switch.widget().set_visible(true);
ui.gallery.upload.set_visible(true);
ui.gallery.back.set_visible(false);
sync_header(ui);
}
/// Show the album grid's own status page (loading, empty, or an error).
+116 -13
View File
@@ -77,6 +77,8 @@ pub(crate) struct BrowserState {
/// zoom value, and Proton account storage usage.
pub(crate) summary: gtk4::Label,
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
@@ -87,9 +89,11 @@ pub(crate) const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250);
pub(crate) const SEARCH_LIMIT: usize = 200;
/// 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_MAX: i32 = 144;
pub(crate) const GRID_THUMB_MAX: i32 = 256;
pub(crate) const GRID_THUMB_DEFAULT: i32 = 72;
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);
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
.column_view
@@ -1306,6 +1338,11 @@ pub(crate) fn wire_browser_actions(ui: &Rc<Ui>, build_thumbnails: &gtk4::Button)
let view = ui.dirs.load_config().files_view;
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(
"view",
Some(glib::VariantTy::STRING),
@@ -1407,6 +1444,41 @@ pub(crate) fn wire_browser_actions(ui: &Rc<Ui>, build_thumbnails: &gtk4::Button)
ui.browser.content.add_controller(drop);
}
/// 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 {
if view.list { "list" } else { "grid" }
}
@@ -1728,8 +1800,9 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
let text = if status.scanning {
ui.browser.thumbnail_progress.pulse();
let folders = ngettext_f("{n} folder", "{n} folders", status.folders_scanned, &[]);
let images = ngettext_f("{n} image", "{n} images", status.images_found, &[]);
// Translators: {root} is a folder path or "Proton Drive"; {folders} and {images} are counts such as "3 folders" and "12 images".
// Translators: a count of images and videos that can have a thumbnail.
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(
"Scanning {root}… {folders}, {images}",
&[("root", root), ("folders", &folders), ("images", &images)],
@@ -1743,7 +1816,7 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
ui.browser
.thumbnail_progress
.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(
"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);
let available = status.completed.saturating_sub(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(
"Thumbnails ready for {n} image in {root}",
"Thumbnails ready for {n} images in {root}",
"Thumbnails ready for {n} file in {root}",
"Thumbnails ready for {n} files in {root}",
available,
&[("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(
"Thumbnails ready for {n} image in {root}; {failed} unavailable",
"Thumbnails ready for {n} images in {root}; {failed} unavailable",
"Thumbnails ready for {n} file in {root}; {failed} unavailable",
"Thumbnails ready for {n} files in {root}; {failed} unavailable",
available,
&[("root", root), ("failed", &failed.to_string())],
),
@@ -2765,6 +2838,11 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
if entry.is_dir {
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
.name
.rsplit('.')
@@ -2775,7 +2853,6 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "heic" | "tiff" => {
"image-x-generic"
}
"mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" => "video-x-generic",
"mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" => "audio-x-generic",
"pdf" | "doc" | "docx" | "odt" => "x-office-document",
"xls" | "xlsx" | "ods" | "csv" => "x-office-spreadsheet",
@@ -3122,7 +3199,7 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
#[cfg(test)]
mod tests {
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,
};
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> {
entries.iter().map(|e| e.name.as_str()).collect()
}
+35 -23
View File
@@ -40,7 +40,9 @@ pub(crate) struct Viewer {
/// Details panel: the toggle that reveals it, the rows it fills in, and the
/// "Show on map" button (hidden when the photo carries no GPS tags).
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_map: gtk4::Button,
/// 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) {
viewer.window.unfullscreen();
}
viewer
.slideshow
.set_icon_name("media-playback-start-symbolic");
viewer.slideshow.set_icon_name("pdfs-slideshow-symbolic");
if viewer.slideshow.is_active() {
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) {
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"))
.modal(true)
.transient_for(&parent)
.default_width(1100)
.default_height(760)
.build();
let window: gtk4::Window = adw_window.clone().upcast();
window.add_css_class("photo-viewer-window");
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");
let slideshow_btn = gtk4::ToggleButton::builder()
.icon_name("media-playback-start-symbolic")
.icon_name("pdfs-slideshow-symbolic")
.tooltip_text(gettext("Slideshow (F5)"))
.valign(gtk4::Align::Center)
.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);
info_panel.add_css_class("viewer-info-panel");
info_panel.set_width_request(320);
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();
spinner.set_halign(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");
overlay.add_overlay(&status_label);
let body = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
body.append(&overlay);
body.append(&info_revealer);
window.set_child(Some(&body));
// The panel docks beside the photo, or lies over it in a narrow window,
// where a click on the photo closes it again.
let info_split = adw::OverlaySplitView::builder()
.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 {
window: window.clone(),
@@ -1250,7 +1257,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
prev: prev_btn.clone(),
next: next_btn.clone(),
info_toggle: info_toggle.clone(),
info_revealer: info_revealer.clone(),
info_split: info_split.clone(),
info_rows,
info_map: info_map.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 {
return;
};
viewer_info
.info_revealer
.set_reveal_child(toggle.is_active());
viewer_info.info_split.set_show_sidebar(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();
File diff suppressed because it is too large. Load diff
+2 -6
View File
@@ -422,9 +422,7 @@ fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
// As in an album: no filters, no Upload, no view switch — back leads out.
ui.gallery.filters.set_visible(false);
ui.gallery.view_switch.widget().set_visible(false);
ui.gallery.upload.set_visible(false);
ui.gallery.back.set_visible(true);
sync_header(ui);
ui.gallery.kind.set(None);
ui.gallery.range.set(None);
@@ -439,9 +437,7 @@ pub(crate) fn close_place(ui: &Rc<Ui>) {
return;
}
ui.gallery.title.set_title(&gettext("Photos"));
ui.gallery.view_switch.widget().set_visible(true);
ui.gallery.upload.set_visible(true);
ui.gallery.back.set_visible(false);
sync_header(ui);
}
/// Show the place grid's own status page (loading, empty, or an error).
+81 -2
View File
@@ -10,6 +10,12 @@ use crate::*;
pub(crate) struct ToggleGroup {
widget: gtk4::Widget,
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)]
@@ -47,6 +53,9 @@ impl ToggleGroup {
Self {
widget,
options: Options::Toggles(toggles),
labels: Rc::new(RefCell::new(labels_of(options))),
tooltips: tooltips_of(options),
compact: Rc::default(),
}
} else {
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
@@ -71,6 +80,9 @@ impl ToggleGroup {
Self {
widget: bar.upcast(),
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) {
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 {
Options::Toggles(toggles) => {
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) => {
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()
}
/// 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)]
mod tests {
use super::*;
+1 -1
View File
@@ -478,7 +478,7 @@ pub(crate) fn text_column(
fn resize_grid_tile(thumbnail: &gtk4::Overlay, label: &gtk4::Label, size: i32) {
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_max_width_chars(name_width);
}
+48 -10
View File
@@ -1,4 +1,4 @@
//! Shared thumbnails for ordinary Drive image files.
//! Shared thumbnails for ordinary Drive image and video files.
//!
//! The Photos gallery has timeline-specific batching and aspect-ratio state.
//! 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.
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_CACHE_MAX: usize = 256;
@@ -125,6 +125,18 @@ pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Over
.build();
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();
overlay.set_width_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_child(Some(&fallback));
overlay.add_overlay(&picture);
overlay.add_overlay(&play);
overlay
}
/// The play badge [`file_thumbnail_widget`] puts after `picture`.
fn play_badge(picture: &gtk4::Picture) -> Option<gtk4::Image> {
picture
.next_sibling()
.and_downcast::<gtk4::Image>()
.filter(|image| image.has_css_class("photo-video-badge"))
}
/// Marks a play badge to show once its video's frame is painted.
const VIDEO_BADGE: &str = "video";
/// Resize an existing thumbnail surface. Grid rows are recycled, so changing
/// these requests in place and rebinding the model is enough to implement the
/// status-bar zoom without downloading or decoding an image again.
@@ -179,6 +203,16 @@ pub(crate) fn bind_file_thumbnail(
fallback.set_visible(true);
picture.set_visible(false);
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
// 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);
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;
}
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);
}
/// 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 {
format!("{uid}:{modified}")
}
@@ -238,6 +269,9 @@ fn paint(picture: &gtk4::Picture, key: &str, texture: &gtk4::gdk::Texture) {
}
picture.set_paintable(Some(texture));
picture.set_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>()
&& 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)]
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]
fn image_extensions_are_case_insensitive_and_specific() {
+2 -2
View File
@@ -10,7 +10,7 @@ use std::time::Duration;
use adw::prelude::*;
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 crate::{Hit, dates, gettext, human_bytes, pgettext};
@@ -235,7 +235,7 @@ impl Preview {
let content_type = if hit.is_dir() {
glib::GString::from("inode/directory")
} else {
gio::functions::content_type_guess(Some(hit.name()), None).0
content_type_for(hit.name(), hit.size())
};
self.kind
.set_label(&gio::functions::content_type_get_description(&content_type));
+18 -5
View File
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use std::rc::Rc;
use adw::prelude::*;
use gtk4::{gdk, gio};
use gtk4::{gdk, gio, glib};
use super::highlight;
use super::thumbs::Thumbs;
@@ -53,7 +53,7 @@ pub(crate) fn fill_slot(slot: &gtk4::Overlay, hit: &Hit, thumbs: &Rc<Thumbs>, mo
let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() else {
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);
picture.set_visible(false);
picture.set_paintable(gdk::Paintable::NONE);
@@ -63,7 +63,7 @@ pub(crate) fn fill_slot(slot: &gtk4::Overlay, hit: &Hit, thumbs: &Rc<Thumbs>, mo
/// The desktop's own full-colour icon for a file name, so results look like
/// the user's file manager. Folders are Proton's purple ones while the Proton
/// 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 {
let folder = if proton_theme_active() {
"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();
}
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None);
gio::functions::content_type_get_icon(&content_type)
gio::functions::content_type_get_icon(&content_type_for(name, size))
}
/// 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,
+12 -4
View File
@@ -9,8 +9,8 @@
//! matches the file's;
//! 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;
//! 3. for Drive images, the daemon's `FileThumbs`, batched after a short quiet
//! period.
//! 3. for Drive images and videos, the daemon's `FileThumbs`, batched after a
//! short quiet period.
//!
//! 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
@@ -30,7 +30,9 @@ use gtk4::gdk_pixbuf::Pixbuf;
use gtk4::prelude::*;
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};
@@ -165,7 +167,13 @@ impl Thumbs {
// The widget name binds the picture to one key: a late reply for
// another hit must not paint into it.
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;
}
if matches!(hit, Hit::Drive(drive) if drive.uid.is_empty()) {
+1 -1
View File
@@ -94,7 +94,7 @@ pub(crate) fn icon_name(hit: &Hit) -> String {
let name = hit.name();
if is_image(name) {
"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"
} else if is_document(name) {
"text-x-generic"
+3
View File
@@ -52,6 +52,7 @@ const STOCK: &[&str] = &[
"image-missing-symbolic",
"image-x-generic-symbolic",
"list-add-symbolic",
"list-remove-symbolic",
"mark-location-symbolic",
"media-playback-pause-symbolic",
"media-playback-start-symbolic",
@@ -61,10 +62,12 @@ const STOCK: &[&str] = &[
"object-select-symbolic",
"preferences-system-symbolic",
"process-stop-symbolic",
"selection-mode-symbolic",
"starred-symbolic",
"system-search-symbolic",
"text-x-generic-symbolic",
"user-trash-symbolic",
"video-x-generic-symbolic",
"view-grid-symbolic",
"view-list-symbolic",
"view-more-symbolic",
+118
View File
@@ -109,6 +109,9 @@ a live account since.
| 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 |
| [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 |
| [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 |
| [B277](#b277--my-files-mounted-over-a-folder-with-files-in-it-and-hid-them) | My files mounted over a folder with files in it, and hid them |
| [B274](#b274--opening-a-file-for-writing-fails-with-eio-while-its-queued-revision-lands) | Opening a file for writing fails with EIO while its queued revision lands |
@@ -381,6 +384,121 @@ a live account since.
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.
---
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
**Status:** Fixed, not verified live
**Found:** 2026-11-12, after B279. 868 new pictures (15.6 GiB) were copied into
`~/ProtonDrive/Pictures`. Each restart reached 1.7 GiB resident and 9.8 GiB of swap within 40 s.
The 16 `pdfs-drain-*` threads were all busy, cgroup memory pressure (`full avg10`) was 77 %, and
the system's 15 GiB of swap was full. The watchdog killed the daemon, and the next start did the
same. The largest picture was a 97 MB JPEG of 11656×8742 pixels.
**Where:** `upload_thumbnails_with` and `scale_thumbnail` in `crates/pdfs-fuse/src/photos.rs`
**Repro:** Queue more large photos than there are drain workers, for example by copying a folder
of 100-megapixel JPEGs into the mount, and start the daemon.
**Cause.** Every upload of an image builds its thumbnail by decoding the whole file. Nothing
limited how many of these decodes ran at once, so all 16 drain workers did it together. One
102-megapixel JPEG holds about 300 MB of pixels, and twice that when it has to be turned upright.
Sixteen of them do not fit in memory, and `MemoryHigh=2G` throttled the daemon into swap until it
stopped answering.
**Fix.** Each thumbnail decode first takes a share of a process-wide 512 MiB budget. The share is
the encoded size plus 8 bytes per pixel, read from the image header. A 12-megapixel phone photo
still decodes beside four others. A 100-megapixel photo waits until no other decode runs, and
then runs alone. The upload path takes its share before it reads the file.
**Test:** `decode_budget_tests` in `crates/pdfs-fuse/src/photos.rs`. On 2026-11-12 the same queue
on the fixed build peaked at 1.3 GiB resident and 90 MB of swap, with memory pressure near 0 %.
---
### B279 — After a watchdog kill the mount never comes back
**Status:** Fixed, not verified live
**Found:** 2026-11-12, copying a folder of pictures into `~/ProtonDrive/Pictures`. The daemon
stopped answering, the watchdog aborted it after 4 h 39 min (2.5 GiB memory peak, 11.8 GiB swap
peak), and every restart then logged
`create mountpoint /home/narl/ProtonDrive: File exists (os error 17)`. After the dead mounts were
cleared by hand, the next start mounted all six folders and was aborted again, 120 s after it
began, leaving six dead mounts behind once more.
**Where:** `mount_once` in `crates/pdfs-cli/src/main.rs`; `prepare_mountpoint` in
`crates/pdfs-fuse/src/mount.rs`; `crates/pdfs-fuse/src/supervisor.rs`;
`crates/pdfs-fuse/src/systemd.rs`
**Repro:** Kill the daemon with `SIGABRT` while it is mounted. Each restart fails with `EEXIST`
until `fusermount3 -uz` clears every mount. Then restart on an account where hydrating the
metadata cache takes about a minute: the watchdog aborts the new daemon before it pings.
**Cause.** Two faults, one after the other.
1. A dead FUSE mount answers `ENOTCONN`. `create_dir_all` in `mount_once` read that as "absent",
tried `mkdir`, and failed with `EEXIST`. The daemon retried that error forever and never
reached `check_mountpoint`, which clears a dead mount.
2. systemd arms the watchdog when the process starts, but the supervisor sent its first ping one
full period (60 s) after it started. The supervisor starts only once the mount is live: 74 s
in on this account (70 s of that is hydrating 47 958 nodes). 74 s + 60 s is past the 120 s
`WatchdogSec`, so the run was aborted just before its first ping.
**Fix.** `mount_once` calls `prepare_mountpoint`, which clears a dead mount before it creates the
directory. Before startup, the daemon sends `WATCHDOG_USEC=` to allow 10 minutes. When the
supervisor first gets an answer from the control socket, it sends the unit's own timeout back.
The supervisor also probes and pings on its first tick, not one period later. A startup that
hangs for good is still aborted, only later.
The daemon stopped answering during the copy because of
[B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed).
**Test:** `first_watchdog_ping_is_due_at_once` in `crates/pdfs-fuse/src/supervisor.rs`. The
stale-mount path and the watchdog handshake need a live systemd unit to check.
---
### B278 — `sync list` and `devices list` show the last sync as epoch seconds
**Status:** Fixed, not verified live
+47
View File
@@ -22,10 +22,57 @@ Existing folders remain device backups; older builds cannot open the upgraded da
### Added
- Shell completion for `pdfs` in bash, zsh and fish, installed by every package, plus elvish
and PowerShell through `pdfs completions SHELL`. Besides commands and options it completes
paths in My files and the ids and uids the running service knows (synced folders, the queue,
computers, trash, albums, shares, invitations, bookmarks, conflicts and versions), each with
a description. Share roles are now checked by the CLI (`viewer`, `editor`, `admin`) before
the request.
- `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing
My files folder. The first sync preserves one-sided files and differing versions (#67).
Attachments stay in mirror mode, are excluded from device profiles, and cannot delete
the remote root when syncing is stopped.
- **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.
### Fixed
- Large `.ts` recordings (MPEG transport streams) show and open as videos: the video icon, a
thumbnail, and the `@media` open rule rather than `@text`. 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 (B283).
- A video over 256 MiB never got a thumbnail, and a smaller one was downloaded whole into
memory for one frame (B283).
## [3.7.0] — 2026-11-02
+32
View File
@@ -8,6 +8,7 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount
what the commands do in the app, see the [user guide](USER_GUIDE.md).
- [Conventions](#conventions)
- [Shell completion](#shell-completion)
- [Account and service](#account-and-service)
- [Files](#files)
- [Available offline](#available-offline)
@@ -32,6 +33,36 @@ what the commands do in the app, see the [user guide](USER_GUIDE.md).
service.
- **Output** is English and stable enough for `grep`; use `--json` where it is available.
## Shell completion
Tab completes commands and options, and also the values the service knows. Paths complete
from My files, and these complete with a description: synced-folder and queue ids, computers,
trash items, albums, items shared with you, invitations, bookmarks, conflict copies, and a file's
versions or a share's members and link once the path is typed. Values come from the running
service. Without it, only commands, options and local paths complete.
| Example | Completes |
|---|---|
| `pdfs pin Doc<Tab>` | `Documents/`; Tab again lists its contents |
| `pdfs sync rm <Tab>` | `1 /home/me/Documents (mirror)` … |
| `pdfs devices rm <Tab>` | computer uids, with their names |
| `pdfs versions restore Documents/a.pdf <Tab>` | version ids, with date and size |
The packages install completion for bash, zsh and fish, which these shells load by themselves. For
a source build, or another shell, load the script that `pdfs completions <shell>` prints:
| Shell | Add to |
|---|---|
| bash | `~/.bashrc`: `source <(pdfs completions bash)` |
| zsh | `~/.zshrc`, after `compinit`: `source <(pdfs completions zsh)` |
| fish | `~/.config/fish/config.fish`: `pdfs completions fish \| source` |
| elvish | `~/.config/elvish/rc.elv`: `eval (pdfs completions elvish \| slurp)` |
| PowerShell | `$PROFILE`: `pdfs completions powershell \| Out-String \| Invoke-Expression` |
The script runs `pdfs` from `PATH` on every Tab, so it keeps working after an update. A
`pdfs` that is not on `PATH` prints a script for its own location with
`PDFS_COMPLETE=<shell> ./pdfs`.
## Account and service
| Command | Description |
@@ -185,6 +216,7 @@ commands are for scripts and for cases where the mount is not available.
| `pdfs cache inspect [--deep]` | Database size, reclaimable space, row counts, cache use against the limit. `--deep` also runs SQLite's integrity check (slow). |
| `pdfs cache vacuum` | Compact the database. Needs free space for a second copy of it while it runs. |
| `pdfs cache clear` | Delete cached file content. Files kept available offline stay. |
| `pdfs completions SHELL` | Print the [shell completion](#shell-completion) script for bash, zsh, fish, elvish or powershell. |
[Troubleshooting](TROUBLESHOOTING.md) explains when to use each of these.
+3
View File
@@ -80,5 +80,8 @@ package() {
# Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/usr/share/locale"
# Shell completion for bash, zsh and fish.
packaging/completions.sh "$rel/pdfs" "$pkgdir/usr/share"
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
@@ -64,6 +64,14 @@ package() {
install -dm755 "$pkgdir/usr/share"
cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale"
# Shell completion for bash, zsh and fish, written by the binary itself.
local share="$pkgdir/usr/share"
install -d "$share/bash-completion/completions" "$share/zsh/site-functions" \
"$share/fish/vendor_completions.d"
"$srcdir/pdfs" completions bash > "$share/bash-completion/completions/pdfs"
"$srcdir/pdfs" completions zsh > "$share/zsh/site-functions/_pdfs"
"$srcdir/pdfs" completions fish > "$share/fish/vendor_completions.d/pdfs.fish"
install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
# pacman brings new releases, so the app never checks for them itself.
@@ -81,6 +81,9 @@ package() {
# Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/usr/share/locale"
# Shell completion for bash, zsh and fish.
packaging/completions.sh target/release/pdfs "$pkgdir/usr/share"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
@@ -76,6 +76,9 @@ package() {
# Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/usr/share/locale"
# Shell completion for bash, zsh and fish.
packaging/completions.sh target/release/pdfs "$pkgdir/usr/share"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Write the shell completion scripts of a built `pdfs` into <datadir>, where
# bash-completion, zsh and fish look for them. Packages pass their install
# tree, e.g. "$pkgdir/usr/share". The scripts call `pdfs` from PATH on every
# Tab, so they are the same for every version and need no rebuild per release.
set -euo pipefail
if [ $# -ne 2 ]; then
echo "usage: $0 <pdfs binary> <datadir>" >&2
exit 2
fi
pdfs="$1"
datadir="$2"
install -d "$datadir/bash-completion/completions" \
"$datadir/zsh/site-functions" \
"$datadir/fish/vendor_completions.d"
"$pdfs" completions bash > "$datadir/bash-completion/completions/pdfs"
"$pdfs" completions zsh > "$datadir/zsh/site-functions/_pdfs"
"$pdfs" completions fish > "$datadir/fish/vendor_completions.d/pdfs.fish"
chmod 644 "$datadir/bash-completion/completions/pdfs" \
"$datadir/zsh/site-functions/_pdfs" \
"$datadir/fish/vendor_completions.d/pdfs.fish"
+4
View File
@@ -40,6 +40,7 @@ rustPlatform.buildRustPackage {
../io.narl.proton-drive-linux.desktop
../io.narl.proton-drive-linux-tray.desktop
../io.narl.proton-drive-linux.svg
../completions.sh
];
};
@@ -107,6 +108,9 @@ rustPlatform.buildRustPackage {
patchShebangs po/build.sh
po/build.sh $out/share/locale
patchShebangs packaging/completions.sh
packaging/completions.sh $out/bin/pdfs $out/share
# Nix brings new releases, so the app never checks for them itself.
install -Dm644 /dev/null $out/share/proton-drive-linux/managed-updates
'';
+4
View File
@@ -114,6 +114,7 @@ install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux.svg \
install -D -m0644 %{src}/packaging/proton-drive.service \
%{buildroot}/usr/lib/systemd/user/proton-drive.service
%{src}/po/build.sh %{buildroot}%{_datadir}/locale
%{src}/packaging/completions.sh "$rel/pdfs" %{buildroot}%{_datadir}
%if 0%{!?git_dir:1}
# dnf brings new releases from COPR, so the app does not offer its own check.
install -D -m0644 /dev/null %{buildroot}%{_datadir}/proton-drive-linux/managed-updates
@@ -149,6 +150,9 @@ fi
%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
/usr/lib/systemd/user/proton-drive.service
%{_datadir}/locale/*/LC_MESSAGES/pdfs.mo
%{_datadir}/bash-completion/completions/pdfs
%{_datadir}/zsh/site-functions/_pdfs
%{_datadir}/fish/vendor_completions.d/pdfs.fish
%if 0%{!?git_dir:1}
%{_datadir}/proton-drive-linux/managed-updates
%endif
+905 -796
View File
File diff suppressed because it is too large. Load diff
+916 -807
View File
File diff suppressed because it is too large. Load diff
+904 -795
View File
File diff suppressed because it is too large. Load diff
+905 -796
View File
File diff suppressed because it is too large. Load diff
+903 -796
View File
File diff suppressed because it is too large. Load diff
+905 -796
View File
File diff suppressed because it is too large. Load diff
+886 -788
View File
File diff suppressed because it is too large. Load diff
+919 -804
View File
File diff suppressed because it is too large. Load diff
+904 -795
View File
File diff suppressed because it is too large. Load diff