mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 09:27:04 +01:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2a9a0f64b | ||
|
|
e571f22d5d | ||
|
|
1c50797772 | ||
|
|
0183f17435 | ||
|
|
b49631e18f | ||
|
|
f25c2459a0 | ||
|
|
ce35a83a04 | ||
|
|
762d06765e | ||
|
|
a0ab243be6 | ||
|
|
2930b457d4 | ||
|
|
d9da0e2263 |
No files matched your search
@@ -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
+28
-4
@@ -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",
|
||||
@@ -3540,6 +3562,7 @@ version = "3.7.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"chrono",
|
||||
"dbus-secret-service-keyring-store",
|
||||
"directories",
|
||||
"ignore",
|
||||
@@ -3825,13 +3848,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8fa2a88130883010b32035e208aaa36fb275f6d7d78ed44a1069b1d33d24481"
|
||||
checksum = "14a22e621abbc1c61b4640728a7de969c16b66d5d983b59c638d501fda25a7b9"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"getrandom 0.4.3",
|
||||
"hex",
|
||||
@@ -3852,9 +3876,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af0bf8d50fa829d1a65b09c6855b606578d02734d4089874c971d1063c23c353"
|
||||
checksum = "bd55a5e8b6600f3aa282ebbdf13ff366431ff9aae84d4eb20b0ef06d598919fe"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"async-trait",
|
||||
|
||||
+6
-2
@@ -15,8 +15,9 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.10.1" }
|
||||
proton-drive-rs = { version = "0.10.1" }
|
||||
proton-sdk = { version = "0.11.0" }
|
||||
proton-drive-rs = { version = "0.11.0" }
|
||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
@@ -56,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"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+116
-22
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -3806,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.
|
||||
///
|
||||
|
||||
@@ -12,6 +12,7 @@ description = "Core auth/session/config for the Proton Drive Linux client"
|
||||
[dependencies]
|
||||
proton-sdk.workspace = true
|
||||
proton-drive-rs.workspace = true
|
||||
chrono.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod similar;
|
||||
pub mod syncignore;
|
||||
pub mod syncroot;
|
||||
pub mod takeout;
|
||||
pub mod timestamp;
|
||||
pub mod tray;
|
||||
pub mod update;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Convert the client's stored epoch seconds at the SDK boundary.
|
||||
|
||||
use proton_drive_rs::ModificationTime;
|
||||
|
||||
/// A UTC modification time, or `None` when outside the SDK's supported range.
|
||||
pub fn modification_time(seconds: i64) -> Option<ModificationTime> {
|
||||
chrono::DateTime::from_timestamp(seconds, 0).map(|time| time.fixed_offset())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn epoch_seconds_preserve_the_instant_including_before_1970() {
|
||||
for seconds in [-1, 0, 1_709_208_000, 1_782_482_460] {
|
||||
let time = modification_time(seconds).unwrap();
|
||||
assert_eq!(time.timestamp(), seconds);
|
||||
assert_eq!(time.offset().local_minus_utc(), 0);
|
||||
assert_eq!(time.timestamp_subsec_nanos(), 0);
|
||||
}
|
||||
assert_eq!(
|
||||
modification_time(-1).unwrap().to_rfc3339(),
|
||||
"1969-12-31T23:59:59+00:00"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_seconds_omit_the_timestamp() {
|
||||
assert!(modification_time(i64::MIN).is_none());
|
||||
assert!(modification_time(i64::MAX).is_none());
|
||||
}
|
||||
}
|
||||
@@ -939,6 +939,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
});
|
||||
let metadata = proton_drive_rs::PhotoUploadMetadata {
|
||||
capture_time,
|
||||
last_modification_time: capture_time
|
||||
.and_then(pdfs_core::timestamp::modification_time),
|
||||
..Default::default()
|
||||
};
|
||||
let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name);
|
||||
|
||||
@@ -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,
|
||||
@@ -352,7 +360,13 @@ impl DriveApi for ProtonDriveClient {
|
||||
name: &str,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::create_folder(self, parent_uid, name, last_modification_time).await
|
||||
ProtonDriveClient::create_folder(
|
||||
self,
|
||||
parent_uid,
|
||||
name,
|
||||
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn upload_file(
|
||||
@@ -384,7 +398,7 @@ impl DriveApi for ProtonDriveClient {
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
@@ -409,7 +423,7 @@ impl DriveApi for ProtonDriveClient {
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
@@ -429,7 +443,7 @@ impl DriveApi for ProtonDriveClient {
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -439,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,
|
||||
|
||||
+428
-68
@@ -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);
|
||||
@@ -245,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,
|
||||
@@ -577,21 +589,6 @@ fn warn_ffmpeg_missing() {
|
||||
});
|
||||
}
|
||||
|
||||
/// A thumbnail from one frame of a downloaded video, which ffmpeg reads from a
|
||||
/// staging copy. See [`video_file_thumbnail`].
|
||||
fn video_thumbnail(
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
staging_dir: &Path,
|
||||
ffmpeg: &OsStr,
|
||||
timeout: Duration,
|
||||
) -> ScaleAttempt {
|
||||
let Some(file) = RawTempFile::create_in(staging_dir, bytes, name) else {
|
||||
return ScaleAttempt::Unavailable;
|
||||
};
|
||||
video_file_thumbnail(&file.0, ffmpeg, timeout)
|
||||
}
|
||||
|
||||
/// A thumbnail from one frame of the video at `path`. A second in, past the
|
||||
/// 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
|
||||
@@ -624,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.
|
||||
@@ -1406,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 {
|
||||
@@ -1560,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);
|
||||
}
|
||||
|
||||
@@ -1609,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 {
|
||||
@@ -1661,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);
|
||||
}
|
||||
};
|
||||
@@ -1674,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);
|
||||
@@ -1869,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;
|
||||
@@ -2295,15 +2455,19 @@ mod decode_budget_tests {
|
||||
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
|
||||
@@ -2536,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");
|
||||
@@ -2544,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(),
|
||||
@@ -2561,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(),
|
||||
@@ -2574,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"),
|
||||
@@ -2591,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"),
|
||||
|
||||
@@ -200,6 +200,7 @@ impl Core {
|
||||
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
|
||||
let metadata = PhotoUploadMetadata {
|
||||
capture_time: Some(candidate.wanted),
|
||||
last_modification_time: pdfs_core::timestamp::modification_time(candidate.wanted),
|
||||
..Default::default()
|
||||
};
|
||||
let uploaded = self.rt.block_on(photos.upload_photo_from(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -344,6 +344,9 @@ impl Core {
|
||||
);
|
||||
let metadata = PhotoUploadMetadata {
|
||||
capture_time: photo.capture_time,
|
||||
last_modification_time: photo
|
||||
.capture_time
|
||||
.and_then(pdfs_core::timestamp::modification_time),
|
||||
..Default::default()
|
||||
};
|
||||
// A thumbnail is made from a file, so a photo that can have one
|
||||
@@ -365,10 +368,13 @@ impl Core {
|
||||
let thumbnails = self.upload_thumbnails(path, &photo.name);
|
||||
// No sidecar and no date in the name: the photo's own
|
||||
// EXIF, before Drive files it under today.
|
||||
let capture_time = metadata
|
||||
.capture_time
|
||||
.or_else(|| pdfs_core::exiftime::capture_time_of(path));
|
||||
let metadata = PhotoUploadMetadata {
|
||||
capture_time: metadata
|
||||
.capture_time
|
||||
.or_else(|| pdfs_core::exiftime::capture_time_of(path)),
|
||||
capture_time,
|
||||
last_modification_time: capture_time
|
||||
.and_then(pdfs_core::timestamp::modification_time),
|
||||
..metadata
|
||||
};
|
||||
std::fs::File::open(path)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Album: a photo in front of the edge of another, a stack of them. -->
|
||||
<path fill="#222222" d="M 4,1 H 13.5 A 1.5,1.5 0 0 1 15,2.5 V 12 H 13.5 V 2.5 H 4 Z"/>
|
||||
<path fill="#222222" fill-rule="evenodd" d="M 2.5,4 H 10.5 A 1.5,1.5 0 0 1 12,5.5 V 13.5 A 1.5,1.5 0 0 1 10.5,15 H 2.5 A 1.5,1.5 0 0 1 1,13.5 V 5.5 A 1.5,1.5 0 0 1 2.5,4 Z M 2.5,5.5 V 13.5 H 10.5 V 5.5 Z"/>
|
||||
<path fill="#222222" d="M 3.5,12.5 L 5.5,9.2 L 7.2,11.2 L 8.2,10.2 L 9.5,12.5 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 550 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Photos: a framed landscape with a sun. Stock themes only have
|
||||
image-x-generic, a file type icon, which reads as "an image file"
|
||||
rather than "your photo library". -->
|
||||
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
|
||||
<path fill="#222222" d="M 3.5,11.5 L 6.5,7 L 9,10.2 L 10.5,8.6 L 12.5,11.5 Z"/>
|
||||
<circle fill="#222222" cx="10.5" cy="5.8" r="1.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 673 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Slideshow: a play mark inside a photo frame, so it is not mistaken for
|
||||
playing the video on screen. -->
|
||||
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
|
||||
<path fill="#222222" d="M 6.5,5.2 L 11,8 L 6.5,10.8 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 527 B |
@@ -15,6 +15,9 @@
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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: >k4::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: >k4::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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1064
-217
File diff suppressed because it is too large.
Load diff
@@ -422,9 +422,7 @@ fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
|
||||
|
||||
// As in an album: no filters, no Upload, no view switch — back leads out.
|
||||
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).
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -478,7 +478,7 @@ pub(crate) fn text_column(
|
||||
|
||||
fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::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);
|
||||
}
|
||||
|
||||
@@ -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: >k4::Picture) -> Option<gtk4::Image> {
|
||||
picture
|
||||
.next_sibling()
|
||||
.and_downcast::<gtk4::Image>()
|
||||
.filter(|image| image.has_css_class("photo-video-badge"))
|
||||
}
|
||||
|
||||
/// Marks a play badge to show once its video's frame is painted.
|
||||
const VIDEO_BADGE: &str = "video";
|
||||
|
||||
/// Resize an existing thumbnail surface. Grid rows are recycled, so changing
|
||||
/// 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: >k4::Picture, key: &str, texture: >k4::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() {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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: >k4::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: >k4::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,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -109,6 +109,7 @@ 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 |
|
||||
@@ -383,6 +384,51 @@ 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
|
||||
|
||||
@@ -11,15 +11,68 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Update `proton-sdk` and `proton-drive-rs` to 0.11.0.
|
||||
Convert epoch timestamps at the SDK boundary and explicitly preserve photo modification
|
||||
times now that they are independent of capture times. See [the SDK review](SDK-0.11.0.md)
|
||||
for features and breaking changes.
|
||||
Schema: **45**. Synced folders now distinguish device backups from My files attachments.
|
||||
Existing folders remain device backups; older builds cannot open the upgraded database.
|
||||
|
||||
### Added
|
||||
|
||||
- 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
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# SDK 0.11.0 review
|
||||
|
||||
Reviewed the sibling `../proton-sdk-rs` checkout at `84039a1` against 0.10.1
|
||||
(`48dd410`), including its changelog and source diff.
|
||||
|
||||
## Features
|
||||
|
||||
- Folder creation, streaming uploads, draft replacement, and revision uploads
|
||||
preserve the supplied timestamp's UTC offset and fractional seconds.
|
||||
- `ModificationTime` is exported as an alias for
|
||||
`chrono::DateTime<chrono::FixedOffset>`.
|
||||
- Photo modification time can be set independently of capture time. Downloaded
|
||||
claimed modification timestamps remain verbatim strings.
|
||||
- Neither SDK crate defines Cargo feature flags. No feature opt-in is needed.
|
||||
The Drive crate now depends directly on `chrono` 0.4; the SDK already used it.
|
||||
The minimum Rust version remains 1.96, matching this workspace.
|
||||
|
||||
## Breaking changes and adaptation
|
||||
|
||||
The modification-time argument changes from `Option<i64>` to
|
||||
`Option<ModificationTime>` on `create_folder`, `upload_file_from`,
|
||||
`upload_file_replacing_draft_from`, `upload_new_revision`, and
|
||||
`upload_new_revision_from`. `None` remains valid.
|
||||
|
||||
The real `DriveApi` adapter converts the client's stored epoch seconds to UTC
|
||||
through `pdfs_core::timestamp::modification_time`. Internal storage, the trait,
|
||||
and the simulated Drive continue using epoch seconds. Out-of-range timestamps
|
||||
are omitted rather than panicking. Existing upload paths still supply whole
|
||||
seconds; this bump alone does not add subsecond storage or offset tracking.
|
||||
|
||||
`PhotoUploadMetadata` gains `last_modification_time`, and the SDK no longer
|
||||
copies `capture_time` into it. Our literals already use `Default`, so this would
|
||||
compile while silently changing uploaded metadata. Control uploads, Takeout
|
||||
imports (including the EXIF fallback), and redating now set both explicitly to
|
||||
preserve the previous behavior when a capture time is known. Without a known
|
||||
capture time, modification time stays omitted as before.
|
||||
|
||||
No other public API changes were found in the reviewed diff. No database or
|
||||
recovery migration is required by this SDK bump.
|
||||
|
||||
## Dependencies
|
||||
|
||||
The workspace requires the published 0.11.0 versions of both crates from
|
||||
crates.io. No local patches or sibling checkout are needed to build it.
|
||||
|
||||
## Verification
|
||||
|
||||
The published crates' source files match the reviewed sibling checkout at
|
||||
`84039a1`. On the final branch based on `main`, these checks passed:
|
||||
|
||||
```sh
|
||||
cargo test --workspace --locked
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
```
|
||||
|
||||
The test suite reported 1,135 passed and 67 ignored; ignored tests were not run.
|
||||
Tests ran outside the sandbox because control-socket tests require socket
|
||||
permissions. No live account or mounted-filesystem acceptance testing was
|
||||
performed.
|
||||
@@ -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"
|
||||
|
||||
|
||||
Executable
+23
@@ -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"
|
||||
@@ -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
|
||||
'';
|
||||
|
||||
@@ -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
|
||||
|
||||
+916
-807
File diff suppressed because it is too large.
Load diff
+886
-788
File diff suppressed because it is too large.
Load diff
+904
-795
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@ SECTIONS = [
|
||||
]
|
||||
|
||||
# Too long or too internal for the wiki; links to them go to GitHub.
|
||||
EXCLUDED = {"BUGS.md", "CHANGELOG.md"}
|
||||
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "SDK-0.11.0.md"}
|
||||
|
||||
# Files outside docs/ whose links are checked but not published.
|
||||
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]
|
||||
|
||||
Reference in new issue
Block a user