mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 17:32:00 +01:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
602f1c9577 | ||
|
|
9132c7ebd5 | ||
|
|
94304ccc1a | ||
|
|
74cd6fd2a9 | ||
|
|
4b669168ee | ||
|
|
ba5b6a8bf2 | ||
|
|
3295eda1f0 | ||
|
|
41212efdf3 | ||
|
|
5f0b15fe70 | ||
|
|
033c8d3d71 | ||
|
|
5c184013f1 | ||
|
|
6be8a8f9bc | ||
|
|
57acb0b849 | ||
|
|
68ff8c9e80 | ||
|
|
4552093c53 | ||
|
|
f2a9a0f64b | ||
|
|
e571f22d5d | ||
|
|
1c50797772 | ||
|
|
0183f17435 | ||
|
|
14a663cee9 | ||
|
|
3f45e89391 | ||
|
|
b49631e18f | ||
|
|
ad648ab5cc | ||
|
|
f25c2459a0 | ||
|
|
ce35a83a04 | ||
|
|
762d06765e | ||
|
|
2b95708260 | ||
|
|
c78bb529bc | ||
|
|
8d824af656 | ||
|
|
a0ab243be6 | ||
|
|
c77c548d7e | ||
|
|
2930b457d4 | ||
|
|
d9da0e2263 | ||
|
|
d4a14a17a8 | ||
|
|
5ca0272f15 | ||
|
|
800705ccb0 |
No files matched your search
@@ -160,6 +160,7 @@ jobs:
|
|||||||
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
|
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
|
||||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||||
po/build.sh deb/usr/share/locale
|
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
|
# Stops the running daemon before removal, so the mount stops mirroring deletes
|
||||||
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
|
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
|
||||||
|
|||||||
Generated
+32
-8
@@ -754,6 +754,18 @@ dependencies = [
|
|||||||
"strsim",
|
"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]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -2551,6 +2563,15 @@ version = "2.12.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
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]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -3520,10 +3541,11 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
"clap_complete",
|
||||||
"pdfs-core",
|
"pdfs-core",
|
||||||
"pdfs-fuse",
|
"pdfs-fuse",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
@@ -3536,10 +3558,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
|
"chrono",
|
||||||
"dbus-secret-service-keyring-store",
|
"dbus-secret-service-keyring-store",
|
||||||
"directories",
|
"directories",
|
||||||
"ignore",
|
"ignore",
|
||||||
@@ -3563,7 +3586,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3585,7 +3608,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3825,13 +3848,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.10.1"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a8fa2a88130883010b32035e208aaa36fb275f6d7d78ed44a1069b1d33d24481"
|
checksum = "14a22e621abbc1c61b4640728a7de969c16b66d5d983b59c638d501fda25a7b9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -3852,9 +3876,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.10.1"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "af0bf8d50fa829d1a65b09c6855b606578d02734d4089874c971d1063c23c353"
|
checksum = "bd55a5e8b6600f3aa282ebbdf13ff366431ff9aae84d4eb20b0ef06d598919fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+7
-3
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -15,8 +15,9 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
|||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.10.1" }
|
proton-sdk = { version = "0.11.0" }
|
||||||
proton-drive-rs = { version = "0.10.1" }
|
proton-drive-rs = { version = "0.11.0" }
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
@@ -56,6 +57,9 @@ rusqlite = { version = "0.41", features = ["bundled"] }
|
|||||||
keyring-core = "1"
|
keyring-core = "1"
|
||||||
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
|
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
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"
|
rpassword = "7"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
futures = "0.3"
|
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 |
|
| [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 |
|
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
|
||||||
| [FAQ](docs/FAQ.md) | Short answers to common questions |
|
| [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 |
|
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
||||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
| [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 |
|
| [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.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
clap_complete.workspace = true
|
||||||
rpassword.workspace = true
|
rpassword.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
zeroize.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
-22
@@ -8,7 +8,8 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
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::auth;
|
||||||
use pdfs_core::cache::ContentCache;
|
use pdfs_core::cache::ContentCache;
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
@@ -20,6 +21,7 @@ use pdfs_core::control::{
|
|||||||
use pdfs_core::db::Db;
|
use pdfs_core::db::Db;
|
||||||
use pdfs_core::service;
|
use pdfs_core::service;
|
||||||
|
|
||||||
|
mod complete;
|
||||||
mod journal;
|
mod journal;
|
||||||
|
|
||||||
/// `println!` for this binary, shadowing std's, which panics when stdout is
|
/// `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 Proton Drive at the given (or default) path. Blocks until unmounted.
|
||||||
Mount {
|
Mount {
|
||||||
/// Mountpoint; defaults to ~/ProtonDrive.
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||||
|
#[arg(value_hint = ValueHint::DirPath)]
|
||||||
mountpoint: Option<PathBuf>,
|
mountpoint: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Run the auto-mount daemon: wait for login, mount, and keep it mounted
|
/// 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.
|
/// don't invoke it by hand.
|
||||||
Daemon {
|
Daemon {
|
||||||
/// Mountpoint; defaults to ~/ProtonDrive.
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||||
|
#[arg(value_hint = ValueHint::DirPath)]
|
||||||
mountpoint: Option<PathBuf>,
|
mountpoint: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Keep a file or folder on this device (download + cache it). A folder's
|
/// Keep a file or folder on this device (download + cache it). A folder's
|
||||||
@@ -179,11 +183,13 @@ enum Command {
|
|||||||
/// Needs a running mount.
|
/// Needs a running mount.
|
||||||
Pin {
|
Pin {
|
||||||
/// File or folder path, inside the mountpoint or relative to it.
|
/// File or folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Stop keeping a file or folder on this device, evicting its cached content.
|
/// Stop keeping a file or folder on this device, evicting its cached content.
|
||||||
Unpin {
|
Unpin {
|
||||||
/// File or folder path, inside the mountpoint or relative to it.
|
/// File or folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// List files and folders pinned to this device.
|
/// 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).
|
/// List a directory via the running daemon (in-app browser backend).
|
||||||
Ls {
|
Ls {
|
||||||
/// Directory path, inside the mountpoint or relative to it. Root if omitted.
|
/// Directory path, inside the mountpoint or relative to it. Root if omitted.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
|
||||||
path: Option<PathBuf>,
|
path: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// List the photos timeline (newest first) via the running daemon.
|
/// 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.
|
/// so an interrupted import can simply be run again.
|
||||||
ImportGooglePhotos {
|
ImportGooglePhotos {
|
||||||
/// The export's `.zip` files (e.g. `takeout-*.zip`).
|
/// The export's `.zip` files (e.g. `takeout-*.zip`).
|
||||||
#[arg(required = true)]
|
#[arg(required = true, value_hint = ValueHint::FilePath)]
|
||||||
archives: Vec<PathBuf>,
|
archives: Vec<PathBuf>,
|
||||||
/// Report what would be imported without uploading anything.
|
/// Report what would be imported without uploading anything.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -277,6 +284,7 @@ enum Command {
|
|||||||
/// List the photos in one album (newest capture first).
|
/// List the photos in one album (newest capture first).
|
||||||
Album {
|
Album {
|
||||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Max photos to list.
|
/// Max photos to list.
|
||||||
#[arg(long, default_value_t = 50)]
|
#[arg(long, default_value_t = 50)]
|
||||||
@@ -293,6 +301,7 @@ enum Command {
|
|||||||
/// Rename a photo album.
|
/// Rename a photo album.
|
||||||
RenameAlbum {
|
RenameAlbum {
|
||||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// New name.
|
/// New name.
|
||||||
name: String,
|
name: String,
|
||||||
@@ -300,6 +309,7 @@ enum Command {
|
|||||||
/// Delete a photo album. Its photos stay in the timeline.
|
/// Delete a photo album. Its photos stay in the timeline.
|
||||||
DeleteAlbum {
|
DeleteAlbum {
|
||||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Also delete photos that exist only in this album (saved from a
|
/// Also delete photos that exist only in this album (saved from a
|
||||||
/// shared album); without it the server refuses to delete such an album.
|
/// shared album); without it the server refuses to delete such an album.
|
||||||
@@ -309,6 +319,7 @@ enum Command {
|
|||||||
/// Add photos to an album.
|
/// Add photos to an album.
|
||||||
AddToAlbum {
|
AddToAlbum {
|
||||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Photo node uids (from `pdfs photos`).
|
/// Photo node uids (from `pdfs photos`).
|
||||||
#[arg(required = true)]
|
#[arg(required = true)]
|
||||||
@@ -317,6 +328,7 @@ enum Command {
|
|||||||
/// Take photos out of an album. They stay in the timeline.
|
/// Take photos out of an album. They stay in the timeline.
|
||||||
RemoveFromAlbum {
|
RemoveFromAlbum {
|
||||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Photo node uids (from `pdfs album <uid>`).
|
/// Photo node uids (from `pdfs album <uid>`).
|
||||||
#[arg(required = true)]
|
#[arg(required = true)]
|
||||||
@@ -335,13 +347,14 @@ enum Command {
|
|||||||
#[arg(long, default_value_t = 50)]
|
#[arg(long, default_value_t = 50)]
|
||||||
limit: usize,
|
limit: usize,
|
||||||
/// Only search below this folder (under the mountpoint).
|
/// 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>,
|
scope: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Rename a file or folder via the running daemon.
|
/// Rename a file or folder via the running daemon.
|
||||||
Rename {
|
Rename {
|
||||||
/// File/folder path, inside My files or an on-demand synced folder, or
|
/// File/folder path, inside My files or an on-demand synced folder, or
|
||||||
/// relative to the mountpoint.
|
/// relative to the mountpoint.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// New name (a single path component).
|
/// New name (a single path component).
|
||||||
new_name: String,
|
new_name: String,
|
||||||
@@ -355,7 +368,7 @@ enum Command {
|
|||||||
Move {
|
Move {
|
||||||
/// Files/folders to move, then the destination folder: each inside a
|
/// Files/folders to move, then the destination folder: each inside a
|
||||||
/// location, or relative to the mountpoint.
|
/// 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>,
|
paths: Vec<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Copy files or folders into another folder in My files via the running
|
/// Copy files or folders into another folder in My files via the running
|
||||||
@@ -367,7 +380,7 @@ enum Command {
|
|||||||
Copy {
|
Copy {
|
||||||
/// Files/folders to copy, then the destination folder: inside My files,
|
/// Files/folders to copy, then the destination folder: inside My files,
|
||||||
/// or relative to the mountpoint.
|
/// 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>,
|
paths: Vec<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Show how much a folder holds on Proton Drive.
|
/// 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.
|
/// encrypted bytes on storage and includes items in the trash.
|
||||||
Du {
|
Du {
|
||||||
/// Folder path, inside My files or relative to the mountpoint.
|
/// Folder path, inside My files or relative to the mountpoint.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Trash a file or folder via the running daemon.
|
/// Trash a file or folder via the running daemon.
|
||||||
Rm {
|
Rm {
|
||||||
/// File/folder path, inside My files or an on-demand synced folder, or
|
/// File/folder path, inside My files or an on-demand synced folder, or
|
||||||
/// relative to the mountpoint.
|
/// relative to the mountpoint.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Create a folder via the running daemon.
|
/// Create a folder via the running daemon.
|
||||||
Mkdir {
|
Mkdir {
|
||||||
/// Parent folder path, inside My files or an on-demand synced folder,
|
/// Parent folder path, inside My files or an on-demand synced folder,
|
||||||
/// or relative to the mountpoint.
|
/// or relative to the mountpoint.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
|
||||||
parent: PathBuf,
|
parent: PathBuf,
|
||||||
/// New folder name (a single path component).
|
/// New folder name (a single path component).
|
||||||
name: String,
|
name: String,
|
||||||
@@ -397,10 +413,10 @@ enum Command {
|
|||||||
/// background; watch progress with `pdfs transfers`.
|
/// background; watch progress with `pdfs transfers`.
|
||||||
Upload {
|
Upload {
|
||||||
/// Local files and/or folders to upload.
|
/// Local files and/or folders to upload.
|
||||||
#[arg(required = true)]
|
#[arg(required = true, value_hint = ValueHint::AnyPath)]
|
||||||
sources: Vec<PathBuf>,
|
sources: Vec<PathBuf>,
|
||||||
/// Destination folder, inside the mountpoint or relative to it.
|
/// 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,
|
parent: PathBuf,
|
||||||
},
|
},
|
||||||
/// Show the daemon's in-flight transfers (active uploads/downloads).
|
/// 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 trashed items, by uid, to the folders they were trashed from.
|
||||||
Restore {
|
Restore {
|
||||||
/// Node uids (`volume~link`), as printed by `pdfs trash`.
|
/// Node uids (`volume~link`), as printed by `pdfs trash`.
|
||||||
#[arg(required = true)]
|
#[arg(required = true, add = ArgValueCandidates::new(complete::trash_uid))]
|
||||||
uids: Vec<String>,
|
uids: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Permanently delete trashed items by uid. This cannot be undone.
|
/// Permanently delete trashed items by uid. This cannot be undone.
|
||||||
DeleteForever {
|
DeleteForever {
|
||||||
/// Node uids (`volume~link`), as printed by `pdfs trash`.
|
/// Node uids (`volume~link`), as printed by `pdfs trash`.
|
||||||
#[arg(required = true)]
|
#[arg(required = true, add = ArgValueCandidates::new(complete::trash_uid))]
|
||||||
uids: Vec<String>,
|
uids: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Permanently delete everything in the trash. This cannot be undone.
|
/// Permanently delete everything in the trash. This cannot be undone.
|
||||||
@@ -433,6 +449,7 @@ enum Command {
|
|||||||
Refresh {
|
Refresh {
|
||||||
/// What to refresh: a folder path (inside the mountpoint or relative to
|
/// What to refresh: a folder path (inside the mountpoint or relative to
|
||||||
/// it), `trash`, or `photos`. The mount root if omitted.
|
/// it), `trash`, or `photos`. The mount root if omitted.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::refresh_target))]
|
||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
/// With `photos`: read every photo's metadata again instead of only what
|
/// With `photos`: read every photo's metadata again instead of only what
|
||||||
/// changed. Minutes on a large library; for a gallery that disagrees with
|
/// 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 a file or folder with Proton and/or external email addresses.
|
||||||
Share {
|
Share {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// One or more invitee emails (Proton or external, auto-detected).
|
/// One or more invitee emails (Proton or external, auto-detected).
|
||||||
#[arg(required = true)]
|
#[arg(required = true)]
|
||||||
emails: Vec<String>,
|
emails: Vec<String>,
|
||||||
/// Role to grant: viewer, editor or admin.
|
/// 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,
|
role: String,
|
||||||
/// Optional message included in the invitation email.
|
/// Optional message included in the invitation email.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -473,17 +491,21 @@ enum Command {
|
|||||||
/// List a node's members, pending invitations and public link.
|
/// List a node's members, pending invitations and public link.
|
||||||
Members {
|
Members {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Change the role of a member or pending invitation on a node's share.
|
/// Change the role of a member or pending invitation on a node's share.
|
||||||
ShareRole {
|
ShareRole {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Entry id (membership or invitation id), from `pdfs members`.
|
/// Entry id (membership or invitation id), from `pdfs members`.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::share_entry_id))]
|
||||||
id: String,
|
id: String,
|
||||||
/// Entry kind: member, proton or external.
|
/// Entry kind: member, proton or external.
|
||||||
kind: ShareKindArg,
|
kind: ShareKindArg,
|
||||||
/// New role: viewer, editor or admin.
|
/// New role: viewer, editor or admin.
|
||||||
|
#[arg(value_parser = ["viewer", "editor", "admin"], ignore_case = true)]
|
||||||
role: String,
|
role: String,
|
||||||
},
|
},
|
||||||
/// Stop sharing a node, or remove one member or pending invitation.
|
/// 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.
|
/// member. With an id and kind (from `pdfs members`), removes that entry.
|
||||||
Unshare {
|
Unshare {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Entry id (membership or invitation id), from `pdfs members`.
|
/// 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>,
|
id: Option<String>,
|
||||||
/// Entry kind: member, proton or external.
|
/// Entry kind: member, proton or external.
|
||||||
kind: Option<ShareKindArg>,
|
kind: Option<ShareKindArg>,
|
||||||
@@ -515,18 +538,22 @@ enum Command {
|
|||||||
SharedWithMe {
|
SharedWithMe {
|
||||||
/// Folder uid to list into, in `volume~link` form (from a previous run).
|
/// Folder uid to list into, in `volume~link` form (from a previous run).
|
||||||
/// Omit for the top level.
|
/// Omit for the top level.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
|
||||||
uid: Option<String>,
|
uid: Option<String>,
|
||||||
},
|
},
|
||||||
/// Download a file shared with me by its uid (from `pdfs shared-with-me`).
|
/// Download a file shared with me by its uid (from `pdfs shared-with-me`).
|
||||||
SharedGet {
|
SharedGet {
|
||||||
/// Node uid in `volume~link` form.
|
/// Node uid in `volume~link` form.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Where to write it. Omit to leave it in the cache and print the path.
|
/// Where to write it. Omit to leave it in the cache and print the path.
|
||||||
|
#[arg(value_hint = ValueHint::AnyPath)]
|
||||||
dest: Option<PathBuf>,
|
dest: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Leave a shared node by its uid (from `pdfs shared-with-me`).
|
/// Leave a shared node by its uid (from `pdfs shared-with-me`).
|
||||||
Leave {
|
Leave {
|
||||||
/// Node uid in `volume~link` form.
|
/// Node uid in `volume~link` form.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
},
|
},
|
||||||
/// Manage invitations addressed to me.
|
/// Manage invitations addressed to me.
|
||||||
@@ -557,6 +584,17 @@ enum Command {
|
|||||||
/// Runs without a daemon on purpose: the state worth diagnosing is usually
|
/// Runs without a daemon on purpose: the state worth diagnosing is usually
|
||||||
/// the state where the daemon will not start.
|
/// the state where the daemon will not start.
|
||||||
Diagnose,
|
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)]
|
#[derive(Subcommand)]
|
||||||
@@ -604,6 +642,7 @@ enum VersionCmd {
|
|||||||
/// List a file's versions, newest first.
|
/// List a file's versions, newest first.
|
||||||
List {
|
List {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Make an earlier version the file's current content.
|
/// Make an earlier version the file's current content.
|
||||||
@@ -612,25 +651,32 @@ enum VersionCmd {
|
|||||||
/// moment to show up in the mount.
|
/// moment to show up in the mount.
|
||||||
Restore {
|
Restore {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Version id, from `pdfs versions list`.
|
/// Version id, from `pdfs versions list`.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::version_id))]
|
||||||
id: String,
|
id: String,
|
||||||
},
|
},
|
||||||
/// Write an earlier version to a local file, leaving the file untouched.
|
/// Write an earlier version to a local file, leaving the file untouched.
|
||||||
Save {
|
Save {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Version id, from `pdfs versions list`.
|
/// Version id, from `pdfs versions list`.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::version_id))]
|
||||||
id: String,
|
id: String,
|
||||||
/// Where to write it. Must not already exist.
|
/// Where to write it. Must not already exist.
|
||||||
|
#[arg(value_hint = ValueHint::AnyPath)]
|
||||||
dest: PathBuf,
|
dest: PathBuf,
|
||||||
},
|
},
|
||||||
/// Permanently delete an earlier version. The content is unrecoverable, and
|
/// Permanently delete an earlier version. The content is unrecoverable, and
|
||||||
/// the version that is currently active cannot be deleted.
|
/// the version that is currently active cannot be deleted.
|
||||||
Rm {
|
Rm {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Version id, from `pdfs versions list`.
|
/// Version id, from `pdfs versions list`.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::version_id))]
|
||||||
id: String,
|
id: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -640,13 +686,21 @@ enum DeviceCmd {
|
|||||||
/// List registered devices.
|
/// List registered devices.
|
||||||
List,
|
List,
|
||||||
/// Rename a device by uid.
|
/// 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.
|
/// 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
|
/// Adopt an existing device as this machine's, so a hostname change or a
|
||||||
/// reinstall re-attaches to it instead of registering a duplicate.
|
/// reinstall re-attaches to it instead of registering a duplicate.
|
||||||
Adopt {
|
Adopt {
|
||||||
/// Device uid (from `devices list`). Omit with `--clear`.
|
/// Device uid (from `devices list`). Omit with `--clear`.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
|
||||||
uid: Option<String>,
|
uid: Option<String>,
|
||||||
/// Drop the adoption and go back to matching by hostname.
|
/// Drop the adoption and go back to matching by hostname.
|
||||||
#[arg(long, conflicts_with = "uid")]
|
#[arg(long, conflicts_with = "uid")]
|
||||||
@@ -657,6 +711,7 @@ enum DeviceCmd {
|
|||||||
/// here. Both computers keep them in step.
|
/// here. Both computers keep them in step.
|
||||||
Join {
|
Join {
|
||||||
/// Device uid (from `devices list`).
|
/// Device uid (from `devices list`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Skip the confirmation and accept every proposed local path.
|
/// Skip the confirmation and accept every proposed local path.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -666,6 +721,7 @@ enum DeviceCmd {
|
|||||||
/// them here. The other computer stays registered, without those folders.
|
/// them here. The other computer stays registered, without those folders.
|
||||||
Migrate {
|
Migrate {
|
||||||
/// Device uid (from `devices list`).
|
/// Device uid (from `devices list`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Skip the confirmation and accept every proposed local path.
|
/// Skip the confirmation and accept every proposed local path.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -678,6 +734,7 @@ enum ConflictCmd {
|
|||||||
/// Decide which side of a conflict to keep. Whatever is removed goes to Trash.
|
/// Decide which side of a conflict to keep. Whatever is removed goes to Trash.
|
||||||
Resolve {
|
Resolve {
|
||||||
/// The conflict copy, absolute or relative to the mountpoint.
|
/// The conflict copy, absolute or relative to the mountpoint.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::conflict_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// `original` trashes the copy; `copy` trashes the original and gives the
|
/// `original` trashes the copy; `copy` trashes the original and gives the
|
||||||
/// copy its name; `both` renames the copy to `--name`.
|
/// copy its name; `both` renames the copy to `--name`.
|
||||||
@@ -694,13 +751,25 @@ enum SyncCmd {
|
|||||||
/// Add a local folder to this machine's device and upload its contents.
|
/// Add a local folder to this machine's device and upload its contents.
|
||||||
Add {
|
Add {
|
||||||
/// Local directory to sync.
|
/// Local directory to sync.
|
||||||
|
#[arg(value_hint = ValueHint::DirPath)]
|
||||||
path: PathBuf,
|
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, add = ArgValueCompleter::new(complete::drive_dir))]
|
||||||
|
to: PathBuf,
|
||||||
|
},
|
||||||
/// List this machine's synced folders.
|
/// List this machine's synced folders.
|
||||||
List,
|
List,
|
||||||
/// Remove a synced folder by id.
|
/// Remove a synced folder by id.
|
||||||
Rm {
|
Rm {
|
||||||
/// Synced-folder id (from `sync list`).
|
/// Synced-folder id (from `sync list`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
|
||||||
id: i64,
|
id: i64,
|
||||||
/// Also delete the folder's copy in Proton Drive.
|
/// Also delete the folder's copy in Proton Drive.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -709,6 +778,7 @@ enum SyncCmd {
|
|||||||
/// Force a sync pass now (all folders, or one by id).
|
/// Force a sync pass now (all folders, or one by id).
|
||||||
Now {
|
Now {
|
||||||
/// Optional synced-folder id; omit to reconcile every folder.
|
/// Optional synced-folder id; omit to reconcile every folder.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
|
||||||
id: Option<i64>,
|
id: Option<i64>,
|
||||||
},
|
},
|
||||||
/// Forget what a synced folder synced before and compare it with Proton
|
/// Forget what a synced folder synced before and compare it with Proton
|
||||||
@@ -716,6 +786,7 @@ enum SyncCmd {
|
|||||||
/// that differs keeps both versions. Nothing is deleted.
|
/// that differs keeps both versions. Nothing is deleted.
|
||||||
Reset {
|
Reset {
|
||||||
/// Synced-folder id (from `sync list`).
|
/// Synced-folder id (from `sync list`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
|
||||||
id: i64,
|
id: i64,
|
||||||
},
|
},
|
||||||
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
|
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
|
||||||
@@ -725,13 +796,13 @@ enum SyncCmd {
|
|||||||
#[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")]
|
#[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")]
|
||||||
duration: Option<u64>,
|
duration: Option<u64>,
|
||||||
/// Pause only this mirror folder (id from `sync list`) until resumed.
|
/// 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>,
|
folder: Option<i64>,
|
||||||
},
|
},
|
||||||
/// Resume syncing after `pdfs sync pause`.
|
/// Resume syncing after `pdfs sync pause`.
|
||||||
Resume {
|
Resume {
|
||||||
/// Resume only this folder, paused with `sync pause --folder`.
|
/// Resume only this folder, paused with `sync pause --folder`.
|
||||||
#[arg(long)]
|
#[arg(long, add = ArgValueCandidates::new(complete::sync_folder_id))]
|
||||||
folder: Option<i64>,
|
folder: Option<i64>,
|
||||||
},
|
},
|
||||||
/// Cap upload and download speed, or show the caps with no options.
|
/// Cap upload and download speed, or show the caps with no options.
|
||||||
@@ -750,8 +821,10 @@ enum SyncCmd {
|
|||||||
/// Save the content of a queued upload (by id from `sync queue`) to a file.
|
/// Save the content of a queued upload (by id from `sync queue`) to a file.
|
||||||
Export {
|
Export {
|
||||||
/// Queue id.
|
/// Queue id.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
|
||||||
id: i64,
|
id: i64,
|
||||||
/// Where to save it: a new file, or an existing folder to save it in.
|
/// Where to save it: a new file, or an existing folder to save it in.
|
||||||
|
#[arg(value_hint = ValueHint::AnyPath)]
|
||||||
dest: PathBuf,
|
dest: PathBuf,
|
||||||
},
|
},
|
||||||
/// Drop a queued change (by id from `sync queue`) and undo it here: a new file
|
/// Drop a queued change (by id from `sync queue`) and undo it here: a new file
|
||||||
@@ -759,6 +832,7 @@ enum SyncCmd {
|
|||||||
/// content is lost unless saved with `sync export` first.
|
/// content is lost unless saved with `sync export` first.
|
||||||
Discard {
|
Discard {
|
||||||
/// Queue id.
|
/// Queue id.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
|
||||||
id: i64,
|
id: i64,
|
||||||
/// Do not ask first.
|
/// Do not ask first.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -767,11 +841,13 @@ enum SyncCmd {
|
|||||||
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
|
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
|
||||||
Retry {
|
Retry {
|
||||||
/// Queue id; omit to retry every failed operation.
|
/// Queue id; omit to retry every failed operation.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
|
||||||
id: Option<i64>,
|
id: Option<i64>,
|
||||||
},
|
},
|
||||||
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
|
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
|
||||||
Mode {
|
Mode {
|
||||||
/// Synced-folder id (from `sync list`).
|
/// Synced-folder id (from `sync list`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
|
||||||
id: i64,
|
id: i64,
|
||||||
/// `mirror` (full local copy) or `ondemand` (FUSE mount, reclaims disk).
|
/// `mirror` (full local copy) or `ondemand` (FUSE mount, reclaims disk).
|
||||||
#[arg(value_parser = ["mirror", "ondemand"])]
|
#[arg(value_parser = ["mirror", "ondemand"])]
|
||||||
@@ -785,7 +861,7 @@ enum SyncCmd {
|
|||||||
yes: bool,
|
yes: bool,
|
||||||
/// Restore another computer's folders instead, by device uid (from
|
/// Restore another computer's folders instead, by device uid (from
|
||||||
/// `pdfs devices list`).
|
/// `pdfs devices list`).
|
||||||
#[arg(long)]
|
#[arg(long, add = ArgValueCandidates::new(complete::device_uid))]
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -795,9 +871,10 @@ enum PublicLinkCmd {
|
|||||||
/// Create a public link on a node.
|
/// Create a public link on a node.
|
||||||
Create {
|
Create {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Role: viewer or editor.
|
/// Role: viewer or editor.
|
||||||
#[arg(long, default_value = "viewer")]
|
#[arg(long, default_value = "viewer", value_parser = ["viewer", "editor"], ignore_case = true)]
|
||||||
role: String,
|
role: String,
|
||||||
/// Optional custom password protecting the link.
|
/// Optional custom password protecting the link.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -810,11 +887,13 @@ enum PublicLinkCmd {
|
|||||||
/// given is kept.
|
/// given is kept.
|
||||||
Update {
|
Update {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Public-link id, from `pdfs members`.
|
/// Public-link id, from `pdfs members`.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::public_link_id))]
|
||||||
id: String,
|
id: String,
|
||||||
/// New role: viewer or editor.
|
/// New role: viewer or editor.
|
||||||
#[arg(long)]
|
#[arg(long, value_parser = ["viewer", "editor"], ignore_case = true)]
|
||||||
role: Option<String>,
|
role: Option<String>,
|
||||||
/// New custom password protecting the link.
|
/// New custom password protecting the link.
|
||||||
#[arg(long, conflicts_with = "no_password")]
|
#[arg(long, conflicts_with = "no_password")]
|
||||||
@@ -832,8 +911,10 @@ enum PublicLinkCmd {
|
|||||||
/// Remove a node's public link.
|
/// Remove a node's public link.
|
||||||
Remove {
|
Remove {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Public-link id, from `pdfs members`.
|
/// Public-link id, from `pdfs members`.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::public_link_id))]
|
||||||
id: String,
|
id: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -843,9 +924,15 @@ enum InvitationCmd {
|
|||||||
/// List invitations addressed to me.
|
/// List invitations addressed to me.
|
||||||
List,
|
List,
|
||||||
/// Accept an invitation by id.
|
/// Accept an invitation by id.
|
||||||
Accept { id: String },
|
Accept {
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::invitation_id))]
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
/// Reject an invitation by id.
|
/// Reject an invitation by id.
|
||||||
Reject { id: String },
|
Reject {
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::invitation_id))]
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -861,7 +948,10 @@ enum BookmarkCmd {
|
|||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
},
|
},
|
||||||
/// Remove a saved bookmark by its token.
|
/// 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.
|
/// Render a daemon failure for a terminal.
|
||||||
@@ -881,6 +971,9 @@ fn cli_error(kind: ErrorKind, message: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
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()
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
// `pgp` logs a benign WARN when it re-serializes a Proton secret-key
|
// `pgp` logs a benign WARN when it re-serializes a Proton secret-key
|
||||||
// packet with a longer (but equivalent) length encoding than the
|
// packet with a longer (but equivalent) length encoding than the
|
||||||
@@ -1021,6 +1114,7 @@ fn main() -> Result<()> {
|
|||||||
Command::Activity { limit } => cmd_activity(limit),
|
Command::Activity { limit } => cmd_activity(limit),
|
||||||
Command::Quota => cmd_quota(),
|
Command::Quota => cmd_quota(),
|
||||||
Command::Cache { action } => cmd_cache(action),
|
Command::Cache { action } => cmd_cache(action),
|
||||||
|
Command::Completions { shell } => cmd_completions(shell),
|
||||||
Command::Diagnose => cmd_diagnose(),
|
Command::Diagnose => cmd_diagnose(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1116,6 +1210,22 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
|||||||
.to_string();
|
.to_string();
|
||||||
ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)?
|
ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)?
|
||||||
}
|
}
|
||||||
|
SyncCmd::Attach { path, to } => {
|
||||||
|
let abs = std::fs::canonicalize(&path)
|
||||||
|
.with_context(|| format!("resolve {}", path.display()))?;
|
||||||
|
let local_path = abs
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| anyhow!("path is not valid UTF-8"))?
|
||||||
|
.to_owned();
|
||||||
|
let remote_path = to
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| anyhow!("remote path is not valid UTF-8"))?
|
||||||
|
.to_owned();
|
||||||
|
ok_or_bail(control_request(CtlRequest::AttachSyncFolder {
|
||||||
|
local_path,
|
||||||
|
remote_path,
|
||||||
|
})?)?
|
||||||
|
}
|
||||||
SyncCmd::List => {
|
SyncCmd::List => {
|
||||||
let response = control_request(CtlRequest::ListSyncFolders)?;
|
let response = control_request(CtlRequest::ListSyncFolders)?;
|
||||||
if emit_json(&response)? {
|
if emit_json(&response)? {
|
||||||
@@ -2294,6 +2404,9 @@ fn cmd_locations() -> Result<()> {
|
|||||||
MountKind::Device { sync_folder_id } => {
|
MountKind::Device { sync_folder_id } => {
|
||||||
format!("Device folder {sync_folder_id}")
|
format!("Device folder {sync_folder_id}")
|
||||||
}
|
}
|
||||||
|
MountKind::Attached { sync_folder_id } => {
|
||||||
|
format!("My files mirror {sync_folder_id}")
|
||||||
|
}
|
||||||
MountKind::Shared { .. } => "Shared folder".to_string(),
|
MountKind::Shared { .. } => "Shared folder".to_string(),
|
||||||
};
|
};
|
||||||
let mode = match location.pending_mode {
|
let mode = match location.pending_mode {
|
||||||
@@ -2492,7 +2605,10 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
// An explicit `--mountpoint` wins; otherwise honor the Settings-page choice
|
// An explicit `--mountpoint` wins; otherwise honor the Settings-page choice
|
||||||
// (config), falling back to the default location.
|
// (config), falling back to the default location.
|
||||||
let mountpoint = mountpoint.unwrap_or_else(|| dirs.resolved_mountpoint(&config));
|
let mountpoint = mountpoint.unwrap_or_else(|| dirs.resolved_mountpoint(&config));
|
||||||
std::fs::create_dir_all(&mountpoint)
|
// Hydrating a large account and remounting its folders can outlast the
|
||||||
|
// unit's watchdog before the supervisor sends its first ping (B279).
|
||||||
|
pdfs_fuse::stretch_watchdog_for_startup();
|
||||||
|
pdfs_fuse::prepare_mountpoint(&mountpoint)
|
||||||
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
|
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
|
||||||
|
|
||||||
let username = auth::load().map(|s| s.username).unwrap_or_default();
|
let username = auth::load().map(|s| s.username).unwrap_or_default();
|
||||||
@@ -3775,6 +3891,15 @@ fn writable_probe(dir: &Path) -> std::io::Result<()> {
|
|||||||
result.and(remove)
|
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
|
/// Self-check the installation, reporting each finding and exiting non-zero if
|
||||||
/// any check failed.
|
/// any check failed.
|
||||||
///
|
///
|
||||||
@@ -4268,6 +4393,7 @@ mod diagnose_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_synced_folder_in_error_is_reported() {
|
fn a_synced_folder_in_error_is_reported() {
|
||||||
let folder = |id: i64, state: &str| pdfs_core::control::SyncFolderInfo {
|
let folder = |id: i64, state: &str| pdfs_core::control::SyncFolderInfo {
|
||||||
|
kind: "device".into(),
|
||||||
id,
|
id,
|
||||||
local_path: format!("/home/u/f{id}"),
|
local_path: format!("/home/u/f{id}"),
|
||||||
remote_uid: "vol~link".into(),
|
remote_uid: "vol~link".into(),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ description = "Core auth/session/config for the Proton Drive Linux client"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
proton-sdk.workspace = true
|
proton-sdk.workspace = true
|
||||||
proton-drive-rs.workspace = true
|
proton-drive-rs.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -996,21 +996,15 @@ impl TokenSink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the unsaved rotation, if there is one. A blob that moved on, or
|
/// Write the unsaved rotation, if there is one. A blob that moved on drops
|
||||||
/// is gone with a logout, drops it: there is nothing it may still go into.
|
/// it. A missing blob is retried: some Secret Service providers also report
|
||||||
|
/// locked or temporarily hidden items as absent. Never recreate it here.
|
||||||
fn flush(&self) -> Result<()> {
|
fn flush(&self) -> Result<()> {
|
||||||
let mut state = self.state.lock();
|
let mut state = self.state.lock();
|
||||||
let Some(tokens) = state.unsaved.clone() else {
|
let Some(tokens) = state.unsaved.clone() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let mut stored = match load() {
|
let mut stored = load()?;
|
||||||
Ok(stored) => stored,
|
|
||||||
Err(Error::NotLoggedIn) => {
|
|
||||||
state.settle();
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
};
|
|
||||||
match take_rotation(
|
match take_rotation(
|
||||||
&mut stored,
|
&mut stored,
|
||||||
&self.session_id,
|
&self.session_id,
|
||||||
@@ -1306,6 +1300,47 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// oo7 0.7.0 reports a locked collection's items as absent (upstream #614).
|
||||||
|
/// Treating that as logout loses the only live tokens (B284, issue #78).
|
||||||
|
#[test]
|
||||||
|
fn a_temporarily_missing_session_does_not_discard_a_rotation() {
|
||||||
|
let _turn = mock_keyring();
|
||||||
|
write_stored(&stored_with("r")).unwrap();
|
||||||
|
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||||
|
let entry = keyring_entry().unwrap();
|
||||||
|
entry
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<keyring_core::mock::Cred>()
|
||||||
|
.unwrap()
|
||||||
|
.set_error(keyring_core::Error::NoEntry);
|
||||||
|
|
||||||
|
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
||||||
|
assert!(sink.state.lock().unsaved.is_some());
|
||||||
|
assert_eq!(load().unwrap().refresh_token, "r");
|
||||||
|
|
||||||
|
sink.flush().unwrap();
|
||||||
|
assert_eq!(load().unwrap().refresh_token, "r2");
|
||||||
|
assert!(sink.state.lock().unsaved.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login() {
|
||||||
|
let _turn = mock_keyring();
|
||||||
|
let sink = TokenSink::new("s", "r".into());
|
||||||
|
sink.state.lock().unsaved = Some(tokens("a2", "r2"));
|
||||||
|
|
||||||
|
assert!(matches!(sink.flush(), Err(Error::NotLoggedIn)));
|
||||||
|
assert!(matches!(load(), Err(Error::NotLoggedIn)));
|
||||||
|
assert!(sink.state.lock().unsaved.is_some());
|
||||||
|
|
||||||
|
let mut login = stored_with("fresh");
|
||||||
|
login.session_id = "new".into();
|
||||||
|
write_stored(&login).unwrap();
|
||||||
|
sink.flush().unwrap();
|
||||||
|
assert!(sink.state.lock().unsaved.is_none());
|
||||||
|
assert_eq!(load().unwrap().refresh_token, "fresh");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
|
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
|
||||||
let unsaved = UnsavedRotation {
|
let unsaved = UnsavedRotation {
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ pub struct AppConfig {
|
|||||||
/// Defaulted for configs predating the field.
|
/// Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub files_view: FilesView,
|
pub files_view: FilesView,
|
||||||
|
/// How the desktop app's Photos page lays out its grid. Defaulted for
|
||||||
|
/// configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub photos_view: PhotosView,
|
||||||
/// The desktop app's main window size, remembered between runs. Defaulted
|
/// The desktop app's main window size, remembered between runs. Defaulted
|
||||||
/// for configs predating the field.
|
/// for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -220,6 +224,10 @@ pub struct FilesView {
|
|||||||
pub descending: bool,
|
pub descending: bool,
|
||||||
/// Folders ahead of files, whatever the sort.
|
/// Folders ahead of files, whatever the sort.
|
||||||
pub folders_first: bool,
|
pub folders_first: bool,
|
||||||
|
/// The icon grid's thumbnail edge in logical pixels. `None` means the
|
||||||
|
/// app's default.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon_size: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FilesView {
|
impl Default for FilesView {
|
||||||
@@ -229,10 +237,23 @@ impl Default for FilesView {
|
|||||||
sort: FileSort::Name,
|
sort: FileSort::Name,
|
||||||
descending: false,
|
descending: false,
|
||||||
folders_first: true,
|
folders_first: true,
|
||||||
|
icon_size: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Photos page's grid choices, remembered between runs.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct PhotosView {
|
||||||
|
/// The zoom level: the row height the grid aims for, in logical pixels.
|
||||||
|
/// `None` means the app's default.
|
||||||
|
pub row_height: Option<i32>,
|
||||||
|
/// Crop every photo to a square tile instead of showing it at its own
|
||||||
|
/// shape.
|
||||||
|
pub square: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// The desktop app's main window, as it was when it last closed.
|
/// The desktop app's main window, as it was when it last closed.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -761,6 +782,11 @@ mod tests {
|
|||||||
sort: FileSort::Modified,
|
sort: FileSort::Modified,
|
||||||
descending: true,
|
descending: true,
|
||||||
folders_first: false,
|
folders_first: false,
|
||||||
|
icon_size: Some(192),
|
||||||
|
},
|
||||||
|
photos_view: PhotosView {
|
||||||
|
row_height: Some(240),
|
||||||
|
square: true,
|
||||||
},
|
},
|
||||||
window: WindowState {
|
window: WindowState {
|
||||||
width: 1200,
|
width: 1200,
|
||||||
@@ -792,6 +818,7 @@ mod tests {
|
|||||||
assert_eq!(decoded.upload_limit, Some(500_000));
|
assert_eq!(decoded.upload_limit, Some(500_000));
|
||||||
assert_eq!(decoded.download_limit, None);
|
assert_eq!(decoded.download_limit, None);
|
||||||
assert_eq!(decoded.files_view, config.files_view);
|
assert_eq!(decoded.files_view, config.files_view);
|
||||||
|
assert_eq!(decoded.photos_view, config.photos_view);
|
||||||
assert_eq!(decoded.window, config.window);
|
assert_eq!(decoded.window, config.window);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,6 +862,17 @@ mod tests {
|
|||||||
let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap();
|
let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap();
|
||||||
assert_eq!(partial.sort, FileSort::Size);
|
assert_eq!(partial.sort, FileSort::Size);
|
||||||
assert!(partial.folders_first);
|
assert!(partial.folders_first);
|
||||||
|
assert_eq!(partial.icon_size, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_config_without_a_photos_view_gets_the_default() {
|
||||||
|
let decoded: AppConfig =
|
||||||
|
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
|
||||||
|
assert_eq!(decoded.photos_view, PhotosView::default());
|
||||||
|
let partial: PhotosView = serde_json::from_str(r#"{"square":true}"#).unwrap();
|
||||||
|
assert!(partial.square);
|
||||||
|
assert_eq!(partial.row_height, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -469,6 +469,13 @@ pub enum Request {
|
|||||||
/// Add a local folder to this machine's device, uploading its tree and
|
/// Add a local folder to this machine's device, uploading its tree and
|
||||||
/// registering the device on first use. Replies with [`Response::Ok`].
|
/// registering the device on first use. Replies with [`Response::Ok`].
|
||||||
AddSyncFolder { local_path: String },
|
AddSyncFolder { local_path: String },
|
||||||
|
/// Keep a real local directory in step with an existing folder in My files.
|
||||||
|
/// First sync unions one-sided files and preserves differing versions.
|
||||||
|
/// Replies with [`Response::Ok`]; no device backup is created.
|
||||||
|
AttachSyncFolder {
|
||||||
|
local_path: String,
|
||||||
|
remote_path: String,
|
||||||
|
},
|
||||||
/// List this device's synced folders. Replies with [`Response::SyncFolders`].
|
/// List this device's synced folders. Replies with [`Response::SyncFolders`].
|
||||||
ListSyncFolders,
|
ListSyncFolders,
|
||||||
/// Remove a synced folder by id; `delete_remote` also trashes its cloud copy.
|
/// Remove a synced folder by id; `delete_remote` also trashes its cloud copy.
|
||||||
@@ -985,14 +992,17 @@ impl SyncIssue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
|
/// One device backup or attached My files mirror (in [`Response::SyncFolders`]).
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct SyncFolderInfo {
|
pub struct SyncFolderInfo {
|
||||||
/// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`].
|
/// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`].
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
/// `device` backup or `attached` My files mirror. Older daemons only had backups.
|
||||||
|
#[serde(default = "default_sync_folder_kind")]
|
||||||
|
pub kind: String,
|
||||||
/// Absolute local folder path.
|
/// Absolute local folder path.
|
||||||
pub local_path: String,
|
pub local_path: String,
|
||||||
/// The uid of the folder's remote root under the device root.
|
/// The uid of the remote root under the device root or My files.
|
||||||
pub remote_uid: String,
|
pub remote_uid: String,
|
||||||
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
|
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
|
||||||
pub mode: String,
|
pub mode: String,
|
||||||
@@ -1015,6 +1025,10 @@ pub struct SyncFolderInfo {
|
|||||||
pub paused: bool,
|
pub paused: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_sync_folder_kind() -> String {
|
||||||
|
"device".into()
|
||||||
|
}
|
||||||
|
|
||||||
/// A folder under this machine's device offered for restore (in
|
/// A folder under this machine's device offered for restore (in
|
||||||
/// [`Response::RestorableFolders`]).
|
/// [`Response::RestorableFolders`]).
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
@@ -1778,6 +1792,11 @@ pub struct PhotoItem {
|
|||||||
/// the JPEG it shows is not the whole of what is stored.
|
/// the JPEG it shows is not the whole of what is stored.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub has_raw: bool,
|
pub has_raw: bool,
|
||||||
|
/// GeoNames id of the town the photo was taken in, when its location is
|
||||||
|
/// known; [`crate::places::city`] names it. Older daemons omit it, and
|
||||||
|
/// album entries do not carry it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub place_id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A daemon too old to classify a timeline entry is assumed to have served a
|
/// A daemon too old to classify a timeline entry is assumed to have served a
|
||||||
@@ -1837,6 +1856,34 @@ pub fn is_thumbnail_image_name(name: &str) -> bool {
|
|||||||
|| RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|
|| RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The smallest `.ts` file taken for an MPEG transport stream. `.ts` is
|
||||||
|
/// TypeScript source as often as it is a recording, and the two sit far apart
|
||||||
|
/// in size: a source file is kilobytes, while a second of broadcast video is
|
||||||
|
/// already megabytes. Drive often stores either kind as
|
||||||
|
/// `application/octet-stream`, so the size is the only signal a listing has.
|
||||||
|
pub const TS_VIDEO_MIN_BYTES: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
/// Whether a file named `name`, of `size` bytes when the caller knows it, is a
|
||||||
|
/// video. The extension decides, except for `.ts`, which counts as video only
|
||||||
|
/// from [`TS_VIDEO_MIN_BYTES`] up. A `.ts` of unknown size counts as video, as
|
||||||
|
/// [`PhotoKind::classify`] has it.
|
||||||
|
pub fn is_video_file_name(name: &str, size: Option<u64>) -> bool {
|
||||||
|
if PhotoKind::classify(Some(name), None) != PhotoKind::Video {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let transport_stream = name
|
||||||
|
.rsplit_once('.')
|
||||||
|
.is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("ts"));
|
||||||
|
!transport_stream || size.is_none_or(|size| size >= TS_VIDEO_MIN_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether file listings show a thumbnail for a file named `name`, of `size`
|
||||||
|
/// bytes when known: an image [`is_thumbnail_image_name`] accepts, or a video,
|
||||||
|
/// whose thumbnail is a frame the daemon takes with ffmpeg.
|
||||||
|
pub fn is_thumbnail_media_name(name: &str, size: Option<u64>) -> bool {
|
||||||
|
is_thumbnail_image_name(name) || is_video_file_name(name, size)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether `name` is one of the concrete RAW formats handled by the exiftool
|
/// Whether `name` is one of the concrete RAW formats handled by the exiftool
|
||||||
/// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose
|
/// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose
|
||||||
/// preview availability is not dependable.
|
/// preview availability is not dependable.
|
||||||
@@ -2391,9 +2438,17 @@ impl Request {
|
|||||||
///
|
///
|
||||||
/// Shared by the CLI and GUI so both speak the wire format identically.
|
/// Shared by the CLI and GUI so both speak the wire format identically.
|
||||||
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
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)?;
|
let mut stream = UnixStream::connect(socket)?;
|
||||||
stream.set_read_timeout(Some(req.read_timeout()))?;
|
stream.set_read_timeout(Some(timeout))?;
|
||||||
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
|
stream.set_write_timeout(Some(timeout.min(WRITE_TIMEOUT)))?;
|
||||||
let mut line = serde_json::to_vec(req)?;
|
let mut line = serde_json::to_vec(req)?;
|
||||||
line.push(b'\n');
|
line.push(b'\n');
|
||||||
stream.write_all(&line)?;
|
stream.write_all(&line)?;
|
||||||
@@ -2653,6 +2708,41 @@ mod tests {
|
|||||||
assert_eq!(PhotoKind::classify(Some("x.jpg"), Some("video/mp4")), Photo);
|
assert_eq!(PhotoKind::classify(Some("x.jpg"), Some("video/mp4")), Photo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ts_file_is_a_video_only_at_the_size_of_one() {
|
||||||
|
const GB: u64 = 1024 * 1024 * 1024;
|
||||||
|
assert!(!is_video_file_name("app.ts", Some(3 * 1024)));
|
||||||
|
assert!(!is_video_file_name(
|
||||||
|
"bundle.TS",
|
||||||
|
Some(TS_VIDEO_MIN_BYTES - 1)
|
||||||
|
));
|
||||||
|
assert!(is_video_file_name("recording.ts", Some(9 * GB)));
|
||||||
|
assert!(is_video_file_name("recording.TS", Some(TS_VIDEO_MIN_BYTES)));
|
||||||
|
assert!(is_video_file_name("recording.ts", None));
|
||||||
|
// Only `.ts` is in doubt; every other video name is one at any size.
|
||||||
|
assert!(is_video_file_name("clip.mkv", Some(10)));
|
||||||
|
assert!(is_video_file_name("clip.m2ts", Some(10)));
|
||||||
|
assert!(!is_video_file_name("notes.txt", Some(9 * GB)));
|
||||||
|
assert!(!is_video_file_name("ts", Some(9 * GB)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn listings_thumbnail_images_and_videos() {
|
||||||
|
assert!(is_thumbnail_media_name("photo.JPG", Some(10)));
|
||||||
|
assert!(is_thumbnail_media_name("camera.nef", None));
|
||||||
|
assert!(is_thumbnail_media_name("clip.mp4", Some(10)));
|
||||||
|
assert!(is_thumbnail_media_name(
|
||||||
|
"recording.ts",
|
||||||
|
Some(64 * 1024 * 1024)
|
||||||
|
));
|
||||||
|
assert!(!is_thumbnail_media_name("app.ts", Some(2048)));
|
||||||
|
assert!(!is_thumbnail_media_name(
|
||||||
|
"song.flac",
|
||||||
|
Some(64 * 1024 * 1024)
|
||||||
|
));
|
||||||
|
assert!(!is_thumbnail_media_name("notes.txt", None));
|
||||||
|
}
|
||||||
|
|
||||||
/// Keep every extension accepted by the shared classifier covered. Drive
|
/// Keep every extension accepted by the shared classifier covered. Drive
|
||||||
/// activation uses this classification before it knows whether opening a
|
/// activation uses this classification before it knows whether opening a
|
||||||
/// file should stream through FUSE or materialise a complete cache entry;
|
/// file should stream through FUSE or materialise a complete cache entry;
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ impl Db {
|
|||||||
// made, and it shows exactly what they put in it.
|
// made, and it shows exactly what they put in it.
|
||||||
group_size: 1,
|
group_size: 1,
|
||||||
has_raw: false,
|
has_raw: false,
|
||||||
|
// Album rows carry no location.
|
||||||
|
place_id: None,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
@@ -247,6 +249,8 @@ impl Db {
|
|||||||
// made, and it shows exactly what they put in it.
|
// made, and it shows exactly what they put in it.
|
||||||
group_size: 1,
|
group_size: 1,
|
||||||
has_raw: false,
|
has_raw: false,
|
||||||
|
// Album rows carry no location.
|
||||||
|
place_id: None,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 44;
|
pub(super) const SCHEMA_VERSION: i64 = 45;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -467,6 +467,29 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V44)?;
|
tx.execute_batch(MIGRATION_V44)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 45 {
|
||||||
|
let has_folders: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_folder'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_kind: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('sync_folder') WHERE name = 'kind'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_folders && !has_kind {
|
||||||
|
tx.execute_batch(MIGRATION_V45_FOLDERS)?;
|
||||||
|
let has_mounts: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'mount'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_mounts {
|
||||||
|
tx.execute_batch(MIGRATION_V45_MOUNTS)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -1382,3 +1405,57 @@ CREATE INDEX IF NOT EXISTS pending_op_kind_name ON pending_op(kind, name);
|
|||||||
const MIGRATION_V44: &str = "
|
const MIGRATION_V44: &str = "
|
||||||
ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT;
|
ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// Schema v45: distinguish device backups from My files attachments. Existing
|
||||||
|
/// released rows are device backups. Attachments cannot evict their local tree.
|
||||||
|
const MIGRATION_V45_FOLDERS: &str = "
|
||||||
|
ALTER TABLE sync_folder ADD COLUMN kind TEXT NOT NULL DEFAULT 'device'
|
||||||
|
CHECK (kind IN ('device', 'attached'));
|
||||||
|
CREATE TRIGGER sync_folder_attached_insert
|
||||||
|
BEFORE INSERT ON sync_folder
|
||||||
|
WHEN NEW.kind = 'attached' AND (NEW.mode != 'mirror' OR NEW.pending_mode IS NOT NULL)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'attached folders only support mirror mode'); END;
|
||||||
|
CREATE TRIGGER sync_folder_attached_update
|
||||||
|
BEFORE UPDATE ON sync_folder
|
||||||
|
WHEN NEW.kind = 'attached' AND (NEW.mode != 'mirror' OR NEW.pending_mode IS NOT NULL)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'attached folders only support mirror mode'); END;
|
||||||
|
";
|
||||||
|
|
||||||
|
const MIGRATION_V45_MOUNTS: &str = "
|
||||||
|
DROP TRIGGER mount_sync_folder_insert;
|
||||||
|
DROP INDEX mount_myfiles;
|
||||||
|
DROP INDEX mount_device;
|
||||||
|
DROP INDEX mount_shared;
|
||||||
|
ALTER TABLE mount RENAME TO mount_v44;
|
||||||
|
CREATE TABLE mount (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('myfiles', 'device', 'attached', 'shared')),
|
||||||
|
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
|
||||||
|
share_root_uid TEXT,
|
||||||
|
local_path TEXT NOT NULL DEFAULT '',
|
||||||
|
root_uid TEXT NOT NULL DEFAULT '',
|
||||||
|
root_share_id TEXT NOT NULL DEFAULT '',
|
||||||
|
mode TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
access TEXT NOT NULL DEFAULT 'rw' CHECK (access IN ('rw', 'ro')),
|
||||||
|
CHECK (
|
||||||
|
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
|
||||||
|
OR (kind IN ('device', 'attached') AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
|
||||||
|
OR (kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
INSERT INTO mount SELECT * FROM mount_v44;
|
||||||
|
DROP TABLE mount_v44;
|
||||||
|
CREATE UNIQUE INDEX mount_myfiles ON mount(kind) WHERE kind = 'myfiles';
|
||||||
|
CREATE UNIQUE INDEX mount_device ON mount(sync_folder_id) WHERE kind IN ('device', 'attached');
|
||||||
|
CREATE UNIQUE INDEX mount_shared ON mount(share_root_uid) WHERE kind = 'shared';
|
||||||
|
CREATE TRIGGER mount_sync_folder_insert
|
||||||
|
AFTER INSERT ON sync_folder
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO mount (kind, sync_folder_id) VALUES (NEW.kind, NEW.id);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER mount_sync_folder_kind_update
|
||||||
|
AFTER UPDATE OF kind ON sync_folder
|
||||||
|
BEGIN
|
||||||
|
UPDATE mount SET kind = NEW.kind WHERE sync_folder_id = NEW.id;
|
||||||
|
END;
|
||||||
|
";
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Unified mount/location presentation rows.
|
//! Unified mount/location presentation rows.
|
||||||
//!
|
//!
|
||||||
//! A device row stores only its `sync_folder` identity. Its path, remote root,
|
//! A device or attached row stores only its `sync_folder` identity. Its path, remote root,
|
||||||
//! mode, state, pending mode, and last-sync timestamp are selected from
|
//! mode, state, pending mode, and last-sync timestamp are selected from
|
||||||
//! `sync_folder`, which remains the authoritative sync-engine table.
|
//! `sync_folder`, which remains the authoritative sync-engine table.
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ impl Db {
|
|||||||
)? > 0)
|
)? > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every local location, with device fields joined from `sync_folder`.
|
/// Every local location, with synced-folder fields joined from `sync_folder`.
|
||||||
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
@@ -81,14 +81,14 @@ impl Db {
|
|||||||
m.kind,
|
m.kind,
|
||||||
m.sync_folder_id,
|
m.sync_folder_id,
|
||||||
m.share_root_uid,
|
m.share_root_uid,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END,
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.local_path ELSE m.local_path END,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END,
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.remote_uid ELSE m.root_uid END,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END,
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.remote_share_id ELSE m.root_share_id END,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END,
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.mode ELSE m.mode END,
|
||||||
m.access,
|
m.access,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END,
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.state ELSE 'idle' END,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END,
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.last_sync ELSE 0 END,
|
||||||
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END
|
CASE WHEN m.kind IN ('device', 'attached') THEN sf.pending_mode ELSE NULL END
|
||||||
FROM mount AS m
|
FROM mount AS m
|
||||||
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
|
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
|
||||||
ORDER BY CASE m.kind
|
ORDER BY CASE m.kind
|
||||||
@@ -138,6 +138,11 @@ impl Db {
|
|||||||
Error::Other(format!("mount row {id} has no sync_folder_id"))
|
Error::Other(format!("mount row {id} has no sync_folder_id"))
|
||||||
})?,
|
})?,
|
||||||
},
|
},
|
||||||
|
"attached" => MountKind::Attached {
|
||||||
|
sync_folder_id: sync_folder_id.ok_or_else(|| {
|
||||||
|
Error::Other(format!("mount row {id} has no sync_folder_id"))
|
||||||
|
})?,
|
||||||
|
},
|
||||||
"shared" => MountKind::Shared {
|
"shared" => MountKind::Shared {
|
||||||
share_root_uid: share_root_uid.ok_or_else(|| {
|
share_root_uid: share_root_uid.ok_or_else(|| {
|
||||||
Error::Other(format!("mount row {id} has no share_root_uid"))
|
Error::Other(format!("mount row {id} has no share_root_uid"))
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ pub struct StoredPhoto {
|
|||||||
/// Whether any member of the group is a camera raw file. The grid badges
|
/// Whether any member of the group is a camera raw file. The grid badges
|
||||||
/// that, because the JPEG it shows is not the whole of what is stored.
|
/// that, because the JPEG it shows is not the whole of what is stored.
|
||||||
pub has_raw: bool,
|
pub has_raw: bool,
|
||||||
|
/// The town the photo was taken in, once its location has been read.
|
||||||
|
pub place_id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
||||||
@@ -256,7 +258,7 @@ fn group_photos(entries: &[Grouped]) -> Vec<String> {
|
|||||||
/// own group: the column is `NULL` until the next refresh computes it, and a
|
/// own group: the column is `NULL` until the next refresh computes it, and a
|
||||||
/// `NULL` would otherwise match nothing and drop the photo off the page.
|
/// `NULL` would otherwise match nothing and drop the photo off the page.
|
||||||
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
|
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
|
||||||
p.kind, p.favorite, g.n, g.raw \
|
p.kind, p.favorite, g.n, g.raw, p.place_id \
|
||||||
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
|
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
|
||||||
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
|
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
|
||||||
|
|
||||||
@@ -272,6 +274,7 @@ fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
|
|||||||
favorite: r.get::<_, i64>(6)? != 0,
|
favorite: r.get::<_, i64>(6)? != 0,
|
||||||
group_size: r.get::<_, i64>(7)?.max(1) as usize,
|
group_size: r.get::<_, i64>(7)?.max(1) as usize,
|
||||||
has_raw: r.get::<_, i64>(8)? != 0,
|
has_raw: r.get::<_, i64>(8)? != 0,
|
||||||
|
place_id: r.get(9)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
pub struct StoredSyncFolder {
|
pub struct StoredSyncFolder {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
/// `device` backup or `attached` My files mirror.
|
||||||
|
pub kind: String,
|
||||||
pub local_path: String,
|
pub local_path: String,
|
||||||
pub remote_uid: String,
|
pub remote_uid: String,
|
||||||
pub remote_share_id: String,
|
pub remote_share_id: String,
|
||||||
@@ -59,12 +61,32 @@ impl Db {
|
|||||||
local_path: &str,
|
local_path: &str,
|
||||||
remote_uid: &str,
|
remote_uid: &str,
|
||||||
remote_share_id: &str,
|
remote_share_id: &str,
|
||||||
|
) -> Result<i64> {
|
||||||
|
self.sync_folder_insert(local_path, remote_uid, remote_share_id, "device")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach an existing My files folder, without creating a device backup.
|
||||||
|
pub fn sync_folder_attach(
|
||||||
|
&self,
|
||||||
|
local_path: &str,
|
||||||
|
remote_uid: &str,
|
||||||
|
remote_share_id: &str,
|
||||||
|
) -> Result<i64> {
|
||||||
|
self.sync_folder_insert(local_path, remote_uid, remote_share_id, "attached")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_folder_insert(
|
||||||
|
&self,
|
||||||
|
local_path: &str,
|
||||||
|
remote_uid: &str,
|
||||||
|
remote_share_id: &str,
|
||||||
|
kind: &str,
|
||||||
) -> Result<i64> {
|
) -> Result<i64> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id)
|
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id, kind)
|
||||||
VALUES (?1, ?2, ?3)",
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![local_path, remote_uid, remote_share_id],
|
params![local_path, remote_uid, remote_share_id, kind],
|
||||||
)?;
|
)?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
}
|
}
|
||||||
@@ -73,7 +95,7 @@ impl Db {
|
|||||||
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync, kind
|
||||||
FROM sync_folder ORDER BY id",
|
FROM sync_folder ORDER BY id",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
@@ -86,6 +108,7 @@ impl Db {
|
|||||||
pending_mode: r.get(5)?,
|
pending_mode: r.get(5)?,
|
||||||
state: r.get(6)?,
|
state: r.get(6)?,
|
||||||
last_sync: r.get(7)?,
|
last_sync: r.get(7)?,
|
||||||
|
kind: r.get(8)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
@@ -99,7 +122,7 @@ impl Db {
|
|||||||
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync, kind
|
||||||
FROM sync_folder WHERE id = ?1",
|
FROM sync_folder WHERE id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
|r| {
|
|r| {
|
||||||
@@ -112,6 +135,7 @@ impl Db {
|
|||||||
pending_mode: r.get(5)?,
|
pending_mode: r.get(5)?,
|
||||||
state: r.get(6)?,
|
state: r.get(6)?,
|
||||||
last_sync: r.get(7)?,
|
last_sync: r.get(7)?,
|
||||||
|
kind: r.get(8)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -363,6 +363,23 @@ fn place_photos_carry_their_own_location() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A timeline photo names the town it was taken in, so the viewer can show it;
|
||||||
|
/// one without a location names none.
|
||||||
|
#[test]
|
||||||
|
fn timeline_photos_name_their_town() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
located("b", 500, "IMG_5.JPG", BERLIN),
|
||||||
|
TimelineRow::new("n", 400),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let berlin = db.photo_places().unwrap()[0].id;
|
||||||
|
let mut photos = db.photos_by_uid(&["b".into(), "n".into()]).unwrap();
|
||||||
|
photos.sort_by(|a, b| a.uid.cmp(&b.uid));
|
||||||
|
let towns: Vec<Option<u32>> = photos.iter().map(|photo| photo.place_id).collect();
|
||||||
|
assert_eq!(towns, [Some(berlin), None]);
|
||||||
|
}
|
||||||
|
|
||||||
/// A refresh that skips an already-resolved photo keeps where it was taken; one
|
/// A refresh that skips an already-resolved photo keeps where it was taken; one
|
||||||
/// that reads the node again takes what the node now says.
|
/// that reads the node again takes what the node now says.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2076,7 +2093,11 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
|
|||||||
{
|
{
|
||||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"DROP TRIGGER mount_sync_folder_insert;
|
"DROP TRIGGER IF EXISTS mount_sync_folder_kind_update;
|
||||||
|
DROP TRIGGER mount_sync_folder_insert;
|
||||||
|
DROP TRIGGER sync_folder_attached_insert;
|
||||||
|
DROP TRIGGER sync_folder_attached_update;
|
||||||
|
ALTER TABLE sync_folder DROP COLUMN kind;
|
||||||
DROP TABLE mount;
|
DROP TABLE mount;
|
||||||
DROP TABLE share_access;
|
DROP TABLE share_access;
|
||||||
DROP TABLE album_photos;
|
DROP TABLE album_photos;
|
||||||
@@ -7691,3 +7712,69 @@ fn a_queued_op_stops_existing_once_the_node_is_deleted() {
|
|||||||
assert_eq!(blobs, vec!["/staging/journal"]);
|
assert_eq!(blobs, vec!["/staging/journal"]);
|
||||||
assert!(!db.op_exists(id).unwrap());
|
assert!(!db.op_exists(id).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachments_have_their_own_location_and_cannot_evict_local_files() {
|
||||||
|
use crate::mounts::MountKind;
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let id = db
|
||||||
|
.sync_folder_attach("/work", "vol~work", "myfiles-share")
|
||||||
|
.unwrap();
|
||||||
|
let folder = db.sync_folder_get(id).unwrap().unwrap();
|
||||||
|
assert_eq!(folder.kind, "attached");
|
||||||
|
assert_eq!(folder.mode, "mirror");
|
||||||
|
assert_eq!(db.sync_folder_list().unwrap()[0].kind, "attached");
|
||||||
|
let locations = db.mount_list().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(locations[0].kind, MountKind::Attached { sync_folder_id } if sync_folder_id == id)
|
||||||
|
);
|
||||||
|
assert_eq!(locations[0].local_path, "/work");
|
||||||
|
assert_eq!(locations[0].root_share_id, "myfiles-share");
|
||||||
|
assert!(db.sync_folder_set_mode(id, "ondemand").is_err());
|
||||||
|
assert!(
|
||||||
|
db.sync_folder_set_pending_mode(id, Some("ondemand"))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(db.sync_folder_get(id).unwrap().unwrap().mode, "mirror");
|
||||||
|
db.sync_folder_remove(id).unwrap();
|
||||||
|
assert!(db.mount_list().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v45_keeps_existing_device_folders_and_location_ids() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let id = db
|
||||||
|
.sync_folder_add("/backup", "vol~backup", "share")
|
||||||
|
.unwrap();
|
||||||
|
db.sync_folder_set_mode(id, "ondemand").unwrap();
|
||||||
|
db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap();
|
||||||
|
let location_id = db.mount_list().unwrap()[0].id;
|
||||||
|
db.conn
|
||||||
|
.lock()
|
||||||
|
.execute_batch(
|
||||||
|
"DROP TRIGGER sync_folder_attached_insert;
|
||||||
|
DROP TRIGGER sync_folder_attached_update;
|
||||||
|
DROP TRIGGER IF EXISTS mount_sync_folder_kind_update;
|
||||||
|
DROP TRIGGER mount_sync_folder_insert;
|
||||||
|
ALTER TABLE sync_folder DROP COLUMN kind;
|
||||||
|
CREATE TRIGGER mount_sync_folder_insert AFTER INSERT ON sync_folder BEGIN
|
||||||
|
INSERT INTO mount (kind, sync_folder_id) VALUES ('device', NEW.id); END;
|
||||||
|
UPDATE sync_state SET value = '44' WHERE key = 'schema_version';",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
|
let folder = db.sync_folder_get(id).unwrap().unwrap();
|
||||||
|
assert_eq!(folder.kind, "device");
|
||||||
|
assert_eq!(folder.mode, "ondemand");
|
||||||
|
assert_eq!(folder.pending_mode.as_deref(), Some("mirror"));
|
||||||
|
assert_eq!(db.mount_list().unwrap()[0].id, location_id);
|
||||||
|
let attached = db
|
||||||
|
.sync_folder_attach("/work", "vol~work", "myfiles")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.sync_folder_get(attached).unwrap().unwrap().kind,
|
||||||
|
"attached"
|
||||||
|
);
|
||||||
|
db.migrate().unwrap();
|
||||||
|
assert_eq!(db.mount_list().unwrap().len(), 2);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ pub mod similar;
|
|||||||
pub mod syncignore;
|
pub mod syncignore;
|
||||||
pub mod syncroot;
|
pub mod syncroot;
|
||||||
pub mod takeout;
|
pub mod takeout;
|
||||||
|
pub mod timestamp;
|
||||||
pub mod tray;
|
pub mod tray;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ pub enum MountKind {
|
|||||||
MyFiles,
|
MyFiles,
|
||||||
/// A folder owned by this machine's Proton Drive device.
|
/// A folder owned by this machine's Proton Drive device.
|
||||||
Device { sync_folder_id: i64 },
|
Device { sync_folder_id: i64 },
|
||||||
|
/// A real local mirror of an existing My files folder.
|
||||||
|
Attached { sync_folder_id: i64 },
|
||||||
/// A future standalone mount rooted at a node shared with this account.
|
/// A future standalone mount rooted at a node shared with this account.
|
||||||
Shared { share_root_uid: String },
|
Shared { share_root_uid: String },
|
||||||
}
|
}
|
||||||
@@ -88,8 +90,8 @@ pub enum MountKind {
|
|||||||
/// One local Proton Drive location as exposed over the control protocol.
|
/// One local Proton Drive location as exposed over the control protocol.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct MountSpec {
|
pub struct MountSpec {
|
||||||
/// Presentation-row id. Device actions use the `sync_folder_id` carried by
|
/// Presentation-row id. Synced-folder actions use the `sync_folder_id`
|
||||||
/// [`MountKind::Device`], not this id.
|
/// carried by [`MountKind::Device`] or [`MountKind::Attached`], not this id.
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub kind: MountKind,
|
pub kind: MountKind,
|
||||||
pub local_path: String,
|
pub local_path: String,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use std::os::unix::process::CommandExt;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use crate::control::is_video_file_name;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Environment override for the terminal used by `"terminal": true` rules.
|
/// Environment override for the terminal used by `"terminal": true` rules.
|
||||||
@@ -108,11 +109,12 @@ impl OpenWith {
|
|||||||
/// a Markdown file). The rules are about *what the user opened*, so the
|
/// a Markdown file). The rules are about *what the user opened*, so the
|
||||||
/// caller passes that name and the argv still gets the real path.
|
/// caller passes that name and the argv still gets the real path.
|
||||||
pub fn command_for_named(&self, path: &Path, name: &str, is_dir: bool) -> Vec<String> {
|
pub fn command_for_named(&self, path: &Path, name: &str, is_dir: bool) -> Vec<String> {
|
||||||
|
let kind = Kind::of(path, name, is_dir);
|
||||||
for rule in &self.rules {
|
for rule in &self.rules {
|
||||||
if rule.command.is_empty() {
|
if rule.command.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !rule.patterns.iter().any(|p| matches(p, name, is_dir)) {
|
if !rule.patterns.iter().any(|p| matches(p, name, kind)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let argv = expand(&rule.command, path);
|
let argv = expand(&rule.command, path);
|
||||||
@@ -325,16 +327,46 @@ fn expand_env(token: &str) -> Option<String> {
|
|||||||
value.filter(|v| !v.is_empty())
|
value.filter(|v| !v.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What is being opened, as far as the `@class` patterns care.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Kind {
|
||||||
|
Dir,
|
||||||
|
Video,
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kind {
|
||||||
|
/// `.ts` names TypeScript and MPEG transport streams alike, so for that
|
||||||
|
/// one extension the size on disk decides (see
|
||||||
|
/// [`is_video_file_name`](crate::control::is_video_file_name)). No other
|
||||||
|
/// name costs a `stat`.
|
||||||
|
fn of(path: &Path, name: &str, is_dir: bool) -> Kind {
|
||||||
|
if is_dir {
|
||||||
|
return Kind::Dir;
|
||||||
|
}
|
||||||
|
let size = extension(name)
|
||||||
|
.eq_ignore_ascii_case("ts")
|
||||||
|
.then(|| std::fs::metadata(path).ok().map(|meta| meta.len()))
|
||||||
|
.flatten();
|
||||||
|
if is_video_file_name(name, size) {
|
||||||
|
Kind::Video
|
||||||
|
} else {
|
||||||
|
Kind::File
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether `pattern` — a glob or an `@class` — matches this name.
|
/// Whether `pattern` — a glob or an `@class` — matches this name.
|
||||||
fn matches(pattern: &str, name: &str, is_dir: bool) -> bool {
|
fn matches(pattern: &str, name: &str, kind: Kind) -> bool {
|
||||||
let name_lower = name.to_lowercase();
|
let name_lower = name.to_lowercase();
|
||||||
|
let file = kind == Kind::File;
|
||||||
match pattern.to_lowercase().as_str() {
|
match pattern.to_lowercase().as_str() {
|
||||||
"@any" => true,
|
"@any" => true,
|
||||||
"@dir" => is_dir,
|
"@dir" => kind == Kind::Dir,
|
||||||
"@text" => !is_dir && class_text(&name_lower),
|
"@text" => file && class_text(&name_lower),
|
||||||
"@document" => !is_dir && class_document(&name_lower),
|
"@document" => file && class_document(&name_lower),
|
||||||
"@image" => !is_dir && class_image(&name_lower),
|
"@image" => file && class_image(&name_lower),
|
||||||
"@media" => !is_dir && class_media(&name_lower),
|
"@media" => kind == Kind::Video || (file && class_media(&name_lower)),
|
||||||
other if other.starts_with('@') => false,
|
other if other.starts_with('@') => false,
|
||||||
other => glob_match(other, &name_lower),
|
other => glob_match(other, &name_lower),
|
||||||
}
|
}
|
||||||
@@ -533,6 +565,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ts_file_opens_as_media_or_text_by_its_size() {
|
||||||
|
let policy = OpenWith {
|
||||||
|
terminal: Some(vec!["alacritty".into(), "-e".into()]),
|
||||||
|
default: None,
|
||||||
|
rules: vec![
|
||||||
|
rule(&["@text"], &["nvim"], true),
|
||||||
|
rule(&["@media"], &["mpv"], false),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let dir = std::env::temp_dir().join(format!("pdfs-opener-ts-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let source = dir.join("blob-source");
|
||||||
|
std::fs::write(&source, "export const answer = 42;\n").unwrap();
|
||||||
|
let recording = dir.join("blob-recording");
|
||||||
|
std::fs::File::create(&recording)
|
||||||
|
.unwrap()
|
||||||
|
.set_len(crate::control::TS_VIDEO_MIN_BYTES)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let source_argv = policy.command_for_named(&source, "app.ts", false);
|
||||||
|
let recording_argv = policy.command_for_named(&recording, "recording.ts", false);
|
||||||
|
// A file that is not on this machine yet has no size to go by.
|
||||||
|
let unknown_argv = policy.command_for_named(&dir.join("missing"), "recording.ts", false);
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(source_argv[..3], ["alacritty", "-e", "nvim"]);
|
||||||
|
assert_eq!(recording_argv[0], "mpv");
|
||||||
|
assert_eq!(unknown_argv[0], "mpv");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_empty_policy_is_xdg_open() {
|
fn an_empty_policy_is_xdg_open() {
|
||||||
let policy = OpenWith::default();
|
let policy = OpenWith::default();
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//! Explicitly bind an existing My files folder to a real local mirror.
|
||||||
|
//!
|
||||||
|
//! Unlike a FUSE mount, this never covers or evicts the local directory.
|
||||||
|
//! The existing three-way engine performs the initial, non-destructive union.
|
||||||
|
|
||||||
|
use super::{Core, parse_uid, sync};
|
||||||
|
use pdfs_core::db::StoredSyncFolder;
|
||||||
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// A bounded, server-verified ancestry walk. Do not decide ownership or
|
||||||
|
/// remote-root overlap from a possibly incomplete cached folder listing.
|
||||||
|
pub(super) fn attach_ancestors(&self, start: &NodeUid) -> CoreResult<Vec<NodeUid>> {
|
||||||
|
let mut ancestors = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut next = Some(start.clone());
|
||||||
|
while let Some(uid) = next {
|
||||||
|
if !seen.insert(uid.clone()) || ancestors.len() >= 256 {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"invalid or excessively deep remote folder ancestry",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ancestors.push(uid.clone());
|
||||||
|
if uid == self.primary_root_uid {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let nodes = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.drive.enumerate_nodes(std::slice::from_ref(&uid)))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "verify sync folder ancestry"))?;
|
||||||
|
let node = nodes
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| CoreError::not_found("remote sync folder is missing"))?;
|
||||||
|
if node.trashed {
|
||||||
|
return Err(CoreError::invalid("remote sync folder is in the trash"));
|
||||||
|
}
|
||||||
|
next = node.parent_uid;
|
||||||
|
}
|
||||||
|
Ok(ancestors)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn attach_sync_folder(
|
||||||
|
&self,
|
||||||
|
local: &Path,
|
||||||
|
remote: &Path,
|
||||||
|
mountpoint: &Path,
|
||||||
|
) -> CoreResult<StoredSyncFolder> {
|
||||||
|
let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock();
|
||||||
|
if !self.primary {
|
||||||
|
return Err(CoreError::invalid("attach must use My files"));
|
||||||
|
}
|
||||||
|
if remote.is_absolute()
|
||||||
|
|| remote
|
||||||
|
.components()
|
||||||
|
.any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"remote folder must stay inside My files",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let local = local
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| CoreError::invalid(format!("resolve {}: {e}", local.display())))?;
|
||||||
|
if !local.is_dir() {
|
||||||
|
return Err(CoreError::invalid("local sync path is not a directory"));
|
||||||
|
}
|
||||||
|
let existing = self
|
||||||
|
.db
|
||||||
|
.sync_folder_list()
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
let others = existing
|
||||||
|
.iter()
|
||||||
|
.map(|f| PathBuf::from(&f.local_path))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut reserved = crate::devices::sync_root_reserved();
|
||||||
|
reserved.mountpoint = Some(
|
||||||
|
mountpoint
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| CoreError::internal(format!("resolve mountpoint: {e}")))?,
|
||||||
|
);
|
||||||
|
if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) {
|
||||||
|
return Err(CoreError::invalid(problem));
|
||||||
|
}
|
||||||
|
let (ino, remote_uid) = self.resolve(remote)?;
|
||||||
|
let is_folder = self
|
||||||
|
.state()
|
||||||
|
.entries
|
||||||
|
.get(&ino)
|
||||||
|
.is_some_and(|e| e.node.is_folder() && !e.node.trashed);
|
||||||
|
if !is_folder {
|
||||||
|
return Err(CoreError::invalid("remote sync path is not a folder"));
|
||||||
|
}
|
||||||
|
let ancestors = self.attach_ancestors(&remote_uid)?;
|
||||||
|
if remote_uid == self.primary_root_uid || !ancestors.contains(&self.primary_root_uid) {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"choose an owned folder below My files, not its root or a shared folder",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for other in existing.iter().filter(|f| f.kind == "attached") {
|
||||||
|
let uid = parse_uid(&other.remote_uid)
|
||||||
|
.ok_or_else(|| CoreError::internal("invalid existing remote sync root"))?;
|
||||||
|
if uid == remote_uid
|
||||||
|
|| ancestors.contains(&uid)
|
||||||
|
|| self.attach_ancestors(&uid)?.contains(&remote_uid)
|
||||||
|
{
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"remote folder overlaps an existing synced folder",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let share_id = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.drive.context_share_id(&self.primary_root_uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "resolve My files share"))?
|
||||||
|
.to_string();
|
||||||
|
if share_id.is_empty() {
|
||||||
|
return Err(CoreError::invalid("My files share is unavailable"));
|
||||||
|
}
|
||||||
|
let id = self
|
||||||
|
.db
|
||||||
|
.sync_folder_attach(&local.to_string_lossy(), &remote_uid.to_string(), &share_id)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
// An empty baseline makes one-sided files new, never deletions. The
|
||||||
|
// engine hashes matching-size files and preserves divergent versions.
|
||||||
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
|
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||||
|
self.db
|
||||||
|
.sync_folder_get(id)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
|
.ok_or_else(|| CoreError::not_found("new sync folder disappeared"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,6 +157,17 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
if ours {
|
if ours {
|
||||||
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
||||||
}
|
}
|
||||||
|
// A file kept offline keeps its copy through a change, and the
|
||||||
|
// keeper replaces it once this batch's listings are read again.
|
||||||
|
// The copy is checked against the revision's `(mtime, size)`, so
|
||||||
|
// no read takes it for a newer revision. Evicted, it was gone until
|
||||||
|
// something read the file, which then downloaded it (B282). A
|
||||||
|
// node new in, or moved into, a kept folder is fetched by checking
|
||||||
|
// that folder.
|
||||||
|
let kept = match !*is_trashed && !ours {
|
||||||
|
true => kept_by_pin(core, node_uid, parent_node_uid.as_ref()),
|
||||||
|
false => None,
|
||||||
|
};
|
||||||
let mut had_node = false;
|
let mut had_node = false;
|
||||||
core.for_each_mount(|st, notify| {
|
core.for_each_mount(|st, notify| {
|
||||||
if *is_trashed {
|
if *is_trashed {
|
||||||
@@ -172,9 +183,12 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Once, not once per mount: the content cache is per-daemon.
|
// Once, not once per mount: the content cache is per-daemon.
|
||||||
if had_node {
|
if had_node && kept.is_none() {
|
||||||
core.cache.evict(node_uid);
|
core.cache.evict(node_uid);
|
||||||
}
|
}
|
||||||
|
if let Some(kept) = kept {
|
||||||
|
dirty.kept.insert(kept);
|
||||||
|
}
|
||||||
// A create, rename or move-in shows up as a change to the parent
|
// A create, rename or move-in shows up as a change to the parent
|
||||||
// listing too, which is recorded for the end of the batch rather
|
// listing too, which is recorded for the end of the batch rather
|
||||||
// than acted on here — see [`DirtyParents`].
|
// than acted on here — see [`DirtyParents`].
|
||||||
@@ -205,6 +219,8 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
| DriveEvent::ScopeAccessLost { .. }
|
| DriveEvent::ScopeAccessLost { .. }
|
||||||
| DriveEvent::SharedWithMeUpdated { .. } => {
|
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||||
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
||||||
|
// Changes went by unseen, kept files among them (B282).
|
||||||
|
core.recheck_all_pins();
|
||||||
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
||||||
core.for_each_mount(|st, notify| {
|
core.for_each_mount(|st, notify| {
|
||||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||||
@@ -296,6 +312,21 @@ struct DirtyParents {
|
|||||||
/// Checked per mount — a mount that already lists every one of those
|
/// Checked per mount — a mount that already lists every one of those
|
||||||
/// children under that parent keeps its listing.
|
/// children under that parent keeps its listing.
|
||||||
ours: HashMap<NodeUid, HashSet<NodeUid>>,
|
ours: HashMap<NodeUid, HashSet<NodeUid>>,
|
||||||
|
/// Pinned files and folders the batch changed, for the pin keeper to
|
||||||
|
/// check once the listings above are dropped (`keeper`).
|
||||||
|
kept: HashSet<NodeUid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the pin keeper has to check after a remote change to `node_uid`,
|
||||||
|
/// whose parent is now `parent_uid`: the node itself when a pin keeps it, or
|
||||||
|
/// else the parent, when a pinned folder holds it and the node is new there.
|
||||||
|
fn kept_by_pin(core: &Core, node_uid: &NodeUid, parent_uid: Option<&NodeUid>) -> Option<NodeUid> {
|
||||||
|
if core.cache.is_pinned(node_uid) {
|
||||||
|
return Some(node_uid.clone());
|
||||||
|
}
|
||||||
|
parent_uid
|
||||||
|
.filter(|parent| core.cache.is_pinned(parent))
|
||||||
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this mount's cached listing of `parent_uid` already accounts for
|
/// Whether this mount's cached listing of `parent_uid` already accounts for
|
||||||
@@ -476,6 +507,9 @@ pub(super) async fn run_event_sync(
|
|||||||
cursor = Some(applied);
|
cursor = Some(applied);
|
||||||
}
|
}
|
||||||
flush_dirty_parents(&core, &dirty);
|
flush_dirty_parents(&core, &dirty);
|
||||||
|
for uid in &dirty.kept {
|
||||||
|
core.recheck_pinned(uid);
|
||||||
|
}
|
||||||
// Another device changed the tree: whatever a front-end lists may have
|
// Another device changed the tree: whatever a front-end lists may have
|
||||||
// moved, including what is shared and what is in the trash.
|
// moved, including what is shared and what is in the trash.
|
||||||
core.events
|
core.events
|
||||||
|
|||||||
@@ -204,18 +204,42 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
|||||||
/// B99), so resolving one needs this too. A mirror folder has no mount to run
|
/// B99), so resolving one needs this too. A mirror folder has no mount to run
|
||||||
/// against: a file there is an ordinary local file.
|
/// against: a file there is an ordinary local file.
|
||||||
fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||||
|
route_within(core, mountpoint, path, |path| {
|
||||||
|
format!("{path} is in a mirrored folder; change the local file there instead")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`route_to_mount`] for keeping a node offline or releasing it. A file in an
|
||||||
|
/// on-demand synced folder is a different Drive node from any file of the
|
||||||
|
/// same name under the primary mount, and keeping one offline did nothing for
|
||||||
|
/// the other: a program reading the synced folder's copy downloaded it
|
||||||
|
/// (`docs/BUGS.md` B281). A mirror folder already holds every file on disk.
|
||||||
|
fn route_keep(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||||
|
route_within(core, mountpoint, path, |path| {
|
||||||
|
format!(
|
||||||
|
"{path} is in a mirrored folder, which keeps all its files on this computer already"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`route_to_mount`], refusing a path that leaves the mount it routed to and
|
||||||
|
/// answering a path in a mirror folder, which has no mount, with `mirrored`.
|
||||||
|
fn route_within(
|
||||||
|
core: &Core,
|
||||||
|
mountpoint: &Path,
|
||||||
|
path: &str,
|
||||||
|
mirrored: impl FnOnce(&str) -> String,
|
||||||
|
) -> CoreResult<(Core, PathBuf)> {
|
||||||
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
|
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
|
||||||
let p = Path::new(path);
|
let p = Path::new(path);
|
||||||
let mirrored = core
|
let in_mirror = core
|
||||||
.db
|
.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
||||||
if mirrored {
|
if in_mirror {
|
||||||
CoreError::invalid(format!(
|
CoreError::invalid(mirrored(path))
|
||||||
"{path} is in a mirrored folder; change the local file there instead"
|
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
e
|
e
|
||||||
}
|
}
|
||||||
@@ -231,6 +255,18 @@ fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The path the pin registry keeps for a pin made through `routed`: the
|
||||||
|
/// mount-relative path on the primary mount, as it always was, and the
|
||||||
|
/// absolute path the request named on an on-demand folder's mount, since
|
||||||
|
/// that node is not under the primary mountpoint (`docs/BUGS.md` B281).
|
||||||
|
fn pin_record(routed: &Core, rel: &Path, path: &str) -> PathBuf {
|
||||||
|
if routed.primary {
|
||||||
|
rel.to_path_buf()
|
||||||
|
} else {
|
||||||
|
Path::new(path).components().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// [`route_change`] for a request that trashes or renames the node it names.
|
/// [`route_change`] for a request that trashes or renames the node it names.
|
||||||
/// A mount's root is My files or a whole synced folder. An on-demand folder's
|
/// A mount's root is My files or a whole synced folder. An on-demand folder's
|
||||||
/// root has a parent on Drive, so nothing further down would stop `pdfs rm
|
/// root has a parent on Drive, so nothing further down would stop `pdfs rm
|
||||||
@@ -385,6 +421,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
|||||||
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||||
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
|
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||||
R::AddSyncFolder { .. }
|
R::AddSyncFolder { .. }
|
||||||
|
| R::AttachSyncFolder { .. }
|
||||||
| R::RemoveSyncFolder { .. }
|
| R::RemoveSyncFolder { .. }
|
||||||
| R::SetSyncFolderMode { .. }
|
| R::SetSyncFolderMode { .. }
|
||||||
| R::SetSyncFolderPaused { .. }
|
| R::SetSyncFolderPaused { .. }
|
||||||
@@ -520,8 +557,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(CtlRequest::Subscribe) => {
|
Ok(CtlRequest::Subscribe) => {
|
||||||
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
|
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Pin { path }) => match route_keep(core, mountpoint, &path) {
|
||||||
Ok(rel) => match core.pin(&rel) {
|
Ok((routed, rel)) => match routed.pin(&rel, &pin_record(&routed, &rel, &path)) {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
core.touch_profile();
|
core.touch_profile();
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -532,8 +569,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Unpin { path }) => match route_keep(core, mountpoint, &path) {
|
||||||
Ok(rel) => match core.unpin(&rel) {
|
Ok((routed, rel)) => match routed.unpin(&rel) {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
core.touch_profile();
|
core.touch_profile();
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -938,6 +975,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
});
|
});
|
||||||
let metadata = proton_drive_rs::PhotoUploadMetadata {
|
let metadata = proton_drive_rs::PhotoUploadMetadata {
|
||||||
capture_time,
|
capture_time,
|
||||||
|
last_modification_time: capture_time
|
||||||
|
.and_then(pdfs_core::timestamp::modification_time),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name);
|
let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name);
|
||||||
@@ -1397,6 +1436,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
message: format!("adding {local_path}"),
|
message: format!("adding {local_path}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::AttachSyncFolder {
|
||||||
|
local_path,
|
||||||
|
remote_path,
|
||||||
|
}) => {
|
||||||
|
let result = rel_to_mount(mountpoint, &remote_path).and_then(|remote| {
|
||||||
|
core.attach_sync_folder(Path::new(&local_path), &remote, mountpoint)
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Ok(folder) => CtlResponse::Ok {
|
||||||
|
message: format!("sync folder {} attached: {}", folder.id, folder.local_path),
|
||||||
|
},
|
||||||
|
Err(error) => CtlResponse::error(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() {
|
Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() {
|
||||||
Ok(items) => CtlResponse::SyncFolders { items },
|
Ok(items) => CtlResponse::SyncFolders { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ use super::{
|
|||||||
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
|
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Registration validates local and remote root overlap before inserting a
|
||||||
|
/// mapping. Serialize all registration paths so concurrent control requests
|
||||||
|
/// cannot both validate against the same old set of roots.
|
||||||
|
pub(super) static SYNC_ROOT_REGISTRATION: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
/// A mode that this daemon is allowed to apply.
|
/// A mode that this daemon is allowed to apply.
|
||||||
///
|
///
|
||||||
/// Persisted and control-protocol values first pass through [`MountMode`] so
|
/// Persisted and control-protocol values first pass through [`MountMode`] so
|
||||||
@@ -152,7 +157,9 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
for location in &mut locations {
|
for location in &mut locations {
|
||||||
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
||||||
if let MountKind::Device { sync_folder_id } = &location.kind {
|
if let MountKind::Device { sync_folder_id } | MountKind::Attached { sync_folder_id } =
|
||||||
|
&location.kind
|
||||||
|
{
|
||||||
location.progress = progress.get(sync_folder_id).cloned();
|
location.progress = progress.get(sync_folder_id).cloned();
|
||||||
location.paused = self.sync_folder_paused(*sync_folder_id);
|
location.paused = self.sync_folder_paused(*sync_folder_id);
|
||||||
}
|
}
|
||||||
@@ -495,7 +502,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Everything directly under a device root, trashed nodes included.
|
/// Everything directly under a device root, trashed nodes included.
|
||||||
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
|
pub(super) fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
||||||
@@ -513,6 +520,7 @@ impl Core {
|
|||||||
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
|
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
|
||||||
/// the two-way engine (Phase 2) reconciles later changes.
|
/// the two-way engine (Phase 2) reconciles later changes.
|
||||||
pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult<StoredSyncFolder> {
|
pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult<StoredSyncFolder> {
|
||||||
|
let _registration = SYNC_ROOT_REGISTRATION.lock();
|
||||||
let meta = std::fs::metadata(local)
|
let meta = std::fs::metadata(local)
|
||||||
.map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?;
|
.map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?;
|
||||||
if !meta.is_dir() {
|
if !meta.is_dir() {
|
||||||
@@ -699,6 +707,11 @@ impl Core {
|
|||||||
.sync_folder_get(id)
|
.sync_folder_get(id)
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
|
if folder.kind == "attached" && delete_remote {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"attached My files folders cannot be deleted by stopping sync; omit --delete-remote",
|
||||||
|
));
|
||||||
|
}
|
||||||
// An `ondemand` folder *is* a FUSE mount over its local path, so dropping
|
// An `ondemand` folder *is* a FUSE mount over its local path, so dropping
|
||||||
// only the row would strand the mount: the path would keep serving a
|
// only the row would strand the mount: the path would keep serving a
|
||||||
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
||||||
@@ -785,6 +798,11 @@ impl Core {
|
|||||||
.sync_folder_get(id)
|
.sync_folder_get(id)
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
|
if folder.kind == "attached" && target_mode != ApplicableMountMode::Mirror {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"attached My files folders only support mirror mode; use the My files mount for online-only access",
|
||||||
|
));
|
||||||
|
}
|
||||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||||
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
||||||
// A switch waits on the folder's reconcile, which a pause skips; refuse
|
// A switch waits on the folder's reconcile, which a pause skips; refuse
|
||||||
@@ -960,6 +978,11 @@ impl Core {
|
|||||||
.sync_folder_get(id)
|
.sync_folder_get(id)
|
||||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
|
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
|
||||||
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
|
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
|
||||||
|
if folder.kind == "attached" && mode != ApplicableMountMode::Mirror {
|
||||||
|
return Err(SwitchBlocked::Failed(
|
||||||
|
"attached My files folders only support mirror mode".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) {
|
if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) {
|
||||||
return Err(SwitchBlocked::Superseded);
|
return Err(SwitchBlocked::Superseded);
|
||||||
}
|
}
|
||||||
@@ -1199,7 +1222,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
for folder in folders {
|
for folder in folders {
|
||||||
if folder.mode != "ondemand" {
|
if folder.kind != "device" || folder.mode != "ondemand" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
|
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
|
||||||
@@ -1395,6 +1418,7 @@ mod tests {
|
|||||||
|
|
||||||
fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder {
|
fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder {
|
||||||
StoredSyncFolder {
|
StoredSyncFolder {
|
||||||
|
kind: "device".into(),
|
||||||
id: 7,
|
id: 7,
|
||||||
local_path: local_path.to_string(),
|
local_path: local_path.to_string(),
|
||||||
remote_uid: remote_uid.to_string(),
|
remote_uid: remote_uid.to_string(),
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ use futures::StreamExt as _;
|
|||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
use proton_drive_rs::proton_sdk::account::Quota;
|
use proton_drive_rs::proton_sdk::account::Quota;
|
||||||
use proton_drive_rs::proton_sdk::error::Result;
|
use proton_drive_rs::proton_sdk::error::Result;
|
||||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid, ShareId};
|
||||||
use proton_drive_rs::{
|
use proton_drive_rs::{
|
||||||
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
|
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
|
||||||
ProtonDriveClient, RevisionReader, Thumbnail,
|
ProtonDriveClient, RevisionReader, Thumbnail, ThumbnailType,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
||||||
@@ -43,6 +43,8 @@ pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>>
|
|||||||
pub(crate) trait DriveApi: Send + Sync {
|
pub(crate) trait DriveApi: Send + Sync {
|
||||||
async fn get_my_files_folder(&self) -> Result<Node>;
|
async fn get_my_files_folder(&self) -> Result<Node>;
|
||||||
|
|
||||||
|
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId>;
|
||||||
|
|
||||||
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
|
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
|
||||||
|
|
||||||
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
||||||
@@ -116,6 +118,14 @@ pub(crate) trait DriveApi: Send + Sync {
|
|||||||
|
|
||||||
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>>;
|
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>>;
|
||||||
|
|
||||||
|
/// The file's thumbnail of `thumbnail_type`, or `None` when its revision
|
||||||
|
/// carries none.
|
||||||
|
async fn download_thumbnail(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
thumbnail_type: ThumbnailType,
|
||||||
|
) -> Result<Option<Vec<u8>>>;
|
||||||
|
|
||||||
async fn download_file_to_dyn(
|
async fn download_file_to_dyn(
|
||||||
&self,
|
&self,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
@@ -305,6 +315,10 @@ impl RevisionRead for RevisionReader {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl DriveApi for ProtonDriveClient {
|
impl DriveApi for ProtonDriveClient {
|
||||||
|
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId> {
|
||||||
|
ProtonDriveClient::context_share_id(self, uid).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_my_files_folder(&self) -> Result<Node> {
|
async fn get_my_files_folder(&self) -> Result<Node> {
|
||||||
ProtonDriveClient::get_my_files_folder(self).await
|
ProtonDriveClient::get_my_files_folder(self).await
|
||||||
}
|
}
|
||||||
@@ -346,7 +360,13 @@ impl DriveApi for ProtonDriveClient {
|
|||||||
name: &str,
|
name: &str,
|
||||||
last_modification_time: Option<i64>,
|
last_modification_time: Option<i64>,
|
||||||
) -> Result<NodeUid> {
|
) -> Result<NodeUid> {
|
||||||
ProtonDriveClient::create_folder(self, parent_uid, name, last_modification_time).await
|
ProtonDriveClient::create_folder(
|
||||||
|
self,
|
||||||
|
parent_uid,
|
||||||
|
name,
|
||||||
|
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_file(
|
async fn upload_file(
|
||||||
@@ -378,7 +398,7 @@ impl DriveApi for ProtonDriveClient {
|
|||||||
reader,
|
reader,
|
||||||
intended_size,
|
intended_size,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
last_modification_time,
|
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
|
||||||
aead,
|
aead,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -403,7 +423,7 @@ impl DriveApi for ProtonDriveClient {
|
|||||||
reader,
|
reader,
|
||||||
intended_size,
|
intended_size,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
last_modification_time,
|
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
|
||||||
aead,
|
aead,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -423,7 +443,7 @@ impl DriveApi for ProtonDriveClient {
|
|||||||
reader,
|
reader,
|
||||||
intended_size,
|
intended_size,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
last_modification_time,
|
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -433,6 +453,14 @@ impl DriveApi for ProtonDriveClient {
|
|||||||
Ok(Arc::new(reader))
|
Ok(Arc::new(reader))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn download_thumbnail(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
thumbnail_type: ThumbnailType,
|
||||||
|
) -> Result<Option<Vec<u8>>> {
|
||||||
|
ProtonDriveClient::download_thumbnail(self, uid, thumbnail_type).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn download_file_to_dyn(
|
async fn download_file_to_dyn(
|
||||||
&self,
|
&self,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
//! Keeping pinned files on this computer after they were pinned.
|
||||||
|
//!
|
||||||
|
//! A pin downloads a file once. Nothing downloaded it again afterwards: a
|
||||||
|
//! change from another device deleted the cached copy, a file added to a
|
||||||
|
//! pinned folder elsewhere was never fetched, and a download that failed while
|
||||||
|
//! the link was down stayed failed. Each of those left a file listed as kept
|
||||||
|
//! offline that the next read downloaded, and that could not be read at all
|
||||||
|
//! without a network (`docs/BUGS.md` B282).
|
||||||
|
//!
|
||||||
|
//! The keeper is one thread that checks pins again and downloads what is
|
||||||
|
//! missing or stale: every pin when the daemon starts and when the link comes
|
||||||
|
//! back, and the pins a batch of remote events touched once the batch is
|
||||||
|
//! applied. A remote change no longer deletes a pinned file's copy. The copy
|
||||||
|
//! is stored under the revision's `(mtime, size)`, so a read of a newer
|
||||||
|
//! revision does not take it, and the keeper replaces it once the new one is
|
||||||
|
//! downloaded.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use proton_drive_rs::NodeKind;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use super::{Core, is_local_uid, node_size, parse_uid};
|
||||||
|
|
||||||
|
/// How often the keeper looks for work.
|
||||||
|
const KEEPER_TICK: Duration = Duration::from_secs(1);
|
||||||
|
/// The first wait before a check that could not download everything is tried
|
||||||
|
/// again. It doubles on every failure, up to [`RETRY_MAX`].
|
||||||
|
const RETRY_FIRST: Duration = Duration::from_secs(30);
|
||||||
|
const RETRY_MAX: Duration = Duration::from_secs(30 * 60);
|
||||||
|
|
||||||
|
/// What the keeper owes a check.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct PinWork {
|
||||||
|
/// Every pin.
|
||||||
|
all: bool,
|
||||||
|
/// The pinned files and folders named here. A folder is checked whole.
|
||||||
|
nodes: HashSet<NodeUid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PinWork {
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
!self.all && self.nodes.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge(&mut self, other: PinWork) {
|
||||||
|
self.all |= other.all;
|
||||||
|
self.nodes.extend(other.nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file a pin keeps, as the tree knows it now.
|
||||||
|
pub(crate) struct KeptFile {
|
||||||
|
pub(crate) uid: NodeUid,
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) mtime: i64,
|
||||||
|
pub(crate) size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Have the keeper check every pin.
|
||||||
|
pub(crate) fn recheck_all_pins(&self) {
|
||||||
|
self.pin_work.lock().all = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Have the keeper check the pinned file or folder `uid`.
|
||||||
|
pub(crate) fn recheck_pinned(&self, uid: &NodeUid) {
|
||||||
|
self.pin_work.lock().nodes.insert(uid.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keeper's loop, until the daemon stops. Starts with a check of every
|
||||||
|
/// pin, which catches up on whatever changed while the daemon was down.
|
||||||
|
///
|
||||||
|
/// Work that failed waits out a backoff of its own. New work does not wait
|
||||||
|
/// for it, so a file that keeps failing does not hold up the next change.
|
||||||
|
pub(crate) fn run_pin_keeper(&self) {
|
||||||
|
self.recheck_all_pins();
|
||||||
|
let mut backlog = PinWork::default();
|
||||||
|
let mut backlog_due = Instant::now();
|
||||||
|
let mut retry = RETRY_FIRST;
|
||||||
|
while self.shutdown.sleep(KEEPER_TICK) {
|
||||||
|
if !self.is_online() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut work = std::mem::take(&mut *self.pin_work.lock());
|
||||||
|
let retrying = !backlog.is_empty() && Instant::now() >= backlog_due;
|
||||||
|
if retrying {
|
||||||
|
work.merge(std::mem::take(&mut backlog));
|
||||||
|
}
|
||||||
|
if work.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let failed = self.keep_pins(&work);
|
||||||
|
if failed.is_empty() {
|
||||||
|
if retrying {
|
||||||
|
retry = RETRY_FIRST;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
backlog.merge(failed);
|
||||||
|
backlog_due = Instant::now() + retry;
|
||||||
|
retry = (retry * 2).min(RETRY_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download every file `work` covers whose cached copy is missing or
|
||||||
|
/// stale. Returns what could not be checked or downloaded.
|
||||||
|
fn keep_pins(&self, work: &PinWork) -> PinWork {
|
||||||
|
let mut failed = PinWork::default();
|
||||||
|
let roots: Vec<NodeUid> = if work.all {
|
||||||
|
self.cache
|
||||||
|
.list_pins()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|pin| parse_uid(&pin.uid))
|
||||||
|
.chain(work.nodes.iter().cloned())
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
work.nodes.iter().cloned().collect()
|
||||||
|
};
|
||||||
|
let mut seen: HashSet<u64> = HashSet::new();
|
||||||
|
let mut files: Vec<u64> = Vec::new();
|
||||||
|
for uid in roots {
|
||||||
|
if self.shutdown.is_stopping() {
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
if !self.cache.is_pinned(&uid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let found = {
|
||||||
|
let st = self.state();
|
||||||
|
st.by_uid.get(&uid).and_then(|&ino| {
|
||||||
|
let e = st.entries.get(&ino)?;
|
||||||
|
Some((ino, e.parent, e.node.is_folder()))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let Some((ino, parent, is_folder)) = found else {
|
||||||
|
debug!(%uid, "pin keeper: pinned node is not in the tree");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if is_folder {
|
||||||
|
match self.pin_subtree_files(ino) {
|
||||||
|
Ok(inos) => files.extend(inos.into_iter().filter(|i| seen.insert(*i))),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(%uid, error = %e, "pin keeper: listing a kept folder failed");
|
||||||
|
failed.nodes.insert(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// An event about this file dropped its parent's listing. Listing
|
||||||
|
// it again brings the file's entry up to the revision Drive has
|
||||||
|
// now, which is what the cached copy is checked against.
|
||||||
|
if !work.all
|
||||||
|
&& parent != ino
|
||||||
|
&& let Err(e) = self.ensure_children(parent)
|
||||||
|
{
|
||||||
|
warn!(%uid, error = ?e, "pin keeper: listing a kept file's folder failed");
|
||||||
|
failed.nodes.insert(uid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if seen.insert(ino) {
|
||||||
|
files.push(ino);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stale: Vec<KeptFile> = files
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|ino| self.kept_file(ino))
|
||||||
|
.filter(|f| !self.cache.is_cached(&f.uid, f.mtime, f.size))
|
||||||
|
.collect();
|
||||||
|
if stale.is_empty() {
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
let job = self.transfers.begin_job("Updating files kept offline");
|
||||||
|
job.set_total(stale.len() as u64);
|
||||||
|
let (mut fetched, mut failures) = (0usize, 0usize);
|
||||||
|
for file in stale {
|
||||||
|
if self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Unpinned while the check ran.
|
||||||
|
if !self.cache.is_pinned(&file.uid) {
|
||||||
|
job.step();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
job.detail(file.name.clone());
|
||||||
|
match self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size) {
|
||||||
|
Ok(()) => fetched += 1,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(uid = %file.uid, error = %e, "pin keeper: download failed");
|
||||||
|
failed.nodes.insert(file.uid);
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
job.step();
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
fetched,
|
||||||
|
failed = failures,
|
||||||
|
"pin keeper: brought files kept offline up to date"
|
||||||
|
);
|
||||||
|
failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file at `ino`, as a pin keeps it. `None` for a folder, a file this
|
||||||
|
/// daemon still owes an upload for, or a file whose real size cannot be
|
||||||
|
/// read.
|
||||||
|
///
|
||||||
|
/// A queued write is ahead of Drive. Downloading Drive's revision would
|
||||||
|
/// cache what the write replaces, and the drain keeps the uploaded bytes
|
||||||
|
/// of a pinned file once they land.
|
||||||
|
///
|
||||||
|
/// A listing carries the ciphertext size until its size upgrade lands
|
||||||
|
/// (B12). Stored under that size, the copy never matched the plaintext it
|
||||||
|
/// holds, so every read downloaded the file again and every check
|
||||||
|
/// downloaded it once more.
|
||||||
|
pub(crate) fn kept_file(&self, ino: u64) -> Option<KeptFile> {
|
||||||
|
let (uid, name, mtime, size, provisional) = {
|
||||||
|
let st = self.state();
|
||||||
|
let e = st.entries.get(&ino)?;
|
||||||
|
let NodeKind::File { claimed_size, .. } = &e.node.kind else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
(
|
||||||
|
e.uid.clone(),
|
||||||
|
e.node.name.clone(),
|
||||||
|
e.node.modification_time,
|
||||||
|
node_size(&e.node),
|
||||||
|
claimed_size.is_none(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if is_local_uid(&uid) || self.pending.lock().contains_key(&uid) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let size = match provisional {
|
||||||
|
true => self.resolve_provisional_size(ino, &uid)?,
|
||||||
|
false => size,
|
||||||
|
};
|
||||||
|
Some(KeptFile {
|
||||||
|
uid,
|
||||||
|
name,
|
||||||
|
mtime,
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn work_that_failed_keeps_every_node_and_the_full_check() {
|
||||||
|
let mut backlog = PinWork::default();
|
||||||
|
assert!(backlog.is_empty());
|
||||||
|
let uid = parse_uid("vol~a").unwrap();
|
||||||
|
backlog.merge(PinWork {
|
||||||
|
all: false,
|
||||||
|
nodes: HashSet::from([uid.clone()]),
|
||||||
|
});
|
||||||
|
backlog.merge(PinWork {
|
||||||
|
all: true,
|
||||||
|
nodes: HashSet::from([uid.clone()]),
|
||||||
|
});
|
||||||
|
assert!(backlog.all);
|
||||||
|
assert_eq!(backlog.nodes.len(), 1);
|
||||||
|
assert!(!backlog.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-18
@@ -79,6 +79,7 @@ use proton_drive_rs::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod albums;
|
mod albums;
|
||||||
|
mod attach;
|
||||||
mod background;
|
mod background;
|
||||||
mod conflicts;
|
mod conflicts;
|
||||||
mod control;
|
mod control;
|
||||||
@@ -90,6 +91,7 @@ mod drive;
|
|||||||
use drive::{DriveApi, RevisionRead};
|
use drive::{DriveApi, RevisionRead};
|
||||||
mod events;
|
mod events;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
|
mod keeper;
|
||||||
mod link;
|
mod link;
|
||||||
pub use filesystem::ProtonFs;
|
pub use filesystem::ProtonFs;
|
||||||
mod mount;
|
mod mount;
|
||||||
@@ -118,13 +120,14 @@ mod workers;
|
|||||||
use background::{forget_and_notify, run_event_sync, run_local_index, run_photos_event_sync};
|
use background::{forget_and_notify, run_event_sync, run_local_index, run_photos_event_sync};
|
||||||
use link::BATCH_CALL_TIMEOUT;
|
use link::BATCH_CALL_TIMEOUT;
|
||||||
pub(crate) use mount::is_stale_mount;
|
pub(crate) use mount::is_stale_mount;
|
||||||
|
pub use mount::prepare_mountpoint;
|
||||||
pub use mount::{MountOptions, MountOutcome, mount};
|
pub use mount::{MountOptions, MountOutcome, mount};
|
||||||
use mount::{
|
use mount::{
|
||||||
SecondaryInsertRejection, SecondaryMount, clear_stale_mount, fuse_connection_id, spawn_session,
|
SecondaryInsertRejection, SecondaryMount, clear_stale_mount, fuse_connection_id, spawn_session,
|
||||||
};
|
};
|
||||||
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
|
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
|
||||||
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
|
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
|
||||||
pub use systemd::{idle_sleep, status as notify_status};
|
pub use systemd::{idle_sleep, status as notify_status, stretch_watchdog_for_startup};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
|
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
|
||||||
use r#virtual::{
|
use r#virtual::{
|
||||||
@@ -587,6 +590,8 @@ struct Core {
|
|||||||
/// Nudges the drain workers: set true and notify to have them re-examine
|
/// Nudges the drain workers: set true and notify to have them re-examine
|
||||||
/// the queue instead of waiting out their backoff.
|
/// the queue instead of waiting out their backoff.
|
||||||
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
||||||
|
/// The pins the keeper owes a check (`keeper`).
|
||||||
|
pin_work: Arc<Mutex<keeper::PinWork>>,
|
||||||
/// The daemon's stop signal, shared with every background loop this `Core`
|
/// The daemon's stop signal, shared with every background loop this `Core`
|
||||||
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
|
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
|
||||||
shutdown: Arc<shutdown::Shutdown>,
|
shutdown: Arc<shutdown::Shutdown>,
|
||||||
@@ -3163,6 +3168,28 @@ impl Core {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Core::resolve_anywhere`], widened to the Photos timeline for sharing.
|
||||||
|
///
|
||||||
|
/// A photo lives on the photos volume, which no location shows, so the
|
||||||
|
/// residency proof above never finds it. The timeline in the `photos` table
|
||||||
|
/// is this daemon's proof instead: it lists only photos on the user's own
|
||||||
|
/// volume, which are theirs to share. A photo in an album someone else
|
||||||
|
/// shared is not in it and stays refused.
|
||||||
|
pub(crate) fn resolve_shareable(&self, uid: &str) -> CoreResult<NodeUid> {
|
||||||
|
resolve_anywhere_with(
|
||||||
|
uid,
|
||||||
|
|uid| self.owns_visible_uid(uid),
|
||||||
|
|uid| {
|
||||||
|
let uid = uid.to_string();
|
||||||
|
Ok(self.db.mirror_contains_uid(&uid)?
|
||||||
|
|| !self
|
||||||
|
.db
|
||||||
|
.photos_by_uid(std::slice::from_ref(&uid))?
|
||||||
|
.is_empty())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn source_parent_uid(&self, ino: u64, rel: &Path) -> CoreResult<NodeUid> {
|
fn source_parent_uid(&self, ino: u64, rel: &Path) -> CoreResult<NodeUid> {
|
||||||
let state = self.state();
|
let state = self.state();
|
||||||
let entry = state
|
let entry = state
|
||||||
@@ -4268,7 +4295,12 @@ impl Core {
|
|||||||
/// recursive pin and downloads every descendant file on a background thread,
|
/// recursive pin and downloads every descendant file on a background thread,
|
||||||
/// reported as a job: a big folder far outlasts the socket read timeout.
|
/// reported as a job: a big folder far outlasts the socket read timeout.
|
||||||
/// Returns a human message.
|
/// Returns a human message.
|
||||||
fn pin(&self, rel: &Path) -> CoreResult<String> {
|
///
|
||||||
|
/// `recorded` is the path the pin registry keeps for the front ends to
|
||||||
|
/// open the pin by: `rel` itself on the primary mount, which they join to
|
||||||
|
/// the mountpoint, and the absolute local path on an on-demand folder's
|
||||||
|
/// mount, whose node is not reachable under the primary mountpoint at all.
|
||||||
|
fn pin(&self, rel: &Path, recorded: &Path) -> CoreResult<String> {
|
||||||
let (ino, uid) = self.resolve(rel)?;
|
let (ino, uid) = self.resolve(rel)?;
|
||||||
let (name, is_folder, mtime, size) = {
|
let (name, is_folder, mtime, size) = {
|
||||||
let st = self.state();
|
let st = self.state();
|
||||||
@@ -4292,7 +4324,7 @@ impl Core {
|
|||||||
// Record the recursive pin first so every descendant is eviction-
|
// Record the recursive pin first so every descendant is eviction-
|
||||||
// exempt before we start filling the cache with the subtree.
|
// exempt before we start filling the cache with the subtree.
|
||||||
self.cache
|
self.cache
|
||||||
.add_pin(&uid, rel, true)
|
.add_pin(&uid, recorded, true)
|
||||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
let folder = name.clone();
|
let folder = name.clone();
|
||||||
@@ -4302,9 +4334,15 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
|
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
|
||||||
return Ok(format!("{name}: downloading its files in the background"));
|
return Ok(format!("{name}: downloading its files in the background"));
|
||||||
}
|
}
|
||||||
|
// Stored under a provisional size, the copy would never match the
|
||||||
|
// plaintext it holds (`docs/BUGS.md` B282).
|
||||||
|
let size = match self.kept_file(ino) {
|
||||||
|
Some(file) if file.uid == uid => file.size,
|
||||||
|
_ => size,
|
||||||
|
};
|
||||||
self.fetch_content(&uid, &name, mtime, size)?;
|
self.fetch_content(&uid, &name, mtime, size)?;
|
||||||
self.cache
|
self.cache
|
||||||
.add_pin(&uid, rel, false)
|
.add_pin(&uid, recorded, false)
|
||||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||||
Ok(name)
|
Ok(name)
|
||||||
}
|
}
|
||||||
@@ -4322,14 +4360,20 @@ impl Core {
|
|||||||
Ok(files) => {
|
Ok(files) => {
|
||||||
job.set_total(files.len() as u64);
|
job.set_total(files.len() as u64);
|
||||||
let mut failed = 0usize;
|
let mut failed = 0usize;
|
||||||
for (uid, file, mtime, size) in files {
|
for ino in files {
|
||||||
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job.detail(file.clone());
|
// A file with a queued write, or no readable size, is
|
||||||
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
|
// left to the drain and the keeper (`keeper::kept_file`).
|
||||||
warn!(%uid, error = %e, "pin folder: download failed");
|
if let Some(file) = self.kept_file(ino) {
|
||||||
failed += 1;
|
job.detail(file.name.clone());
|
||||||
|
if let Err(e) =
|
||||||
|
self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size)
|
||||||
|
{
|
||||||
|
warn!(uid = %file.uid, error = %e, "pin folder: download failed");
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
job.step();
|
job.step();
|
||||||
}
|
}
|
||||||
@@ -4363,9 +4407,9 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every file in the subtree rooted at folder `ino`.
|
/// The inode of every file in the subtree rooted at folder `ino`.
|
||||||
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
|
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<u64>> {
|
||||||
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
|
let mut files: Vec<u64> = Vec::new();
|
||||||
let mut stack = vec![ino];
|
let mut stack = vec![ino];
|
||||||
while let Some(dir) = stack.pop() {
|
while let Some(dir) = stack.pop() {
|
||||||
if self.shutdown.is_stopping() {
|
if self.shutdown.is_stopping() {
|
||||||
@@ -4380,12 +4424,7 @@ impl Core {
|
|||||||
if e.node.is_folder() {
|
if e.node.is_folder() {
|
||||||
stack.push(k);
|
stack.push(k);
|
||||||
} else {
|
} else {
|
||||||
files.push((
|
files.push(k);
|
||||||
e.uid.clone(),
|
|
||||||
e.node.name.clone(),
|
|
||||||
e.node.modification_time,
|
|
||||||
node_size(&e.node),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6441,6 +6480,7 @@ fn sync_folder_info(
|
|||||||
) -> SyncFolderInfo {
|
) -> SyncFolderInfo {
|
||||||
SyncFolderInfo {
|
SyncFolderInfo {
|
||||||
id: f.id,
|
id: f.id,
|
||||||
|
kind: f.kind,
|
||||||
local_path: f.local_path,
|
local_path: f.local_path,
|
||||||
remote_uid: f.remote_uid,
|
remote_uid: f.remote_uid,
|
||||||
mode: f.mode,
|
mode: f.mode,
|
||||||
|
|||||||
@@ -286,6 +286,9 @@ impl Core {
|
|||||||
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
||||||
}
|
}
|
||||||
self.wake_drain();
|
self.wake_drain();
|
||||||
|
// Downloads for pins failed while the link was down, and the events
|
||||||
|
// that would have named the files are behind us (`docs/BUGS.md` B282).
|
||||||
|
self.recheck_all_pins();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait up to `budget` for the link to come back. Returns whether it is up.
|
/// Wait up to `budget` for the link to come back. Returns whether it is up.
|
||||||
|
|||||||
@@ -196,6 +196,20 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make sure `mountpoint` is a directory a mount can go on, clearing a dead
|
||||||
|
/// mount a killed daemon left there first.
|
||||||
|
///
|
||||||
|
/// A dead mount answers `ENOTCONN`, which `create_dir_all` takes for "absent";
|
||||||
|
/// it then tries `mkdir` and fails with `EEXIST`. The daemon retried that error
|
||||||
|
/// forever and never got as far as [`check_mountpoint`], which would have
|
||||||
|
/// cleared the mount (`docs/BUGS.md` B279).
|
||||||
|
pub fn prepare_mountpoint(mountpoint: &Path) -> std::io::Result<()> {
|
||||||
|
if is_stale_mount(mountpoint) {
|
||||||
|
clear_stale_mount(mountpoint);
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(mountpoint)
|
||||||
|
}
|
||||||
|
|
||||||
/// Refuse to mount My files over a folder that already has files in it: FUSE
|
/// Refuse to mount My files over a folder that already has files in it: FUSE
|
||||||
/// would hide them, unsent and out of sight, until the service stops
|
/// would hide them, unsent and out of sight, until the service stops
|
||||||
/// (`docs/BUGS.md` B277). Runs before the root fetch and before any worker
|
/// (`docs/BUGS.md` B277). Runs before the root fetch and before any worker
|
||||||
@@ -497,6 +511,7 @@ pub(crate) fn mount_with(
|
|||||||
landed: Arc::new(Mutex::new(HashMap::new())),
|
landed: Arc::new(Mutex::new(HashMap::new())),
|
||||||
unadopted: Arc::new(Mutex::new(HashMap::new())),
|
unadopted: Arc::new(Mutex::new(HashMap::new())),
|
||||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
|
pin_work: Arc::new(Mutex::new(Default::default())),
|
||||||
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
||||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||||
@@ -607,6 +622,17 @@ pub(crate) fn mount_with(
|
|||||||
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep pinned files on this computer through changes made elsewhere, and
|
||||||
|
// fetch the ones a failed or missed download left out (`docs/BUGS.md`
|
||||||
|
// B282). Its own thread: a check lists folders and downloads files.
|
||||||
|
{
|
||||||
|
let keeper_core = core.clone();
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pdfs-pin-keeper".into())
|
||||||
|
.spawn(move || keeper_core.run_pin_keeper());
|
||||||
|
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
||||||
|
}
|
||||||
|
|
||||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||||
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
||||||
if matches!(host, Host::Daemon) {
|
if matches!(host, Host::Daemon) {
|
||||||
|
|||||||
+607
-71
@@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
use std::io::{Read as _, Write as _};
|
use std::io::{Read as _, Write as _};
|
||||||
use std::os::unix::fs::OpenOptionsExt as _;
|
use std::os::unix::fs::{FileExt as _, OpenOptionsExt as _};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
@@ -20,14 +20,17 @@ use base64::Engine as _;
|
|||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
|
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
|
||||||
ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name,
|
ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name,
|
||||||
|
is_thumbnail_media_name,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::{self, StoredPhoto};
|
use pdfs_core::db::{self, StoredPhoto};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
|
||||||
|
use proton_drive_rs::proton_sdk::error::Result as DriveResult;
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType};
|
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use super::drive::RevisionRead;
|
||||||
use super::{
|
use super::{
|
||||||
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority,
|
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority,
|
||||||
node_size, now_ms, parse_uid,
|
node_size, now_ms, parse_uid,
|
||||||
@@ -58,9 +61,18 @@ const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20);
|
|||||||
static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0);
|
static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0);
|
||||||
static EXIFTOOL_MISSING_WARNING: Once = Once::new();
|
static EXIFTOOL_MISSING_WARNING: Once = Once::new();
|
||||||
static FFMPEG_MISSING_WARNING: Once = Once::new();
|
static FFMPEG_MISSING_WARNING: Once = Once::new();
|
||||||
/// Largest video downloaded whole to grab one frame for its thumbnail. The
|
/// How much of a video's start a thumbnail downloads. The first frame, and
|
||||||
/// download lands in memory, so a long 4K recording is left without one.
|
/// the one a second in, sit inside it at any common bitrate. A video is never
|
||||||
const VIDEO_THUMB_MAX_BYTES: u64 = 256 * 1024 * 1024;
|
/// downloaded whole for one frame: a recording can be many gigabytes.
|
||||||
|
const VIDEO_HEAD_BYTES: u64 = 16 * 1024 * 1024;
|
||||||
|
/// A video no larger than this is downloaded whole: its head and tail would
|
||||||
|
/// cover most of it anyway.
|
||||||
|
const VIDEO_WHOLE_MAX_BYTES: u64 = 24 * 1024 * 1024;
|
||||||
|
/// Largest MP4/MOV index (`moov` box) fetched from past the head. An hour of
|
||||||
|
/// video indexes in a few MiB; anything larger is not worth one frame.
|
||||||
|
const VIDEO_INDEX_MAX_BYTES: u64 = 32 * 1024 * 1024;
|
||||||
|
/// Top-level boxes walked looking for the index before giving up.
|
||||||
|
const VIDEO_BOX_WALK_MAX: usize = 32;
|
||||||
/// A single frame is seconds of work even for 4K; this only stops a wedged
|
/// A single frame is seconds of work even for 4K; this only stops a wedged
|
||||||
/// ffmpeg from holding a thumbnail permit.
|
/// ffmpeg from holding a thumbnail permit.
|
||||||
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
|
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
@@ -72,6 +84,105 @@ const UPLOAD_THUMB_QUALITIES: [u8; 4] = [65, 50, 35, 20];
|
|||||||
/// Largest still image read whole to render the thumbnail its upload carries.
|
/// Largest still image read whole to render the thumbnail its upload carries.
|
||||||
const UPLOAD_IMAGE_MAX_BYTES: u64 = 256 * 1024 * 1024;
|
const UPLOAD_IMAGE_MAX_BYTES: u64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// How much memory every thumbnail decode in the daemon may hold at once:
|
||||||
|
/// the encoded file plus its decoded pixels.
|
||||||
|
///
|
||||||
|
/// Without a limit, each of the 16 drain workers decoded its own full-size
|
||||||
|
/// photo. A batch of 100-megapixel JPEGs then held about 9 GiB, which swapped the
|
||||||
|
/// machine until the watchdog killed the daemon, on every restart
|
||||||
|
/// (`docs/BUGS.md` B280). With this budget, a phone photo still decodes beside
|
||||||
|
/// several others, and a 100-megapixel one decodes alone.
|
||||||
|
const DECODE_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// The process-wide [`DecodeBudget`] every thumbnail decode draws from.
|
||||||
|
static DECODE_BUDGET: DecodeBudget = DecodeBudget::new(DECODE_BUDGET_BYTES);
|
||||||
|
|
||||||
|
/// A counting gate over bytes: [`acquire`](Self::acquire) waits until the cost
|
||||||
|
/// fits beside what is already held.
|
||||||
|
struct DecodeBudget {
|
||||||
|
limit: u64,
|
||||||
|
used: std::sync::Mutex<u64>,
|
||||||
|
freed: std::sync::Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A share of a [`DecodeBudget`], given back on drop.
|
||||||
|
struct DecodePermit<'a> {
|
||||||
|
budget: &'a DecodeBudget,
|
||||||
|
cost: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecodeBudget {
|
||||||
|
const fn new(limit: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
limit,
|
||||||
|
used: std::sync::Mutex::new(0),
|
||||||
|
freed: std::sync::Condvar::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until `cost` bytes fit, and hold them. A cost above the limit is
|
||||||
|
/// cut down to the limit, so the largest image waits for an idle budget
|
||||||
|
/// and then runs alone, instead of waiting forever.
|
||||||
|
fn acquire(&self, cost: u64) -> DecodePermit<'_> {
|
||||||
|
let cost = cost.clamp(1, self.limit);
|
||||||
|
let mut used = self
|
||||||
|
.used
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
while *used + cost > self.limit {
|
||||||
|
used = self
|
||||||
|
.freed
|
||||||
|
.wait(used)
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
}
|
||||||
|
*used += cost;
|
||||||
|
DecodePermit { budget: self, cost }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DecodePermit<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut used = self
|
||||||
|
.budget
|
||||||
|
.used
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
*used -= self.cost;
|
||||||
|
self.budget.freed.notify_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What decoding an image of `encoded_len` bytes and `dimensions` costs in
|
||||||
|
/// memory: the encoded bytes, the decoded pixels at up to 4 bytes each, and a
|
||||||
|
/// second copy of the pixels when [`decode_upright`] rotates them. When the
|
||||||
|
/// header gives no dimensions, assume the image is compressed 16 to 1.
|
||||||
|
fn decode_cost(encoded_len: u64, dimensions: Option<(u32, u32)>) -> u64 {
|
||||||
|
let pixels = match dimensions {
|
||||||
|
Some((width, height)) => u64::from(width) * u64::from(height) * 4 * 2,
|
||||||
|
None => encoded_len.saturating_mul(16),
|
||||||
|
};
|
||||||
|
encoded_len.saturating_add(pixels)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pixel dimensions in the header of an encoded image, without decoding it.
|
||||||
|
fn dimensions_of(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||||
|
image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||||
|
.with_guessed_format()
|
||||||
|
.ok()?
|
||||||
|
.into_dimensions()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`dimensions_of`] for an image file. Reads only the header.
|
||||||
|
fn dimensions_of_file(path: &Path) -> Option<(u32, u32)> {
|
||||||
|
image::ImageReader::open(path)
|
||||||
|
.ok()?
|
||||||
|
.with_guessed_format()
|
||||||
|
.ok()?
|
||||||
|
.into_dimensions()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no
|
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no
|
||||||
/// pixels are decoded. `None` when the format is unknown or the header is torn.
|
/// pixels are decoded. `None` when the format is unknown or the header is torn.
|
||||||
fn ratio_of(bytes: &[u8]) -> Option<f64> {
|
fn ratio_of(bytes: &[u8]) -> Option<f64> {
|
||||||
@@ -146,8 +257,8 @@ struct RawPreviewUnavailable;
|
|||||||
/// the photo will never have a thumbnail, and only that verdict is persisted.
|
/// the photo will never have a thumbnail, and only that verdict is persisted.
|
||||||
enum ThumbAttempt {
|
enum ThumbAttempt {
|
||||||
Made(GeneratedThumb),
|
Made(GeneratedThumb),
|
||||||
/// Decoded nothing — a format this build has no decoder for, or a video
|
/// Decoded nothing: a format this build has no decoder for, a video
|
||||||
/// too large to fetch for one frame. Permanent.
|
/// ffmpeg takes no frame from, or a `.ts` that is not one. Permanent.
|
||||||
Undecodable,
|
Undecodable,
|
||||||
/// The photo couldn't be downloaded. Transient: try again next time.
|
/// The photo couldn't be downloaded. Transient: try again next time.
|
||||||
Unavailable,
|
Unavailable,
|
||||||
@@ -209,7 +320,14 @@ fn thumbnail_build_may_start(status: &ThumbnailBuildStatus, path: &str) -> CoreR
|
|||||||
/// formats return an undecodable verdict.
|
/// formats return an undecodable verdict.
|
||||||
///
|
///
|
||||||
/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool.
|
/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool.
|
||||||
|
/// Waits for its share of [`DECODE_BUDGET`] first.
|
||||||
fn scale_thumbnail(bytes: &[u8], name: &str, staging_dir: &Path) -> ScaleAttempt {
|
fn scale_thumbnail(bytes: &[u8], name: &str, staging_dir: &Path) -> ScaleAttempt {
|
||||||
|
let _permit = DECODE_BUDGET.acquire(decode_cost(bytes.len() as u64, dimensions_of(bytes)));
|
||||||
|
scale_thumbnail_unbudgeted(bytes, name, staging_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`scale_thumbnail`] for a caller that already holds its [`DecodePermit`].
|
||||||
|
fn scale_thumbnail_unbudgeted(bytes: &[u8], name: &str, staging_dir: &Path) -> ScaleAttempt {
|
||||||
scale_thumbnail_with_exiftool(
|
scale_thumbnail_with_exiftool(
|
||||||
bytes,
|
bytes,
|
||||||
name,
|
name,
|
||||||
@@ -471,21 +589,6 @@ fn warn_ffmpeg_missing() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A thumbnail from one frame of a downloaded video, which ffmpeg reads from a
|
|
||||||
/// staging copy. See [`video_file_thumbnail`].
|
|
||||||
fn video_thumbnail(
|
|
||||||
bytes: &[u8],
|
|
||||||
name: &str,
|
|
||||||
staging_dir: &Path,
|
|
||||||
ffmpeg: &OsStr,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> ScaleAttempt {
|
|
||||||
let Some(file) = RawTempFile::create_in(staging_dir, bytes, name) else {
|
|
||||||
return ScaleAttempt::Unavailable;
|
|
||||||
};
|
|
||||||
video_file_thumbnail(&file.0, ffmpeg, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A thumbnail from one frame of the video at `path`. A second in, past the
|
/// A thumbnail from one frame of the video at `path`. A second in, past the
|
||||||
/// black lead-in most clips open on, and the first frame for a clip shorter than
|
/// black lead-in most clips open on, and the first frame for a clip shorter than
|
||||||
/// that. ffmpeg applies the clip's rotation, so a portrait phone video comes out
|
/// that. ffmpeg applies the clip's rotation, so a portrait phone video comes out
|
||||||
@@ -518,6 +621,151 @@ fn video_file_thumbnail(path: &Path, ffmpeg: &OsStr, timeout: Duration) -> Scale
|
|||||||
ScaleAttempt::Undecodable
|
ScaleAttempt::Undecodable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a thumbnail is made from.
|
||||||
|
enum Fetched {
|
||||||
|
/// The thumbnail itself, from the server.
|
||||||
|
Ready(Vec<u8>),
|
||||||
|
/// Nothing: a video with no server thumbnail, and no ffmpeg to take one.
|
||||||
|
Nothing,
|
||||||
|
/// A whole image.
|
||||||
|
Whole(Vec<u8>),
|
||||||
|
Video(VideoSample),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parts of a video its thumbnail needs, downloaded: the start of the
|
||||||
|
/// file, and for an MP4 or MOV whose index is further in, that index.
|
||||||
|
struct VideoSample {
|
||||||
|
/// The whole file's length.
|
||||||
|
size: u64,
|
||||||
|
head: Vec<u8>,
|
||||||
|
/// The `moov` box and its offset, when it is not inside `head`.
|
||||||
|
index: Option<(u64, Vec<u8>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoSample {
|
||||||
|
/// Whether this could be an MPEG transport stream: one opens on the 0x47
|
||||||
|
/// sync byte, and repeats it every 188-byte packet. Only asked of `.ts`
|
||||||
|
/// files, which are TypeScript source as often as they are video.
|
||||||
|
fn is_transport_stream(&self) -> bool {
|
||||||
|
self.head.first() == Some(&0x47) && self.head.get(188).is_none_or(|&byte| byte == 0x47)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download what a thumbnail of the video `reader` reads needs. A file of up
|
||||||
|
/// to [`VIDEO_WHOLE_MAX_BYTES`] comes whole. A larger one brings its first
|
||||||
|
/// [`VIDEO_HEAD_BYTES`], which is all a stream format (Matroska, WebM, MPEG-TS,
|
||||||
|
/// AVI) needs for a frame, plus the index of an ISO media file (MP4, MOV, 3GP)
|
||||||
|
/// written with its index after the media data, which is how most cameras and
|
||||||
|
/// phones write it.
|
||||||
|
async fn sample_video(reader: &dyn RevisionRead) -> DriveResult<VideoSample> {
|
||||||
|
let size = reader.size();
|
||||||
|
if size <= VIDEO_WHOLE_MAX_BYTES {
|
||||||
|
let head = reader.read_at(0, size).await?;
|
||||||
|
return Ok(VideoSample {
|
||||||
|
size,
|
||||||
|
head,
|
||||||
|
index: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let head = reader.read_at(0, VIDEO_HEAD_BYTES).await?;
|
||||||
|
let index = match iso_media_index(reader, &head, size).await? {
|
||||||
|
Some((offset, len)) if offset.saturating_add(len) > head.len() as u64 => {
|
||||||
|
Some((offset, reader.read_at(offset, len).await?))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
Ok(VideoSample { size, head, index })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the `moov` box of an ISO media file sits, as `(offset, length)`.
|
||||||
|
/// `None` for a file that is not one, or whose index is missing, oversized or
|
||||||
|
/// past [`VIDEO_BOX_WALK_MAX`] boxes. Box headers past `head` are read one at a
|
||||||
|
/// time, 16 bytes each.
|
||||||
|
async fn iso_media_index(
|
||||||
|
reader: &dyn RevisionRead,
|
||||||
|
head: &[u8],
|
||||||
|
size: u64,
|
||||||
|
) -> DriveResult<Option<(u64, u64)>> {
|
||||||
|
const FIRST_BOXES: [&[u8; 4]; 6] = [b"ftyp", b"moov", b"mdat", b"wide", b"free", b"skip"];
|
||||||
|
if !head
|
||||||
|
.get(4..8)
|
||||||
|
.is_some_and(|kind| FIRST_BOXES.iter().any(|known| kind == *known))
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let mut offset = 0u64;
|
||||||
|
for _ in 0..VIDEO_BOX_WALK_MAX {
|
||||||
|
if offset.saturating_add(8) > size {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let header = match usize::try_from(offset)
|
||||||
|
.ok()
|
||||||
|
.and_then(|start| head.get(start..start.checked_add(16)?))
|
||||||
|
{
|
||||||
|
Some(header) => header.to_vec(),
|
||||||
|
None => reader.read_at(offset, 16).await?,
|
||||||
|
};
|
||||||
|
let Some((kind, len)) = iso_box_header(&header, size - offset) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if kind == *b"moov" {
|
||||||
|
return Ok((len <= VIDEO_INDEX_MAX_BYTES).then_some((offset, len)));
|
||||||
|
}
|
||||||
|
offset = offset.saturating_add(len);
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type and length of the ISO box whose header starts `header`, of a box
|
||||||
|
/// that may run at most `left` bytes to the end of the file.
|
||||||
|
fn iso_box_header(header: &[u8], left: u64) -> Option<([u8; 4], u64)> {
|
||||||
|
let size = u32::from_be_bytes(header.get(0..4)?.try_into().ok()?);
|
||||||
|
let kind: [u8; 4] = header.get(4..8)?.try_into().ok()?;
|
||||||
|
let len = match size {
|
||||||
|
// Runs to the end of the file.
|
||||||
|
0 => left,
|
||||||
|
// The real length follows as 64 bits.
|
||||||
|
1 => u64::from_be_bytes(header.get(8..16)?.try_into().ok()?),
|
||||||
|
len => u64::from(len),
|
||||||
|
};
|
||||||
|
(8..=left).contains(&len).then_some((kind, len))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thumbnail from one frame of a downloaded [`VideoSample`]. The sample is
|
||||||
|
/// laid out in a staging file at the offsets it came from, so ffmpeg finds an
|
||||||
|
/// index where the file's own boxes say it is. The gap between holds no data
|
||||||
|
/// and costs no disk.
|
||||||
|
fn video_sample_thumbnail(
|
||||||
|
sample: &VideoSample,
|
||||||
|
name: &str,
|
||||||
|
staging_dir: &Path,
|
||||||
|
ffmpeg: &OsStr,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> ScaleAttempt {
|
||||||
|
let transport_stream_name = Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|extension| extension.eq_ignore_ascii_case("ts"));
|
||||||
|
if transport_stream_name && !sample.is_transport_stream() {
|
||||||
|
return ScaleAttempt::Undecodable;
|
||||||
|
}
|
||||||
|
let Some(file) = RawTempFile::create_in(staging_dir, &sample.head, name) else {
|
||||||
|
return ScaleAttempt::Unavailable;
|
||||||
|
};
|
||||||
|
if let Some((offset, index)) = &sample.index {
|
||||||
|
let laid_out = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.open(&file.0)
|
||||||
|
.and_then(|staged| {
|
||||||
|
staged.write_all_at(index, *offset)?;
|
||||||
|
staged.set_len(sample.size.max(offset + index.len() as u64))
|
||||||
|
});
|
||||||
|
if laid_out.is_err() {
|
||||||
|
return ScaleAttempt::Unavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
video_file_thumbnail(&file.0, ffmpeg, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
/// The thumbnail to attach to an upload of the local file at `path`, which is
|
/// The thumbnail to attach to an upload of the local file at `path`, which is
|
||||||
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and
|
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and
|
||||||
/// the gallery has to download the whole file to make its own.
|
/// the gallery has to download the whole file to make its own.
|
||||||
@@ -544,11 +792,20 @@ fn upload_thumbnails_with(
|
|||||||
}
|
}
|
||||||
video_file_thumbnail(path, ffmpeg, FFMPEG_TIMEOUT)
|
video_file_thumbnail(path, ffmpeg, FFMPEG_TIMEOUT)
|
||||||
} else if is_thumbnail_image_name(name) {
|
} else if is_thumbnail_image_name(name) {
|
||||||
let fits = std::fs::metadata(path).is_ok_and(|meta| meta.len() <= UPLOAD_IMAGE_MAX_BYTES);
|
let Some(len) = std::fs::metadata(path)
|
||||||
let Some(bytes) = fits.then(|| std::fs::read(path).ok()).flatten() else {
|
.ok()
|
||||||
|
.map(|meta| meta.len())
|
||||||
|
.filter(|len| *len <= UPLOAD_IMAGE_MAX_BYTES)
|
||||||
|
else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
scale_thumbnail(&bytes, name, staging_dir)
|
// Taken before the read, so the encoded bytes count against the
|
||||||
|
// budget too.
|
||||||
|
let _permit = DECODE_BUDGET.acquire(decode_cost(len, dimensions_of_file(path)));
|
||||||
|
let Ok(bytes) = std::fs::read(path) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
scale_thumbnail_unbudgeted(&bytes, name, staging_dir)
|
||||||
} else {
|
} else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -770,6 +1027,7 @@ impl Core {
|
|||||||
favorite: photo.favorite,
|
favorite: photo.favorite,
|
||||||
group_size: photo.group_size as u32,
|
group_size: photo.group_size as u32,
|
||||||
has_raw: photo.has_raw,
|
has_raw: photo.has_raw,
|
||||||
|
place_id: photo.place_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1291,10 +1549,10 @@ impl Core {
|
|||||||
.map(|item| (item.uid.clone(), item.name.clone()))
|
.map(|item| (item.uid.clone(), item.name.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Proton only stores server-generated thumbnails for nodes in Photos.
|
// Drive makes no thumbnails itself; a file has one only when its
|
||||||
// These requests come exclusively from ordinary Drive, Shared and Trash
|
// uploader attached it, as this client and Proton's apps do. The
|
||||||
// views, so downloading a remote thumbnail here would be a guaranteed
|
// generation step asks for that first, and only makes one from the
|
||||||
// miss. Generate directly from the full image and cache the result.
|
// file's content without it.
|
||||||
let mut wanted = Vec::new();
|
let mut wanted = Vec::new();
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
for (raw, uid, modified) in &parsed {
|
for (raw, uid, modified) in &parsed {
|
||||||
@@ -1445,7 +1703,10 @@ impl Core {
|
|||||||
return (uid, ThumbAttempt::Cancelled);
|
return (uid, ThumbAttempt::Cancelled);
|
||||||
}
|
}
|
||||||
let video = is_video_name(&name);
|
let video = is_video_name(&name);
|
||||||
if video && !ffmpeg_available() {
|
// Without ffmpeg a video can still have its uploader's
|
||||||
|
// thumbnail, but Photos has asked the server already.
|
||||||
|
let can_sample = !video || ffmpeg_available();
|
||||||
|
if !can_sample && matches!(job, ThumbJob::Photos) {
|
||||||
return (uid, ThumbAttempt::Unavailable);
|
return (uid, ThumbAttempt::Unavailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1494,21 +1755,34 @@ impl Core {
|
|||||||
return (uid, ThumbAttempt::Cancelled);
|
return (uid, ThumbAttempt::Cancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A video is downloaded whole for one frame, so a long
|
|
||||||
// recording is not worth it — and would not fit in memory.
|
|
||||||
if video {
|
|
||||||
match core.photos().get_node(&uid).await {
|
|
||||||
Ok(Some(node)) if node_size(&node) > VIDEO_THUMB_MAX_BYTES => {
|
|
||||||
info!(%uid, size = node_size(&node), "video too large for a local thumbnail");
|
|
||||||
return (uid, ThumbAttempt::Undecodable);
|
|
||||||
}
|
|
||||||
Ok(Some(_)) => {}
|
|
||||||
Ok(None) | Err(_) => return (uid, ThumbAttempt::Unavailable),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let download_uid = uid.clone();
|
let download_uid = uid.clone();
|
||||||
let download = async move { client.download_file(&download_uid).await };
|
let drive = core.drive.clone();
|
||||||
|
let download = async move {
|
||||||
|
// Photos asked the server in one batch already. A
|
||||||
|
// file view's or a build's file may carry a
|
||||||
|
// thumbnail its uploader made, which costs one
|
||||||
|
// small download instead of its content.
|
||||||
|
if !matches!(job, ThumbJob::Photos)
|
||||||
|
&& let Some(bytes) = drive
|
||||||
|
.download_thumbnail(&download_uid, ThumbnailType::Thumbnail)
|
||||||
|
.await?
|
||||||
|
.filter(|bytes| !bytes.is_empty())
|
||||||
|
{
|
||||||
|
return Ok(Fetched::Ready(bytes));
|
||||||
|
}
|
||||||
|
if !can_sample {
|
||||||
|
return Ok(Fetched::Nothing);
|
||||||
|
}
|
||||||
|
if video {
|
||||||
|
let reader = drive.open_revision(&download_uid).await?;
|
||||||
|
sample_video(reader.as_ref()).await.map(Fetched::Video)
|
||||||
|
} else {
|
||||||
|
client
|
||||||
|
.download_file(&download_uid)
|
||||||
|
.await
|
||||||
|
.map(Fetched::Whole)
|
||||||
|
}
|
||||||
|
};
|
||||||
tokio::pin!(download);
|
tokio::pin!(download);
|
||||||
let downloaded = match job {
|
let downloaded = match job {
|
||||||
ThumbJob::Files(_) => loop {
|
ThumbJob::Files(_) => loop {
|
||||||
@@ -1546,10 +1820,10 @@ impl Core {
|
|||||||
let Some(downloaded) = downloaded else {
|
let Some(downloaded) = downloaded else {
|
||||||
return (uid, ThumbAttempt::Cancelled);
|
return (uid, ThumbAttempt::Cancelled);
|
||||||
};
|
};
|
||||||
let bytes = match downloaded {
|
let fetched = match downloaded {
|
||||||
Ok(bytes) => bytes,
|
Ok(fetched) => fetched,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(%uid, error = %e, "image download for thumbnail failed");
|
warn!(%uid, error = %e, "download for a thumbnail failed");
|
||||||
return (uid, ThumbAttempt::Unavailable);
|
return (uid, ThumbAttempt::Unavailable);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1559,18 +1833,20 @@ impl Core {
|
|||||||
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
|
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
|
||||||
// stall the runtime's worker; hand it to the blocking pool.
|
// stall the runtime's worker; hand it to the blocking pool.
|
||||||
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
|
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
|
||||||
let made = tokio::task::spawn_blocking(move || {
|
let made = tokio::task::spawn_blocking(move || match fetched {
|
||||||
if video {
|
Fetched::Video(sample) => video_sample_thumbnail(
|
||||||
video_thumbnail(
|
&sample,
|
||||||
&bytes,
|
&name,
|
||||||
&name,
|
&staging_dir,
|
||||||
&staging_dir,
|
OsStr::new("ffmpeg"),
|
||||||
OsStr::new("ffmpeg"),
|
FFMPEG_TIMEOUT,
|
||||||
FFMPEG_TIMEOUT,
|
),
|
||||||
)
|
Fetched::Whole(bytes) => scale_thumbnail(&bytes, &name, &staging_dir),
|
||||||
} else {
|
Fetched::Nothing => ScaleAttempt::Unavailable,
|
||||||
scale_thumbnail(&bytes, &name, &staging_dir)
|
Fetched::Ready(bytes) => match ratio_of(&bytes) {
|
||||||
}
|
Some(ratio) => ScaleAttempt::Made(GeneratedThumb { bytes, ratio }),
|
||||||
|
None => ScaleAttempt::Undecodable,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap_or(ScaleAttempt::Unavailable);
|
.unwrap_or(ScaleAttempt::Unavailable);
|
||||||
@@ -1754,7 +2030,7 @@ impl Core {
|
|||||||
if seen_folders.insert(entry.uid) {
|
if seen_folders.insert(entry.uid) {
|
||||||
folders.push(folder.join(entry.name));
|
folders.push(folder.join(entry.name));
|
||||||
}
|
}
|
||||||
} else if is_thumbnail_image_name(&entry.name) {
|
} else if is_thumbnail_media_name(&entry.name, Some(entry.size)) {
|
||||||
match parse_uid(&entry.uid) {
|
match parse_uid(&entry.uid) {
|
||||||
Some(uid) if seen_images.insert(uid.clone()) => {
|
Some(uid) if seen_images.insert(uid.clone()) => {
|
||||||
found_here += 1;
|
found_here += 1;
|
||||||
@@ -2116,19 +2392,83 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod decode_budget_tests {
|
||||||
|
use super::{DecodeBudget, decode_cost};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// A decode that does not fit beside the ones running waits for them,
|
||||||
|
/// instead of every drain worker decoding at once (`docs/BUGS.md` B280).
|
||||||
|
#[test]
|
||||||
|
fn a_decode_over_the_budget_waits_for_the_running_one() {
|
||||||
|
let budget = Arc::new(DecodeBudget::new(100));
|
||||||
|
let first = budget.acquire(60);
|
||||||
|
let started = Arc::new(AtomicBool::new(false));
|
||||||
|
let waiter = {
|
||||||
|
let (budget, started) = (budget.clone(), started.clone());
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _second = budget.acquire(60);
|
||||||
|
started.store(true, Ordering::SeqCst);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
assert!(
|
||||||
|
!started.load(Ordering::SeqCst),
|
||||||
|
"second decode ran over budget"
|
||||||
|
);
|
||||||
|
drop(first);
|
||||||
|
waiter.join().expect("waiter");
|
||||||
|
assert!(started.load(Ordering::SeqCst));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes that fit together run together.
|
||||||
|
#[test]
|
||||||
|
fn small_decodes_share_the_budget() {
|
||||||
|
let budget = DecodeBudget::new(100);
|
||||||
|
let _a = budget.acquire(30);
|
||||||
|
let _b = budget.acquire(30);
|
||||||
|
let _c = budget.acquire(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An image bigger than the whole budget still decodes, alone, rather than
|
||||||
|
/// waiting forever.
|
||||||
|
#[test]
|
||||||
|
fn an_image_over_the_whole_budget_still_decodes() {
|
||||||
|
let budget = DecodeBudget::new(100);
|
||||||
|
drop(budget.acquire(10_000));
|
||||||
|
let _again = budget.acquire(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cost of the 102-megapixel JPEG that set off B280 fills the real
|
||||||
|
/// budget, so it never decodes beside another one.
|
||||||
|
#[test]
|
||||||
|
fn a_hundred_megapixel_photo_costs_the_whole_budget() {
|
||||||
|
let cost = decode_cost(97_266_141, Some((11_656, 8_742)));
|
||||||
|
assert!(cost >= super::DECODE_BUDGET_BYTES);
|
||||||
|
let phone = decode_cost(4_000_000, Some((4_000, 3_000)));
|
||||||
|
assert!(phone * 4 < super::DECODE_BUDGET_BYTES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod thumb_tests {
|
mod thumb_tests {
|
||||||
use super::{
|
use super::{
|
||||||
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
|
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
|
||||||
can_make_thumbnail, exiftool_binary, ffmpeg_available, fit_upload_thumb, is_video_name,
|
VIDEO_HEAD_BYTES, VIDEO_WHOLE_MAX_BYTES, VideoSample, can_make_thumbnail, exiftool_binary,
|
||||||
ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool, similar_hashes,
|
ffmpeg_available, fit_upload_thumb, is_video_name, ratio_of, sample_video, scale_thumbnail,
|
||||||
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
scale_thumbnail_with_exiftool, similar_hashes, thumbnail_build_may_start,
|
||||||
|
upload_thumbnails_with, video_sample_thumbnail,
|
||||||
};
|
};
|
||||||
|
use crate::drive::RevisionRead;
|
||||||
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
||||||
use proton_drive_rs::ThumbnailType;
|
use proton_drive_rs::ThumbnailType;
|
||||||
|
use proton_drive_rs::proton_sdk::error::Result as DriveResult;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// A `width`×`height` JPEG, standing in for a camera photo the server never
|
/// A `width`×`height` JPEG, standing in for a camera photo the server never
|
||||||
@@ -2361,6 +2701,199 @@ mod thumb_tests {
|
|||||||
std::env::temp_dir().join(format!("pdfs-{test}-{}", std::process::id()))
|
std::env::temp_dir().join(format!("pdfs-{test}-{}", std::process::id()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A sample of a video small enough to come whole.
|
||||||
|
fn whole(bytes: &[u8]) -> VideoSample {
|
||||||
|
VideoSample {
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
head: bytes.to_vec(),
|
||||||
|
index: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_on<F: std::future::Future>(future: F) -> F::Output {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(future)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Drive revision of `size` bytes, zero except for the parts written in,
|
||||||
|
/// that counts the bytes read from it. Stands for a recording far larger
|
||||||
|
/// than a test should hold in memory.
|
||||||
|
struct FakeRevision {
|
||||||
|
size: u64,
|
||||||
|
parts: Vec<(u64, Vec<u8>)>,
|
||||||
|
read: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeRevision {
|
||||||
|
fn new(size: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
size,
|
||||||
|
parts: Vec::new(),
|
||||||
|
read: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with(mut self, offset: u64, bytes: Vec<u8>) -> Self {
|
||||||
|
self.parts.push((offset, bytes));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl RevisionRead for FakeRevision {
|
||||||
|
fn size(&self) -> u64 {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_sizes(&self) -> &[u64] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_at(&self, offset: u64, length: u64) -> DriveResult<Vec<u8>> {
|
||||||
|
let end = offset.saturating_add(length).min(self.size);
|
||||||
|
let mut out = vec![0u8; end.saturating_sub(offset) as usize];
|
||||||
|
for (start, bytes) in &self.parts {
|
||||||
|
let part_end = start + bytes.len() as u64;
|
||||||
|
let (from, to) = (offset.max(*start), end.min(part_end));
|
||||||
|
if from < to {
|
||||||
|
out[(from - offset) as usize..(to - offset) as usize]
|
||||||
|
.copy_from_slice(&bytes[(from - start) as usize..(to - start) as usize]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.read.fetch_add(out.len() as u64, Ordering::Relaxed);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ISO media box of `len` bytes, of which only the header is returned.
|
||||||
|
fn iso_box_header(kind: &[u8; 4], len: u64) -> Vec<u8> {
|
||||||
|
if let Ok(len) = u32::try_from(len) {
|
||||||
|
[&len.to_be_bytes()[..], kind].concat()
|
||||||
|
} else {
|
||||||
|
[&1u32.to_be_bytes()[..], kind, &len.to_be_bytes()[..]].concat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GIB: u64 = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_large_stream_video_downloads_its_head_alone() {
|
||||||
|
let mut head = vec![0u8; 188 * 2];
|
||||||
|
head[0] = 0x47;
|
||||||
|
head[188] = 0x47;
|
||||||
|
let revision = FakeRevision::new(9 * GIB).with(0, head);
|
||||||
|
let sample = block_on(sample_video(&revision)).unwrap();
|
||||||
|
assert_eq!(sample.size, 9 * GIB);
|
||||||
|
assert_eq!(sample.head.len() as u64, VIDEO_HEAD_BYTES);
|
||||||
|
assert!(sample.index.is_none());
|
||||||
|
assert!(sample.is_transport_stream());
|
||||||
|
assert_eq!(revision.read.load(Ordering::Relaxed), VIDEO_HEAD_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A camera MP4 keeps its index after gigabytes of media data. The sample
|
||||||
|
/// is the head, the box headers on the way, and the index itself.
|
||||||
|
#[test]
|
||||||
|
fn a_large_mp4_downloads_its_head_and_its_index() {
|
||||||
|
let ftyp = [
|
||||||
|
iso_box_header(b"ftyp", 24),
|
||||||
|
b"isom\0\0\0\0mp41isom".to_vec(),
|
||||||
|
]
|
||||||
|
.concat();
|
||||||
|
let mdat_len = 6 * GIB;
|
||||||
|
let moov_at = ftyp.len() as u64 + mdat_len;
|
||||||
|
let moov = [iso_box_header(b"moov", 4096), vec![7u8; 4088]].concat();
|
||||||
|
let size = moov_at + moov.len() as u64;
|
||||||
|
let revision = FakeRevision::new(size)
|
||||||
|
.with(0, ftyp.clone())
|
||||||
|
.with(ftyp.len() as u64, iso_box_header(b"mdat", mdat_len))
|
||||||
|
.with(moov_at, moov.clone());
|
||||||
|
|
||||||
|
let sample = block_on(sample_video(&revision)).unwrap();
|
||||||
|
assert_eq!(sample.index, Some((moov_at, moov)));
|
||||||
|
assert!(
|
||||||
|
revision.read.load(Ordering::Relaxed) <= VIDEO_HEAD_BYTES + 4096 + 16,
|
||||||
|
"read {} bytes",
|
||||||
|
revision.read.load(Ordering::Relaxed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_mp4_with_its_index_up_front_downloads_its_head_alone() {
|
||||||
|
let ftyp = [iso_box_header(b"ftyp", 16), b"isom\0\0\0\0".to_vec()].concat();
|
||||||
|
let moov = [iso_box_header(b"moov", 64), vec![1u8; 56]].concat();
|
||||||
|
let revision = FakeRevision::new(GIB)
|
||||||
|
.with(0, ftyp.clone())
|
||||||
|
.with(ftyp.len() as u64, moov)
|
||||||
|
.with(80, iso_box_header(b"mdat", GIB - 80));
|
||||||
|
let sample = block_on(sample_video(&revision)).unwrap();
|
||||||
|
assert!(sample.index.is_none());
|
||||||
|
assert_eq!(revision.read.load(Ordering::Relaxed), VIDEO_HEAD_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_small_video_downloads_whole() {
|
||||||
|
let revision = FakeRevision::new(VIDEO_WHOLE_MAX_BYTES).with(0, vec![3u8; 64]);
|
||||||
|
let sample = block_on(sample_video(&revision)).unwrap();
|
||||||
|
assert_eq!(sample.head.len() as u64, VIDEO_WHOLE_MAX_BYTES);
|
||||||
|
assert!(sample.index.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `.ts` that is TypeScript reaches ffmpeg never: the sync byte is missing,
|
||||||
|
/// and that is a final answer, not a retry.
|
||||||
|
#[test]
|
||||||
|
fn a_ts_without_the_sync_byte_is_no_video() {
|
||||||
|
let attempt = video_sample_thumbnail(
|
||||||
|
&whole(b"export const answer = 42;"),
|
||||||
|
"bundle.ts",
|
||||||
|
&std::env::temp_dir(),
|
||||||
|
OsStr::new("pdfs-ffmpeg-deliberately-absent"),
|
||||||
|
Duration::from_millis(10),
|
||||||
|
);
|
||||||
|
assert!(matches!(attempt, ScaleAttempt::Undecodable));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ffmpeg reads the index where the file's own boxes put it, so the staging
|
||||||
|
/// file has the full length and the index at its offset.
|
||||||
|
#[test]
|
||||||
|
fn the_index_is_staged_at_its_own_offset() {
|
||||||
|
let tmp = scratch("video-index");
|
||||||
|
let frame = tmp.join("frame.jpg");
|
||||||
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
std::fs::write(&frame, jpeg(640, 360)).unwrap();
|
||||||
|
let size: u64 = 3 * GIB;
|
||||||
|
let at: u64 = size - 4096;
|
||||||
|
// Arguments: -nostdin -loglevel error -ss <seek> -i <path> …
|
||||||
|
let ffmpeg = fake_ffmpeg(
|
||||||
|
&tmp,
|
||||||
|
&format!(
|
||||||
|
"[ \"$(stat -c %s \"$7\")\" = {size} ] && \
|
||||||
|
[ \"$(tail -c +{} \"$7\" | head -c 4)\" = moov ] && cat '{}'",
|
||||||
|
// The box type, after its 4-byte length; `tail` counts from 1.
|
||||||
|
at + 5,
|
||||||
|
frame.display()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let sample = VideoSample {
|
||||||
|
size,
|
||||||
|
head: vec![0u8; 1024],
|
||||||
|
index: Some((
|
||||||
|
at,
|
||||||
|
[iso_box_header(b"moov", 4096), vec![0u8; 4088]].concat(),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
let attempt = video_sample_thumbnail(
|
||||||
|
&sample,
|
||||||
|
"clip.mp4",
|
||||||
|
&tmp,
|
||||||
|
ffmpeg.as_os_str(),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&tmp).unwrap();
|
||||||
|
assert!(matches!(attempt, ScaleAttempt::Made(_)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_video_frame_becomes_a_thumbnail() {
|
fn a_video_frame_becomes_a_thumbnail() {
|
||||||
let tmp = scratch("video-frame");
|
let tmp = scratch("video-frame");
|
||||||
@@ -2369,8 +2902,8 @@ mod thumb_tests {
|
|||||||
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
|
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
|
||||||
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
|
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
|
||||||
|
|
||||||
let ScaleAttempt::Made(thumb) = video_thumbnail(
|
let ScaleAttempt::Made(thumb) = video_sample_thumbnail(
|
||||||
b"video bytes",
|
&whole(b"video bytes"),
|
||||||
"clip.mp4",
|
"clip.mp4",
|
||||||
&tmp,
|
&tmp,
|
||||||
ffmpeg.as_os_str(),
|
ffmpeg.as_os_str(),
|
||||||
@@ -2386,8 +2919,8 @@ mod thumb_tests {
|
|||||||
fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() {
|
fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() {
|
||||||
let tmp = scratch("video-broken");
|
let tmp = scratch("video-broken");
|
||||||
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
|
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
|
||||||
let attempt = video_thumbnail(
|
let attempt = video_sample_thumbnail(
|
||||||
b"not a video",
|
&whole(b"not a video"),
|
||||||
"clip.mp4",
|
"clip.mp4",
|
||||||
&tmp,
|
&tmp,
|
||||||
ffmpeg.as_os_str(),
|
ffmpeg.as_os_str(),
|
||||||
@@ -2399,8 +2932,8 @@ mod thumb_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_ffmpeg_is_retryable() {
|
fn missing_ffmpeg_is_retryable() {
|
||||||
let attempt = video_thumbnail(
|
let attempt = video_sample_thumbnail(
|
||||||
b"video bytes",
|
&whole(b"video bytes"),
|
||||||
"clip.mp4",
|
"clip.mp4",
|
||||||
&std::env::temp_dir(),
|
&std::env::temp_dir(),
|
||||||
OsStr::new("pdfs-ffmpeg-deliberately-absent"),
|
OsStr::new("pdfs-ffmpeg-deliberately-absent"),
|
||||||
@@ -2416,9 +2949,12 @@ mod thumb_tests {
|
|||||||
let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_FIXTURE");
|
let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_FIXTURE");
|
||||||
let path = std::path::PathBuf::from(path);
|
let path = std::path::PathBuf::from(path);
|
||||||
let name = path.file_name().unwrap().to_string_lossy();
|
let name = path.file_name().unwrap().to_string_lossy();
|
||||||
let bytes = std::fs::read(&path).unwrap();
|
// Through the same partial download a Drive video gets.
|
||||||
let ScaleAttempt::Made(thumb) = video_thumbnail(
|
let revision = FakeRevision::new(std::fs::read(&path).unwrap().len() as u64)
|
||||||
&bytes,
|
.with(0, std::fs::read(&path).unwrap());
|
||||||
|
let sample = block_on(sample_video(&revision)).unwrap();
|
||||||
|
let ScaleAttempt::Made(thumb) = video_sample_thumbnail(
|
||||||
|
&sample,
|
||||||
&name,
|
&name,
|
||||||
&std::env::temp_dir(),
|
&std::env::temp_dir(),
|
||||||
OsStr::new("ffmpeg"),
|
OsStr::new("ffmpeg"),
|
||||||
|
|||||||
@@ -141,19 +141,11 @@ impl Core {
|
|||||||
|
|
||||||
/// Snapshot the local arrangement into a [`Profile`].
|
/// Snapshot the local arrangement into a [`Profile`].
|
||||||
fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult<Profile> {
|
fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult<Profile> {
|
||||||
let folders = self
|
let folders = profile_folders(
|
||||||
.db
|
self.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?,
|
||||||
.into_iter()
|
);
|
||||||
.map(|f| ProfileFolder {
|
|
||||||
remote_uid: f.remote_uid,
|
|
||||||
local_path: f.local_path,
|
|
||||||
// A queued switch is where the user asked the folder to be, so
|
|
||||||
// that is what the next machine should set up.
|
|
||||||
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let pins = self
|
let pins = self
|
||||||
.db
|
.db
|
||||||
.pin_list()
|
.pin_list()
|
||||||
@@ -389,6 +381,7 @@ impl Core {
|
|||||||
items: &[pdfs_core::control::RestoreItem],
|
items: &[pdfs_core::control::RestoreItem],
|
||||||
device: Option<&str>,
|
device: Option<&str>,
|
||||||
) -> CoreResult<RestoreOutcome> {
|
) -> CoreResult<RestoreOutcome> {
|
||||||
|
let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock();
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Err(CoreError::invalid("nothing to restore"));
|
return Err(CoreError::invalid("nothing to restore"));
|
||||||
}
|
}
|
||||||
@@ -398,6 +391,11 @@ impl Core {
|
|||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
|
||||||
|
// A caller must not smuggle a My files root into a device mapping,
|
||||||
|
// where destructive device-only operations would otherwise be allowed.
|
||||||
|
let device_root = super::parse_uid(&device.root_uid)
|
||||||
|
.ok_or_else(|| CoreError::internal("invalid device root"))?;
|
||||||
|
let children = self.device_root_nodes(&device_root)?;
|
||||||
let reserved = crate::devices::sync_root_reserved();
|
let reserved = crate::devices::sync_root_reserved();
|
||||||
let mut roots: Vec<PathBuf> = existing
|
let mut roots: Vec<PathBuf> = existing
|
||||||
.iter()
|
.iter()
|
||||||
@@ -406,6 +404,18 @@ impl Core {
|
|||||||
let mut restored = 0usize;
|
let mut restored = 0usize;
|
||||||
let mut skipped: Vec<String> = Vec::new();
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
|
if !children.iter().any(|node| {
|
||||||
|
node.uid.to_string() == item.remote_uid
|
||||||
|
&& node.is_folder()
|
||||||
|
&& !node.trashed
|
||||||
|
&& node.name != PROFILE_DIR_NAME
|
||||||
|
}) {
|
||||||
|
skipped.push(format!(
|
||||||
|
"{}: not a folder under this device",
|
||||||
|
item.local_path
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// The path is proposed from the profile another machine wrote, and
|
// The path is proposed from the profile another machine wrote, and
|
||||||
// may hold a folder name from Drive: checked like any new root.
|
// may hold a folder name from Drive: checked like any new root.
|
||||||
let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) {
|
let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) {
|
||||||
@@ -468,6 +478,19 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only device backups belong in the device's cross-machine profile.
|
||||||
|
fn profile_folders(folders: Vec<pdfs_core::db::StoredSyncFolder>) -> Vec<ProfileFolder> {
|
||||||
|
folders
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.kind == "device")
|
||||||
|
.map(|f| ProfileFolder {
|
||||||
|
remote_uid: f.remote_uid,
|
||||||
|
local_path: f.local_path,
|
||||||
|
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// What a restore did: how many folders it attached, and why it left out the
|
/// What a restore did: how many folders it attached, and why it left out the
|
||||||
/// others.
|
/// others.
|
||||||
pub(crate) struct RestoreOutcome {
|
pub(crate) struct RestoreOutcome {
|
||||||
@@ -530,6 +553,21 @@ fn default_local_path(name: &str) -> PathBuf {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attached_my_files_folders_are_not_in_the_device_profile() {
|
||||||
|
let dir = crate::sim::daemon::scratch("profile-folder-kinds");
|
||||||
|
let db = pdfs_core::db::Db::open(&dir.join("cache.db")).unwrap();
|
||||||
|
db.sync_folder_add("/backup", "vol~backup", "device-share")
|
||||||
|
.unwrap();
|
||||||
|
db.sync_folder_attach("/work", "vol~work", "my-files-share")
|
||||||
|
.unwrap();
|
||||||
|
let folders = profile_folders(db.sync_folder_list().unwrap());
|
||||||
|
assert_eq!(folders.len(), 1);
|
||||||
|
assert_eq!(folders[0].remote_uid, "vol~backup");
|
||||||
|
drop(db);
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn foreign_home_is_not_reused() {
|
fn foreign_home_is_not_reused() {
|
||||||
assert!(!path_is_usable_here(Path::new(
|
assert!(!path_is_usable_here(Path::new(
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ impl Core {
|
|||||||
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
|
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
|
||||||
let metadata = PhotoUploadMetadata {
|
let metadata = PhotoUploadMetadata {
|
||||||
capture_time: Some(candidate.wanted),
|
capture_time: Some(candidate.wanted),
|
||||||
|
last_modification_time: pdfs_core::timestamp::modification_time(candidate.wanted),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let uploaded = self.rt.block_on(photos.upload_photo_from(
|
let uploaded = self.rt.block_on(photos.upload_photo_from(
|
||||||
|
|||||||
@@ -555,6 +555,7 @@ mod tests {
|
|||||||
|
|
||||||
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
|
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
|
||||||
StoredSyncFolder {
|
StoredSyncFolder {
|
||||||
|
kind: "device".into(),
|
||||||
id,
|
id,
|
||||||
local_path: local_path.to_string(),
|
local_path: local_path.to_string(),
|
||||||
remote_uid: format!("vol~root{id}"),
|
remote_uid: format!("vol~root{id}"),
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ impl Core {
|
|||||||
// stale from here on, and the server may not have applied the swap yet.
|
// stale from here on, and the server may not have applied the swap yet.
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
|
// A file kept offline gets the restored content back on disk, and
|
||||||
|
// again when the event of the swap arrives (`docs/BUGS.md` B282).
|
||||||
|
if self.cache.is_pinned(uid) {
|
||||||
|
self.recheck_pinned(uid);
|
||||||
|
}
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Restore,
|
ActivityKind::Restore,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ impl Core {
|
|||||||
role: &str,
|
role: &str,
|
||||||
message: Option<&str>,
|
message: Option<&str>,
|
||||||
) -> CoreResult<(usize, usize)> {
|
) -> CoreResult<(usize, usize)> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.share_node_for_uid(&uid, emails, role, message)
|
self.share_node_for_uid(&uid, emails, role, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ impl Core {
|
|||||||
&self,
|
&self,
|
||||||
uid: &str,
|
uid: &str,
|
||||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.list_share_for_uid(&uid)
|
self.list_share_for_uid(&uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ impl Core {
|
|||||||
kind: ShareEntryKind,
|
kind: ShareEntryKind,
|
||||||
role: &str,
|
role: &str,
|
||||||
) -> CoreResult<()> {
|
) -> CoreResult<()> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.update_share_role_for_uid(&uid, id, kind, role)
|
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ impl Core {
|
|||||||
id: &str,
|
id: &str,
|
||||||
kind: ShareEntryKind,
|
kind: ShareEntryKind,
|
||||||
) -> CoreResult<()> {
|
) -> CoreResult<()> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.remove_share_entry_for_uid(&uid, id, kind)
|
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ impl Core {
|
|||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
expires: Option<i64>,
|
expires: Option<i64>,
|
||||||
) -> CoreResult<PublicLinkInfo> {
|
) -> CoreResult<PublicLinkInfo> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.create_public_link_for_uid(&uid, role, password, expires)
|
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +391,7 @@ impl Core {
|
|||||||
password: LinkChange<String>,
|
password: LinkChange<String>,
|
||||||
expires: LinkChange<i64>,
|
expires: LinkChange<i64>,
|
||||||
) -> CoreResult<PublicLinkInfo> {
|
) -> CoreResult<PublicLinkInfo> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +460,7 @@ impl Core {
|
|||||||
/// [`Core::remove_public_link`] for a node addressed through any daemon
|
/// [`Core::remove_public_link`] for a node addressed through any daemon
|
||||||
/// location.
|
/// location.
|
||||||
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
|
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.remove_public_link_for_uid(&uid, id)
|
self.remove_public_link_for_uid(&uid, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +486,7 @@ impl Core {
|
|||||||
|
|
||||||
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
|
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
|
||||||
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
|
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.stop_sharing_for_uid(&uid)
|
self.stop_sharing_for_uid(&uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2912,6 +2912,433 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
|
||||||
|
fn attaching_my_files_preserves_local_files_and_syncs_both_ways() {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("attach-my-files");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Work").is_some()
|
||||||
|
}));
|
||||||
|
drive.device().write("Work/cloud.txt", b"from cloud");
|
||||||
|
drive.device().write("Work/common.txt", b"remote version");
|
||||||
|
drive.device().write("Work/identical.txt", b"same bytes");
|
||||||
|
let local = dir.join("local");
|
||||||
|
std::fs::create_dir(&local).unwrap();
|
||||||
|
std::fs::write(local.join("local.txt"), b"only on this computer").unwrap();
|
||||||
|
std::fs::write(local.join("common.txt"), b"local version").unwrap();
|
||||||
|
std::fs::write(local.join("identical.txt"), b"same bytes").unwrap();
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: local.to_str().unwrap().into(),
|
||||||
|
remote_path: "Work".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let folder = match daemon.request(&Request::ListSyncFolders).unwrap() {
|
||||||
|
Response::SyncFolders { items } => {
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.find(|f| f.local_path == local.to_str().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
other => panic!("unexpected reply: {other:?}"),
|
||||||
|
};
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
drive.lookup("Work/local.txt").is_some()
|
||||||
|
&& std::fs::read(local.join("cloud.txt")).is_ok_and(|b| b == b"from cloud")
|
||||||
|
&& std::fs::read(local.join("common.txt")).is_ok_and(|b| b == b"remote version")
|
||||||
|
&& !conflict_copies(&local, "common").is_empty()
|
||||||
|
}));
|
||||||
|
assert_ne!(
|
||||||
|
std::fs::metadata(&local).unwrap().dev(),
|
||||||
|
std::fs::metadata(&daemon.mountpoint).unwrap().dev()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
conflict_copies(&local, "common")
|
||||||
|
.iter()
|
||||||
|
.any(|p| std::fs::read(p).is_ok_and(|b| b == b"local version"))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: local.to_str().unwrap().into(),
|
||||||
|
remote_path: "Work".into()
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let duplicate = dir.join("duplicate");
|
||||||
|
std::fs::create_dir(&duplicate).unwrap();
|
||||||
|
assert!(
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: duplicate.to_str().unwrap().into(),
|
||||||
|
remote_path: "Work".into()
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: duplicate.to_str().unwrap().into(),
|
||||||
|
remote_path: "../Work".into()
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
// Parent/child mappings, the account root and files cannot be attached.
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("Work/Sub")).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
&& drive.lookup("Work/Sub").is_some()
|
||||||
|
}));
|
||||||
|
for remote in ["Work/Sub", ".", "Work/cloud.txt"] {
|
||||||
|
assert!(
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: duplicate.to_str().unwrap().into(),
|
||||||
|
remote_path: remote.into(),
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Local overlap and symlink aliases into the actual cloud mount are refused.
|
||||||
|
let alias = dir.join("cloud-alias");
|
||||||
|
std::os::unix::fs::symlink(daemon.mountpoint.join("Work"), &alias).unwrap();
|
||||||
|
std::fs::create_dir_all(local.join("Sub")).unwrap();
|
||||||
|
for path in [
|
||||||
|
local.join("Sub"),
|
||||||
|
dir.clone(),
|
||||||
|
daemon.mountpoint.join("Work"),
|
||||||
|
alias,
|
||||||
|
] {
|
||||||
|
let error = daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: path.to_string_lossy().into_owned(),
|
||||||
|
remote_path: "Work/Sub".into(),
|
||||||
|
})
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("overlaps"), "{error}");
|
||||||
|
}
|
||||||
|
assert!(conflict_copies(&local, "identical").is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(local.join("identical.txt")).unwrap(),
|
||||||
|
b"same bytes"
|
||||||
|
);
|
||||||
|
// Simultaneous requests cannot register two local trees for one cloud root.
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("Other")).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Other").is_some()
|
||||||
|
}));
|
||||||
|
let candidate_a = dir.join("candidate-a");
|
||||||
|
let candidate_b = dir.join("candidate-b");
|
||||||
|
std::fs::create_dir(&candidate_a).unwrap();
|
||||||
|
std::fs::create_dir(&candidate_b).unwrap();
|
||||||
|
let outcomes = std::thread::scope(|scope| {
|
||||||
|
let a = scope.spawn(|| {
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: candidate_a.to_str().unwrap().into(),
|
||||||
|
remote_path: "Other".into(),
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
});
|
||||||
|
let b = scope.spawn(|| {
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: candidate_b.to_str().unwrap().into(),
|
||||||
|
remote_path: "Other".into(),
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
});
|
||||||
|
[a.join().unwrap(), b.join().unwrap()]
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
outcomes.iter().filter(|result| result.is_ok()).count(),
|
||||||
|
1,
|
||||||
|
"exactly one mapping may own a remote root: {outcomes:?}"
|
||||||
|
);
|
||||||
|
drive
|
||||||
|
.device()
|
||||||
|
.write("Work/cloud.txt", b"new cloud revision");
|
||||||
|
daemon
|
||||||
|
.request(&Request::SyncNow { id: Some(folder) })
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || std::fs::read(
|
||||||
|
local.join("cloud.txt")
|
||||||
|
)
|
||||||
|
.is_ok_and(|b| b == b"new cloud revision")));
|
||||||
|
std::fs::write(local.join("local.txt"), b"new local revision").unwrap();
|
||||||
|
daemon
|
||||||
|
.request(&Request::SyncNow { id: Some(folder) })
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || drive
|
||||||
|
.tree()
|
||||||
|
.get("Work/local.txt")
|
||||||
|
== Some(&Entry::File(Arc::new(b"new local revision".to_vec())))));
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || listed_folder(
|
||||||
|
&daemon, folder
|
||||||
|
)
|
||||||
|
.is_some_and(|f| f.state == "idle")));
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused {
|
||||||
|
id: folder,
|
||||||
|
paused: true,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let before = drive.tree();
|
||||||
|
for entry in std::fs::read_dir(&local).unwrap() {
|
||||||
|
let path = entry.unwrap().path();
|
||||||
|
if path.is_dir() {
|
||||||
|
std::fs::remove_dir_all(path).unwrap();
|
||||||
|
} else {
|
||||||
|
std::fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused {
|
||||||
|
id: folder,
|
||||||
|
paused: false,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
daemon
|
||||||
|
.request(&Request::SyncNow { id: Some(folder) })
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || listed_folder(
|
||||||
|
&daemon, folder
|
||||||
|
)
|
||||||
|
.is_some_and(|f| f.state == "error")));
|
||||||
|
assert_eq!(
|
||||||
|
drive.tree(),
|
||||||
|
before,
|
||||||
|
"the wipe guard must preserve the remote tree"
|
||||||
|
);
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let db = Db::open(&dir.join("pdfs.db")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.sync_folder_get(folder).unwrap().unwrap().remote_share_id,
|
||||||
|
"my-files-share"
|
||||||
|
);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attached_my_files(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 {
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.drained() && drive.lookup("Work").is_some()
|
||||||
|
}));
|
||||||
|
std::fs::create_dir(local).unwrap();
|
||||||
|
daemon
|
||||||
|
.request(&Request::AttachSyncFolder {
|
||||||
|
local_path: local.to_string_lossy().into_owned(),
|
||||||
|
remote_path: "Work".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
match daemon.request(&Request::ListSyncFolders).unwrap() {
|
||||||
|
Response::SyncFolders { items } => {
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.find(|f| f.local_path == local.to_string_lossy())
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
other => panic!("unexpected reply: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
|
||||||
|
fn attached_my_files_views_preserve_concurrent_edits() {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("attach-views");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let local = dir.join("local");
|
||||||
|
let id = attached_my_files(&daemon, &drive, &local);
|
||||||
|
let mounted = daemon.mountpoint.join("Work");
|
||||||
|
// Prime the mount listing before a mirror upload, to catch stale listings.
|
||||||
|
assert_eq!(std::fs::read_dir(&mounted).unwrap().count(), 0);
|
||||||
|
std::fs::write(local.join("mirror.txt"), b"mirror bytes").unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
std::fs::read_dir(&mounted)
|
||||||
|
.unwrap()
|
||||||
|
.any(|e| e.unwrap().file_name() == "mirror.txt")
|
||||||
|
&& std::fs::read(mounted.join("mirror.txt")).is_ok_and(|b| b == b"mirror bytes")
|
||||||
|
}),
|
||||||
|
"mirror upload must reach the mounted view without a restart"
|
||||||
|
);
|
||||||
|
std::fs::write(mounted.join("mount.txt"), b"mounted bytes").unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mounted bytes")
|
||||||
|
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")
|
||||||
|
}),
|
||||||
|
"mounted writes must reach the mirror"
|
||||||
|
);
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused { id, paused: true })
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
listed_folder(&daemon, id).is_some_and(|f| f.state != "syncing")
|
||||||
|
}));
|
||||||
|
std::fs::write(mounted.join("mount.txt"), b"mount changed").unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
|
||||||
|
std::fs::write(local.join("mount.txt"), b"local changed").unwrap();
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused { id, paused: false })
|
||||||
|
.unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mount changed")
|
||||||
|
&& conflict_copies(&local, "mount")
|
||||||
|
.iter()
|
||||||
|
.any(|p| std::fs::read(p).is_ok_and(|b| b == b"local changed"))
|
||||||
|
&& drive.tree().iter().any(|(path, entry)| {
|
||||||
|
path.starts_with("Work/mount (sync-conflict")
|
||||||
|
&& entry == &Entry::File(Arc::new(b"local changed".to_vec()))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
"both edits must survive the next pass"
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = daemon
|
||||||
|
.request(&Request::SetSyncFolderMode {
|
||||||
|
id,
|
||||||
|
mode: "ondemand".into(),
|
||||||
|
})
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("only support mirror"), "{error}");
|
||||||
|
let error = daemon
|
||||||
|
.request(&Request::RemoveSyncFolder {
|
||||||
|
id,
|
||||||
|
delete_remote: true,
|
||||||
|
})
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("cannot be deleted"), "{error}");
|
||||||
|
let folder = listed_folder(&daemon, id).unwrap();
|
||||||
|
assert_eq!(folder.kind, "attached");
|
||||||
|
assert_eq!(folder.mode, "mirror");
|
||||||
|
assert!(folder.pending_mode.is_none());
|
||||||
|
// Restore must not reclassify a My files root as a device backup,
|
||||||
|
// which would make the device-only destructive operations available.
|
||||||
|
let restore_path = dir.join("forged-device-restore");
|
||||||
|
daemon
|
||||||
|
.request(&Request::RestoreSyncFolders {
|
||||||
|
items: vec![control::RestoreItem {
|
||||||
|
remote_uid: folder.remote_uid.clone(),
|
||||||
|
local_path: restore_path.to_string_lossy().into_owned(),
|
||||||
|
mode: "ondemand".into(),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
matches!(daemon.request(&Request::ListActivity { limit: 100 }),
|
||||||
|
Ok(Response::Activity { items }) if items.iter().any(|item| {
|
||||||
|
!item.ok && item.detail.contains("not a folder under this device")
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
assert!(!restore_path.exists());
|
||||||
|
match daemon.request(&Request::ListLocations).unwrap() {
|
||||||
|
Response::Locations { items } => assert!(items.iter().any(|m| {
|
||||||
|
matches!(m.kind, pdfs_core::mounts::MountKind::Attached { sync_folder_id } if sync_folder_id == id)
|
||||||
|
})),
|
||||||
|
other => panic!("unexpected reply: {other:?}"),
|
||||||
|
}
|
||||||
|
daemon
|
||||||
|
.request(&Request::RemoveSyncFolder {
|
||||||
|
id,
|
||||||
|
delete_remote: false,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(listed_folder(&daemon, id).is_none());
|
||||||
|
assert!(drive.lookup("Work/mount.txt").is_some());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(local.join("mount.txt")).unwrap(),
|
||||||
|
b"mount changed"
|
||||||
|
);
|
||||||
|
daemon.stop().unwrap();
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
|
||||||
|
fn attached_my_files_trashed_root_keeps_local_files() {
|
||||||
|
attached_root_loss_keeps_local_files(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
|
||||||
|
fn attached_my_files_root_moved_outside_my_files_keeps_local_files() {
|
||||||
|
attached_root_loss_keeps_local_files(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attached_root_loss_keeps_local_files(move_outside: bool) {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("attach-root-gone");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let local = dir.join("local");
|
||||||
|
let id = attached_my_files(&daemon, &drive, &local);
|
||||||
|
std::fs::write(local.join("keep.txt"), b"keep my bytes").unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
drive.lookup("Work/keep.txt").is_some()
|
||||||
|
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")
|
||||||
|
}));
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused { id, paused: true })
|
||||||
|
.unwrap();
|
||||||
|
if move_outside {
|
||||||
|
use crate::drive::DriveApi;
|
||||||
|
let rt = daemon.rt.as_ref().unwrap();
|
||||||
|
let device = rt
|
||||||
|
.block_on(
|
||||||
|
daemon
|
||||||
|
.client
|
||||||
|
.create_device("other computer", proton_drive_rs::DeviceType::Linux),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let root = drive.lookup("Work").unwrap();
|
||||||
|
rt.block_on(daemon.client.move_node(&root, &device.root_folder_uid))
|
||||||
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
drive.device().trash("Work");
|
||||||
|
}
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderPaused { id, paused: false })
|
||||||
|
.unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
listed_folder(&daemon, id).is_some_and(|f| f.state == "error")
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(local.join("keep.txt")).unwrap(),
|
||||||
|
b"keep my bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read_dir(&local).unwrap().count(), 1);
|
||||||
|
let daemon = daemon.restart().unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
listed_folder(&daemon, id).is_some_and(|f| f.state == "error")
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(local.join("keep.txt")).unwrap(),
|
||||||
|
b"keep my bytes"
|
||||||
|
);
|
||||||
|
daemon.stop().unwrap();
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
/// Register `local` as a synced folder in `mode` and wait until the daemon
|
/// Register `local` as a synced folder in `mode` and wait until the daemon
|
||||||
/// lists it that way, returning its id.
|
/// lists it that way, returning its id.
|
||||||
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
|
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
|
||||||
@@ -3020,6 +3447,118 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy() {
|
||||||
|
// Keeping a file offline took only paths under the primary mount, so
|
||||||
|
// the copy in an on-demand synced folder could not be kept, and a
|
||||||
|
// program reading it there downloaded it every time (B281).
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("pin-in-ondemand");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let ondemand = dir.join("ondemand");
|
||||||
|
sync_folder(&daemon, &ondemand, "ondemand");
|
||||||
|
|
||||||
|
let content = b"not a real png, but kept all the same".repeat(64);
|
||||||
|
let file = ondemand.join("photo.png");
|
||||||
|
std::fs::write(&file, &content).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
}));
|
||||||
|
daemon.request(&Request::PurgeCache).unwrap();
|
||||||
|
|
||||||
|
let path = file.canonicalize().unwrap().to_string_lossy().into_owned();
|
||||||
|
let pinned = daemon
|
||||||
|
.request(&Request::Pin { path: path.clone() })
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
|
||||||
|
let pins = || match daemon.request(&Request::ListPins) {
|
||||||
|
Ok(Response::Pins { pins }) => pins,
|
||||||
|
other => panic!("pins: {other:?}"),
|
||||||
|
};
|
||||||
|
let listed = pins();
|
||||||
|
assert!(
|
||||||
|
listed.iter().any(|pin| pin.path == path && pin.cached),
|
||||||
|
"the pin opens by the path it was made through: {listed:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// With the link gone, only a kept copy can answer the read.
|
||||||
|
daemon.client.set_online(false);
|
||||||
|
assert_eq!(std::fs::read(&file).unwrap(), content);
|
||||||
|
daemon.client.set_online(true);
|
||||||
|
|
||||||
|
let unpinned = daemon
|
||||||
|
.request(&Request::Unpin { path: path.clone() })
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(unpinned, Response::Ok { .. }), "{unpinned:?}");
|
||||||
|
assert!(pins().is_empty());
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere() {
|
||||||
|
// A change from another device deleted a pinned file's copy and
|
||||||
|
// nothing downloaded it again, and a file added to a pinned folder
|
||||||
|
// elsewhere was never downloaded: both were listed as kept offline
|
||||||
|
// and read only with a network (B282).
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("pin-keeper");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
|
||||||
|
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"first").unwrap();
|
||||||
|
std::fs::write(daemon.mountpoint.join("solo.txt"), b"alone").unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
&& drive.lookup("Docs/a.txt").is_some()
|
||||||
|
&& drive.lookup("solo.txt").is_some()
|
||||||
|
}));
|
||||||
|
daemon.request(&Request::PurgeCache).unwrap();
|
||||||
|
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
|
||||||
|
path: folder.into(),
|
||||||
|
}) {
|
||||||
|
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
for path in ["Docs", "solo.txt"] {
|
||||||
|
let pinned = daemon.request(&Request::Pin { path: path.into() }).unwrap();
|
||||||
|
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
|
||||||
|
}
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
entry("Docs", "a.txt").is_some_and(|e| e.cached)
|
||||||
|
&& entry("", "solo.txt").is_some_and(|e| e.cached)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Another device edits both files and adds one to the kept folder.
|
||||||
|
drive.device().write("Docs/a.txt", b"second, and longer");
|
||||||
|
drive.device().write("solo.txt", b"alone, edited");
|
||||||
|
drive.device().write("Docs/new.txt", b"made elsewhere");
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(60), || {
|
||||||
|
entry("Docs", "a.txt").is_some_and(|e| e.cached && e.size == 18)
|
||||||
|
&& entry("", "solo.txt").is_some_and(|e| e.cached && e.size == 13)
|
||||||
|
&& entry("Docs", "new.txt").is_some_and(|e| e.cached)
|
||||||
|
}),
|
||||||
|
"the keeper downloaded the new content: {:?} {:?} {:?}",
|
||||||
|
entry("Docs", "a.txt"),
|
||||||
|
entry("", "solo.txt"),
|
||||||
|
entry("Docs", "new.txt"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// With the link gone, only the kept copies can answer.
|
||||||
|
daemon.client.set_online(false);
|
||||||
|
let read = |rel: &str| std::fs::read(daemon.mountpoint.join(rel)).unwrap();
|
||||||
|
assert_eq!(read("Docs/a.txt"), b"second, and longer");
|
||||||
|
assert_eq!(read("solo.txt"), b"alone, edited");
|
||||||
|
assert_eq!(read("Docs/new.txt"), b"made elsewhere");
|
||||||
|
daemon.client.set_online(true);
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
/// The synced folder `id` as the daemon lists it.
|
/// The synced folder `id` as the daemon lists it.
|
||||||
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
|
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
|
||||||
match daemon.request(&Request::ListSyncFolders) {
|
match daemon.request(&Request::ListSyncFolders) {
|
||||||
@@ -3192,4 +3731,110 @@ mod tests {
|
|||||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A video in My files gets a thumbnail from a frame of its own, but a
|
||||||
|
/// recording can be gigabytes: only its start, and an MP4's index, are
|
||||||
|
/// downloaded for it. A video with a thumbnail from its uploader is not
|
||||||
|
/// downloaded at all, and a `.ts` that is TypeScript gets no thumbnail.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_video_thumbnail_downloads_a_small_part_of_the_video() {
|
||||||
|
if std::process::Command::new("ffmpeg")
|
||||||
|
.arg("-version")
|
||||||
|
.output()
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
eprintln!("skipped: needs ffmpeg");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = scratch("video-thumbs");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
// Twenty seconds at 30 Mbit/s: larger than a thumbnail downloads whole.
|
||||||
|
// An MP4 written this way has its index at the end.
|
||||||
|
let encode = |name: &str| {
|
||||||
|
let path = dir.join(name);
|
||||||
|
let status = std::process::Command::new("ffmpeg")
|
||||||
|
.args(["-nostdin", "-loglevel", "error", "-y", "-f", "lavfi"])
|
||||||
|
.args(["-i", "testsrc2=size=1280x720:rate=30", "-t", "20"])
|
||||||
|
.args(["-c:v", "libx264", "-preset", "ultrafast"])
|
||||||
|
.args(["-b:v", "30M", "-maxrate", "30M", "-bufsize", "30M"])
|
||||||
|
.arg(&path)
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success());
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
assert!(bytes.len() > 30 << 20, "{name} is {} bytes", bytes.len());
|
||||||
|
bytes
|
||||||
|
};
|
||||||
|
let (recording, clip) = (encode("recording.ts"), encode("clip.mp4"));
|
||||||
|
let source = b"export const answer = 42;\n".repeat(64 * 1024);
|
||||||
|
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
drive.device().write("recording.ts", &recording);
|
||||||
|
drive.device().write("clip.mp4", &clip);
|
||||||
|
drive.device().write("app.ts", &source);
|
||||||
|
let phone = drive.device().write("phone.mp4", &clip);
|
||||||
|
let mut frame = Vec::new();
|
||||||
|
image::codecs::jpeg::JpegEncoder::new(&mut frame)
|
||||||
|
.encode_image(&image::RgbImage::new(320, 180))
|
||||||
|
.unwrap();
|
||||||
|
let client = drive.client(1, Faults::lan());
|
||||||
|
client.set_thumbnail(&phone, frame);
|
||||||
|
let daemon = Daemon::start(&dir.join("daemon"), client.clone()).unwrap();
|
||||||
|
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir {
|
||||||
|
path: String::new(),
|
||||||
|
}) else {
|
||||||
|
panic!("listing");
|
||||||
|
};
|
||||||
|
let items: Vec<control::FileThumbRequest> = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| !entry.is_dir)
|
||||||
|
.map(|entry| control::FileThumbRequest {
|
||||||
|
uid: entry.uid.clone(),
|
||||||
|
modified: entry.modified,
|
||||||
|
name: entry.name.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let Ok(Response::FileThumbGeneration { generation }) =
|
||||||
|
daemon.request(&Request::ReserveFileThumbGeneration)
|
||||||
|
else {
|
||||||
|
panic!("generation");
|
||||||
|
};
|
||||||
|
let read_before = client.content_read();
|
||||||
|
|
||||||
|
let mut paths = std::collections::HashMap::new();
|
||||||
|
let settled = wait_until(Duration::from_secs(120), || {
|
||||||
|
let Ok(Response::Thumbs { items: replies }) = daemon.request(&Request::FileThumbs {
|
||||||
|
items: items.clone(),
|
||||||
|
generation,
|
||||||
|
}) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
paths = replies
|
||||||
|
.iter()
|
||||||
|
.filter_map(|reply| {
|
||||||
|
let name = &items.iter().find(|item| item.uid == reply.uid)?.name;
|
||||||
|
Some((name.clone(), (reply.path.clone(), reply.pending)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
paths.values().all(|(_, pending)| !pending) && paths.len() == 4
|
||||||
|
});
|
||||||
|
assert!(settled, "{paths:?}");
|
||||||
|
let read = client.content_read() - read_before;
|
||||||
|
|
||||||
|
for name in ["recording.ts", "clip.mp4", "phone.mp4"] {
|
||||||
|
assert!(paths[name].0.is_some(), "{name} has a thumbnail: {paths:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(paths["app.ts"].0, None, "TypeScript is no video");
|
||||||
|
// Two 16 MiB heads, one MP4 index and the TypeScript file, and nothing
|
||||||
|
// of the video that came with a thumbnail.
|
||||||
|
assert!(
|
||||||
|
read < 34 << 20,
|
||||||
|
"downloaded {} MiB for three thumbnails",
|
||||||
|
read >> 20
|
||||||
|
);
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ use proton_drive_rs::proton_sdk::ids::{
|
|||||||
};
|
};
|
||||||
use proton_drive_rs::{
|
use proton_drive_rs::{
|
||||||
AvailableNameTarget, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node,
|
AvailableNameTarget, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node,
|
||||||
NodeKind, NodeMoveItem, RevisionState, Thumbnail,
|
NodeKind, NodeMoveItem, RevisionState, Thumbnail, ThumbnailType,
|
||||||
};
|
};
|
||||||
use sha1::{Digest as _, Sha1};
|
use sha1::{Digest as _, Sha1};
|
||||||
|
|
||||||
@@ -396,6 +396,8 @@ impl FakeDrive {
|
|||||||
hold_answer: Mutex::new(None),
|
hold_answer: Mutex::new(None),
|
||||||
hold_trash: Mutex::new(None),
|
hold_trash: Mutex::new(None),
|
||||||
hold_download: Mutex::new(None),
|
hold_download: Mutex::new(None),
|
||||||
|
thumbnails: Mutex::new(HashMap::new()),
|
||||||
|
content_read: AtomicU64::new(0),
|
||||||
hold_any_landing: AtomicBool::new(false),
|
hold_any_landing: AtomicBool::new(false),
|
||||||
hold_landing: Mutex::new(None),
|
hold_landing: Mutex::new(None),
|
||||||
drop_after_create: AtomicBool::new(false),
|
drop_after_create: AtomicBool::new(false),
|
||||||
@@ -677,6 +679,10 @@ struct ClientInner {
|
|||||||
hold_trash: Mutex<Option<Arc<HoldState>>>,
|
hold_trash: Mutex<Option<Arc<HoldState>>>,
|
||||||
/// The download [`FakeClient::hold_next_download`] is waiting for.
|
/// The download [`FakeClient::hold_next_download`] is waiting for.
|
||||||
hold_download: Mutex<Option<Arc<HoldState>>>,
|
hold_download: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// Thumbnails set by [`FakeClient::set_thumbnail`].
|
||||||
|
thumbnails: Mutex<HashMap<NodeUid, Vec<u8>>>,
|
||||||
|
/// File content bytes served, by downloads and ranged reads alike.
|
||||||
|
content_read: AtomicU64,
|
||||||
/// The landing [`FakeClient::hold_next_landing`] is waiting for.
|
/// The landing [`FakeClient::hold_next_landing`] is waiting for.
|
||||||
hold_landing: Mutex<Option<Arc<HoldState>>>,
|
hold_landing: Mutex<Option<Arc<HoldState>>>,
|
||||||
/// Set by [`FakeClient::hold_next_landing_of_any`]: hold whichever node
|
/// Set by [`FakeClient::hold_next_landing_of_any`]: hold whichever node
|
||||||
@@ -821,6 +827,16 @@ impl FakeClient {
|
|||||||
|
|
||||||
/// Hold the next download of `uid`'s content once Drive has answered it,
|
/// Hold the next download of `uid`'s content once Drive has answered it,
|
||||||
/// before the bytes reach the daemon, until the answer is dropped.
|
/// before the bytes reach the daemon, until the answer is dropped.
|
||||||
|
/// Give `uid` a server thumbnail, as an upload that carried one would.
|
||||||
|
pub(crate) fn set_thumbnail(&self, uid: &NodeUid, bytes: Vec<u8>) {
|
||||||
|
self.0.thumbnails.lock().insert(uid.clone(), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File content bytes served so far, by whole downloads and ranged reads.
|
||||||
|
pub(crate) fn content_read(&self) -> u64 {
|
||||||
|
self.0.content_read.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn hold_next_download(&self, uid: &NodeUid) -> Held {
|
pub(crate) fn hold_next_download(&self, uid: &NodeUid) -> Held {
|
||||||
let state = Arc::new(HoldState {
|
let state = Arc::new(HoldState {
|
||||||
uid: uid.clone(),
|
uid: uid.clone(),
|
||||||
@@ -1123,6 +1139,14 @@ fn delete_one(server: &mut Server, uid: &NodeUid) -> Result<()> {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl DriveApi for FakeClient {
|
impl DriveApi for FakeClient {
|
||||||
|
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId> {
|
||||||
|
self.request(false, |server| {
|
||||||
|
assert_eq!(uid, &server.root);
|
||||||
|
Ok(ShareId::new("my-files-share"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_my_files_folder(&self) -> Result<Node> {
|
async fn get_my_files_folder(&self) -> Result<Node> {
|
||||||
self.request(false, |server| {
|
self.request(false, |server| {
|
||||||
let root = server.root.clone();
|
let root = server.root.clone();
|
||||||
@@ -1467,6 +1491,21 @@ impl DriveApi for FakeClient {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn download_thumbnail(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
_thumbnail_type: ThumbnailType,
|
||||||
|
) -> Result<Option<Vec<u8>>> {
|
||||||
|
self.request(false, |server| {
|
||||||
|
server
|
||||||
|
.current(uid)
|
||||||
|
.map(|_| ())
|
||||||
|
.ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found"))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(self.0.thumbnails.lock().get(uid).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
async fn download_file_to_dyn(
|
async fn download_file_to_dyn(
|
||||||
&self,
|
&self,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
@@ -1482,6 +1521,9 @@ impl DriveApi for FakeClient {
|
|||||||
.await?;
|
.await?;
|
||||||
self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid))
|
self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid))
|
||||||
.await;
|
.await;
|
||||||
|
self.0
|
||||||
|
.content_read
|
||||||
|
.fetch_add(content.len() as u64, Ordering::SeqCst);
|
||||||
output.write_all(&content).map_err(io_error)
|
output.write_all(&content).map_err(io_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1725,13 +1767,19 @@ impl RevisionRead for FakeRevision {
|
|||||||
|
|
||||||
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>> {
|
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>> {
|
||||||
let content = self.content.clone();
|
let content = self.content.clone();
|
||||||
self.client
|
let read = self
|
||||||
|
.client
|
||||||
.request(false, move |_| {
|
.request(false, move |_| {
|
||||||
let start = (offset as usize).min(content.len());
|
let start = (offset as usize).min(content.len());
|
||||||
let end = (offset.saturating_add(length) as usize).min(content.len());
|
let end = (offset.saturating_add(length) as usize).min(content.len());
|
||||||
Ok(content[start..end].to_vec())
|
Ok(content[start..end].to_vec())
|
||||||
})
|
})
|
||||||
.await
|
.await?;
|
||||||
|
self.client
|
||||||
|
.0
|
||||||
|
.content_read
|
||||||
|
.fetch_add(read.len() as u64, Ordering::SeqCst);
|
||||||
|
Ok(read)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ pub(crate) fn run(core: Core, control_socket: PathBuf) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut last_rss = Instant::now();
|
let mut last_rss = Instant::now();
|
||||||
let mut last_ping = Instant::now();
|
// `None` until the first probe, which runs on the first tick: startup has
|
||||||
|
// already used up much of the watchdog's time (`docs/BUGS.md` B279).
|
||||||
|
let mut last_ping: Option<Instant> = None;
|
||||||
|
let mut restored = false;
|
||||||
// Per-lane completion counts from the previous tick, to tell a queue that is
|
// Per-lane completion counts from the previous tick, to tell a queue that is
|
||||||
// being worked through from one that is not moving at all.
|
// being worked through from one that is not moving at all.
|
||||||
let mut last_completed = (0_u64, 0_u64);
|
let mut last_completed = (0_u64, 0_u64);
|
||||||
@@ -84,16 +87,22 @@ pub(crate) fn run(core: Core, control_socket: PathBuf) {
|
|||||||
// throw away in-flight uploads. The probe decides that; a stall only
|
// throw away in-flight uploads. The probe decides that; a stall only
|
||||||
// gets said out loud.
|
// gets said out loud.
|
||||||
if let Some(period) = watchdog
|
if let Some(period) = watchdog
|
||||||
&& last_ping.elapsed() >= period
|
&& ping_due(last_ping, period)
|
||||||
{
|
{
|
||||||
if probe(&control_socket) {
|
if probe(&control_socket) {
|
||||||
|
if !restored {
|
||||||
|
// Startup ran on the longer timeout the daemon asked for.
|
||||||
|
// The socket answers now, so the unit's own one applies.
|
||||||
|
systemd::restore_watchdog();
|
||||||
|
restored = true;
|
||||||
|
}
|
||||||
systemd::watchdog_ping();
|
systemd::watchdog_ping();
|
||||||
} else {
|
} else {
|
||||||
warn!(
|
warn!(
|
||||||
"daemon did not answer its own control socket; withholding the watchdog ping"
|
"daemon did not answer its own control socket; withholding the watchdog ping"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
last_ping = Instant::now();
|
last_ping = Some(Instant::now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,6 +174,11 @@ fn report_stalls(report: &pdfs_core::control::Diagnostics, last_completed: (u64,
|
|||||||
stalled
|
stalled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the watchdog wants its next ping. The first one is due at once.
|
||||||
|
fn ping_due(last_ping: Option<Instant>, period: Duration) -> bool {
|
||||||
|
last_ping.is_none_or(|at| at.elapsed() >= period)
|
||||||
|
}
|
||||||
|
|
||||||
/// One round trip over the control socket: connect, ask for the cheapest
|
/// One round trip over the control socket: connect, ask for the cheapest
|
||||||
/// request there is, read the answer.
|
/// request there is, read the answer.
|
||||||
///
|
///
|
||||||
@@ -205,6 +219,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The first ping goes out on the first tick, not a full period after the
|
||||||
|
/// supervisor starts: startup has already spent most of the watchdog's time
|
||||||
|
/// (`docs/BUGS.md` B279).
|
||||||
|
#[test]
|
||||||
|
fn first_watchdog_ping_is_due_at_once() {
|
||||||
|
let period = Duration::from_secs(60);
|
||||||
|
assert!(ping_due(None, period));
|
||||||
|
assert!(!ping_due(Some(Instant::now()), period));
|
||||||
|
let long_ago = Instant::now().checked_sub(period).expect("uptime");
|
||||||
|
assert!(ping_due(Some(long_ago), period));
|
||||||
|
}
|
||||||
|
|
||||||
/// A worker doing normal work must not be reported: a WARN every tick is a
|
/// A worker doing normal work must not be reported: a WARN every tick is a
|
||||||
/// WARN nobody reads.
|
/// WARN nobody reads.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -170,7 +170,24 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
self.progress_begin(folder.id);
|
self.progress_begin(folder.id);
|
||||||
let result = if push_only {
|
// An attachment's root can be trashed or moved by the web app. A
|
||||||
|
// missing root must never turn into a scan of an empty remote tree.
|
||||||
|
let attached_root = if current.kind == "attached" {
|
||||||
|
self.attach_ancestors(&remote_root).and_then(|ancestors| {
|
||||||
|
if ancestors.contains(&self.primary_root_uid) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(pdfs_core::CoreError::invalid(
|
||||||
|
"attached folder is no longer below My files",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
let result = if let Err(error) = attached_root {
|
||||||
|
Err(PassAbort::Failed(error.to_string()))
|
||||||
|
} else if push_only {
|
||||||
self.push_pass(folder.id, &local_root, &remote_root, &rules)
|
self.push_pass(folder.id, &local_root, &remote_root, &rules)
|
||||||
} else {
|
} else {
|
||||||
match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) {
|
match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) {
|
||||||
|
|||||||
@@ -102,6 +102,37 @@ pub fn idle_sleep(duration: Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a mount may take to start before the watchdog counts it as hung.
|
||||||
|
///
|
||||||
|
/// Startup has no control socket to probe yet, and on a large account it is
|
||||||
|
/// long: hydrating 48 000 nodes took 70 s on 2026-11-12, and the secondary
|
||||||
|
/// mounts followed. Under the 2-minute `WatchdogSec` that run was aborted just
|
||||||
|
/// before its first ping, and left six dead mounts behind (`docs/BUGS.md` B279).
|
||||||
|
const STARTUP_WATCHDOG: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
|
/// Give the mount about to start [`STARTUP_WATCHDOG`] before the watchdog fires.
|
||||||
|
///
|
||||||
|
/// Sends `WATCHDOG_USEC=`, which replaces the unit's timeout and restarts its
|
||||||
|
/// timer. The supervisor puts the configured timeout back with
|
||||||
|
/// [`restore_watchdog`] once it can prove the daemon answers. A startup that
|
||||||
|
/// wedges for good is still aborted, only later.
|
||||||
|
pub fn stretch_watchdog_for_startup() {
|
||||||
|
if watchdog_period().is_some() {
|
||||||
|
notify(&format!("WATCHDOG_USEC={}\n", STARTUP_WATCHDOG.as_micros()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put back the timeout the unit configured, undoing
|
||||||
|
/// [`stretch_watchdog_for_startup`].
|
||||||
|
pub(crate) fn restore_watchdog() {
|
||||||
|
if watchdog_period().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(usec) = std::env::var("WATCHDOG_USEC") {
|
||||||
|
notify(&format!("WATCHDOG_USEC={usec}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
|
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
|
||||||
/// for a hang by the watchdog it is racing.
|
/// for a hang by the watchdog it is racing.
|
||||||
pub(crate) fn stopping() {
|
pub(crate) fn stopping() {
|
||||||
|
|||||||
@@ -344,6 +344,9 @@ impl Core {
|
|||||||
);
|
);
|
||||||
let metadata = PhotoUploadMetadata {
|
let metadata = PhotoUploadMetadata {
|
||||||
capture_time: photo.capture_time,
|
capture_time: photo.capture_time,
|
||||||
|
last_modification_time: photo
|
||||||
|
.capture_time
|
||||||
|
.and_then(pdfs_core::timestamp::modification_time),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// A thumbnail is made from a file, so a photo that can have one
|
// A thumbnail is made from a file, so a photo that can have one
|
||||||
@@ -365,10 +368,13 @@ impl Core {
|
|||||||
let thumbnails = self.upload_thumbnails(path, &photo.name);
|
let thumbnails = self.upload_thumbnails(path, &photo.name);
|
||||||
// No sidecar and no date in the name: the photo's own
|
// No sidecar and no date in the name: the photo's own
|
||||||
// EXIF, before Drive files it under today.
|
// EXIF, before Drive files it under today.
|
||||||
|
let capture_time = metadata
|
||||||
|
.capture_time
|
||||||
|
.or_else(|| pdfs_core::exiftime::capture_time_of(path));
|
||||||
let metadata = PhotoUploadMetadata {
|
let metadata = PhotoUploadMetadata {
|
||||||
capture_time: metadata
|
capture_time,
|
||||||
.capture_time
|
last_modification_time: capture_time
|
||||||
.or_else(|| pdfs_core::exiftime::capture_time_of(path)),
|
.and_then(pdfs_core::timestamp::modification_time),
|
||||||
..metadata
|
..metadata
|
||||||
};
|
};
|
||||||
std::fs::File::open(path)
|
std::fs::File::open(path)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Album: a photo in front of the edge of another, a stack of them. -->
|
||||||
|
<path fill="#222222" d="M 4,1 H 13.5 A 1.5,1.5 0 0 1 15,2.5 V 12 H 13.5 V 2.5 H 4 Z"/>
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 2.5,4 H 10.5 A 1.5,1.5 0 0 1 12,5.5 V 13.5 A 1.5,1.5 0 0 1 10.5,15 H 2.5 A 1.5,1.5 0 0 1 1,13.5 V 5.5 A 1.5,1.5 0 0 1 2.5,4 Z M 2.5,5.5 V 13.5 H 10.5 V 5.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 3.5,12.5 L 5.5,9.2 L 7.2,11.2 L 8.2,10.2 L 9.5,12.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 550 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Photos: a framed landscape with a sun. Stock themes only have
|
||||||
|
image-x-generic, a file type icon, which reads as "an image file"
|
||||||
|
rather than "your photo library". -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 3.5,11.5 L 6.5,7 L 9,10.2 L 10.5,8.6 L 12.5,11.5 Z"/>
|
||||||
|
<circle fill="#222222" cx="10.5" cy="5.8" r="1.3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 673 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Slideshow: a play mark inside a photo frame, so it is not mistaken for
|
||||||
|
playing the video on screen. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 6.5,5.2 L 11,8 L 6.5,10.8 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 527 B |
@@ -15,6 +15,9 @@
|
|||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-photos-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-album-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-slideshow-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-synced-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-synced-symbolic.svg</file>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ button.sidebar-account { padding: 4px 8px; font-weight: normal; }
|
|||||||
/* The folder on screen, last in the breadcrumb trail: a flat button's side
|
/* The folder on screen, last in the breadcrumb trail: a flat button's side
|
||||||
* padding, so the "›" before it is spaced like the ones between buttons. */
|
* padding, so the "›" before it is spaced like the ones between buttons. */
|
||||||
.crumb-current { padding: 0 10px; }
|
.crumb-current { padding: 0 10px; }
|
||||||
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
scale.browser-status-meter trough { min-width: 140px; min-height: 6px; }
|
||||||
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
||||||
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
||||||
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
||||||
@@ -61,14 +61,27 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
|||||||
|
|
||||||
/* Photo viewer: always light-on-dark, whatever the system style */
|
/* Photo viewer: always light-on-dark, whatever the system style */
|
||||||
.photo-viewer-window { background-color: #111014; }
|
.photo-viewer-window { background-color: #111014; }
|
||||||
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }
|
.media-player-window video { background-color: black; }
|
||||||
|
.media-player-note { color: rgba(255, 255, 255, 0.5); }
|
||||||
|
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.45) 60%, rgba(0, 0, 0, 0)); padding: 10px 16px 40px 20px; color: white; }
|
||||||
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
|
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
|
||||||
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
||||||
.viewer-action-btn { color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }
|
/* Dark discs with a white glyph read on a white photo as well as on a black one. */
|
||||||
.viewer-action-btn:hover { background-color: rgba(255, 255, 255, 0.28); color: white; }
|
.viewer-action-btn { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 50%; min-width: 36px; min-height: 36px; margin-left: 4px; -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
|
||||||
|
.viewer-action-btn:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
|
||||||
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||||
.viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
|
.viewer-close-btn { margin-left: 12px; }
|
||||||
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
|
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
|
||||||
|
.viewer-group-pill { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 999px; min-height: 36px; padding: 0 14px; font-weight: 700; font-size: 0.85rem; }
|
||||||
|
.viewer-group-pill:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
|
||||||
|
.viewer-group-pill.lit { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||||
|
/* The thumbnail strip along the lightbox's bottom edge, the photo on screen ringed in the accent. */
|
||||||
|
.viewer-strip-bar { background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); padding: 28px 12px 10px 12px; }
|
||||||
|
.viewer-strip { background: none; }
|
||||||
|
.viewer-strip > row { padding: 2px; margin: 0 2px; border-radius: 8px; background: none; }
|
||||||
|
.viewer-strip > row:hover { background-color: rgba(255, 255, 255, 0.15); }
|
||||||
|
.viewer-strip > row:selected { background-color: var(--accent-bg-color); }
|
||||||
|
.viewer-strip-thumb { border-radius: 6px; background-color: rgba(255, 255, 255, 0.08); }
|
||||||
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
|
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
|
||||||
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
|
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
|
||||||
.viewer-nav-btn:disabled { opacity: 0; }
|
.viewer-nav-btn:disabled { opacity: 0; }
|
||||||
@@ -80,7 +93,12 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
|||||||
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
|
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
|
||||||
|
|
||||||
/* Photos grid */
|
/* Photos grid */
|
||||||
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 2px 2px 6px 2px; }
|
.gallery-day { font-size: 1.1rem; font-weight: 700; padding: 4px 2px 8px 2px; }
|
||||||
|
/* The check that picks a whole section: on hover, and always while selecting. */
|
||||||
|
.gallery-day-check { opacity: 0; transition: opacity 160ms ease; min-width: 24px; min-height: 24px; margin-bottom: 4px; }
|
||||||
|
.gallery-heading:hover .gallery-day-check, .gallery-heading.selecting .gallery-day-check, .gallery-day-check:focus-visible { opacity: 1; }
|
||||||
|
/* The section at the top of the grid, floating once its heading scrolled off. */
|
||||||
|
.gallery-float-heading { font-weight: 700; padding: 4px 12px; border-radius: 999px; background-color: color-mix(in srgb, var(--popover-bg-color) 92%, transparent); color: var(--popover-fg-color); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); }
|
||||||
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; }
|
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; }
|
||||||
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
|
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
|
||||||
.photo-thumb { transition: transform 220ms ease; }
|
.photo-thumb { transition: transform 220ms ease; }
|
||||||
@@ -94,6 +112,7 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
|||||||
.photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }
|
.photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }
|
||||||
.photo-video-badge-small { padding: 3px; min-width: 0; min-height: 0; }
|
.photo-video-badge-small { padding: 3px; min-width: 0; min-height: 0; }
|
||||||
.photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); }
|
.photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); }
|
||||||
|
.photo-favorite-badge { color: white; -gtk-icon-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
||||||
.photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }
|
.photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }
|
||||||
.memory-card { padding: 0; border-radius: 12px; }
|
.memory-card { padding: 0; border-radius: 12px; }
|
||||||
.memory-cover { border-radius: 12px; background: alpha(currentColor, 0.06); }
|
.memory-cover { border-radius: 12px; background: alpha(currentColor, 0.06); }
|
||||||
@@ -103,7 +122,7 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
|||||||
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
|
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
|
||||||
.album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
|
.album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
|
||||||
.album-card:hover .photo-thumb { transform: scale(1.05); }
|
.album-card:hover .photo-thumb { transform: scale(1.05); }
|
||||||
.view-switch button, .view-switch toggle { padding-left: 18px; padding-right: 18px; }
|
.view-switch button, .view-switch toggle { padding-left: 12px; padding-right: 12px; }
|
||||||
|
|
||||||
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
||||||
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
||||||
|
|||||||
@@ -128,8 +128,18 @@ fn follow_changes(ui: &Rc<Ui>, state: &FeedState, topics: &[Topic]) {
|
|||||||
if changed(Topic::Shares) {
|
if changed(Topic::Shares) {
|
||||||
refresh_invitations_badge(ui, Duration::ZERO);
|
refresh_invitations_badge(ui, Duration::ZERO);
|
||||||
}
|
}
|
||||||
match ui.stack.visible_child_name().as_deref() {
|
let page = ui.stack.visible_child_name();
|
||||||
Some("browser") if changed(Topic::Files) => load_browser(ui),
|
// A timeline out of sight is read again when the user comes back to it;
|
||||||
|
// one on screen is left alone (below).
|
||||||
|
if changed(Topic::Photos) && (page.as_deref() != Some("gallery") || !photo_grid_shown(ui)) {
|
||||||
|
ui.gallery.timeline_stale.set(true);
|
||||||
|
}
|
||||||
|
match page.as_deref() {
|
||||||
|
// A search on screen is left alone: reloading would swap its hits for
|
||||||
|
// the folder's listing while the query stays in the box.
|
||||||
|
Some("browser") if changed(Topic::Files) && ui.browser.search.text().trim().is_empty() => {
|
||||||
|
load_browser(ui)
|
||||||
|
}
|
||||||
// The timeline is left alone: reloading it would throw away the place
|
// The timeline is left alone: reloading it would throw away the place
|
||||||
// the user scrolled to. F5 reloads it on request.
|
// the user scrolled to. F5 reloads it on request.
|
||||||
Some("gallery")
|
Some("gallery")
|
||||||
|
|||||||
+125
-20
@@ -40,6 +40,8 @@ use widgets::crumb::*;
|
|||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
use widgets::file_list::*;
|
use widgets::file_list::*;
|
||||||
use widgets::menu::*;
|
use widgets::menu::*;
|
||||||
|
use widgets::photo_drag::*;
|
||||||
|
use widgets::player::*;
|
||||||
use widgets::scrubber::*;
|
use widgets::scrubber::*;
|
||||||
use widgets::share_dialog::*;
|
use widgets::share_dialog::*;
|
||||||
use widgets::thumbnails::*;
|
use widgets::thumbnails::*;
|
||||||
@@ -71,7 +73,7 @@ use gtk4::glib::BoxedAnyObject;
|
|||||||
|
|
||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
|
|
||||||
use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState};
|
use pdfs_core::config::{AppDirs, FileSort, FilesView, PhotosView, WindowState};
|
||||||
|
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
||||||
@@ -457,6 +459,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
files: browser_widgets.files.clone(),
|
files: browser_widgets.files.clone(),
|
||||||
history: RefCell::new(Vec::new()),
|
history: RefCell::new(Vec::new()),
|
||||||
future: RefCell::new(Vec::new()),
|
future: RefCell::new(Vec::new()),
|
||||||
|
places: RefCell::new(HashMap::new()),
|
||||||
|
restore: RefCell::new(None),
|
||||||
crumb: browser_widgets.crumb.clone(),
|
crumb: browser_widgets.crumb.clone(),
|
||||||
content: browser_widgets.content.clone(),
|
content: browser_widgets.content.clone(),
|
||||||
status: browser_widgets.status.clone(),
|
status: browser_widgets.status.clone(),
|
||||||
@@ -488,6 +492,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
empty_actions: browser_widgets.empty_actions.clone(),
|
empty_actions: browser_widgets.empty_actions.clone(),
|
||||||
summary: browser_widgets.summary.clone(),
|
summary: browser_widgets.summary.clone(),
|
||||||
zoom: browser_widgets.zoom.clone(),
|
zoom: browser_widgets.zoom.clone(),
|
||||||
|
zoom_save: RefCell::new(None),
|
||||||
},
|
},
|
||||||
details: DetailsState {
|
details: DetailsState {
|
||||||
details: browser_widgets.details,
|
details: browser_widgets.details,
|
||||||
@@ -522,6 +527,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
jump: Cell::new(None),
|
jump: Cell::new(None),
|
||||||
burst: Cell::new(false),
|
burst: Cell::new(false),
|
||||||
import_banner: gallery_widgets.import_banner.clone(),
|
import_banner: gallery_widgets.import_banner.clone(),
|
||||||
|
upload_bar: gallery_widgets.upload_bar.clone(),
|
||||||
upload: gallery_widgets.upload.clone(),
|
upload: gallery_widgets.upload.clone(),
|
||||||
empty_actions: gallery_widgets.empty_actions.clone(),
|
empty_actions: gallery_widgets.empty_actions.clone(),
|
||||||
title: gallery_widgets.title.clone(),
|
title: gallery_widgets.title.clone(),
|
||||||
@@ -587,10 +593,13 @@ fn build_window(app: &adw::Application) {
|
|||||||
selected: RefCell::new(HashSet::new()),
|
selected: RefCell::new(HashSet::new()),
|
||||||
select_anchor: RefCell::new(None),
|
select_anchor: RefCell::new(None),
|
||||||
select_btn: gallery_widgets.select_btn.clone(),
|
select_btn: gallery_widgets.select_btn.clone(),
|
||||||
select_bar: gallery_widgets.select_bar.clone(),
|
selection: gallery_widgets.selection.clone(),
|
||||||
select_label: gallery_widgets.select_label.clone(),
|
more: gallery_widgets.more.clone(),
|
||||||
select_trash: gallery_widgets.select_trash.clone(),
|
upload_face: gallery_widgets.upload_face.clone(),
|
||||||
select_album: gallery_widgets.select_album.clone(),
|
float_heading: gallery_widgets.float_heading.clone(),
|
||||||
|
float_label: gallery_widgets.float_label.clone(),
|
||||||
|
square: Cell::new(false),
|
||||||
|
zoom_save: RefCell::new(None),
|
||||||
},
|
},
|
||||||
shared: SharedState::new(&shared_widgets),
|
shared: SharedState::new(&shared_widgets),
|
||||||
shared_by_me: SharedByMeState::new(&shared_by_me_widgets),
|
shared_by_me: SharedByMeState::new(&shared_by_me_widgets),
|
||||||
@@ -693,7 +702,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
&ui,
|
&ui,
|
||||||
&gallery_widgets.list,
|
&gallery_widgets.list,
|
||||||
&gallery_widgets.scroll,
|
&gallery_widgets.scroll,
|
||||||
&gallery_widgets.select_done,
|
&gallery_widgets.square_action,
|
||||||
);
|
);
|
||||||
wire_gallery_empty(
|
wire_gallery_empty(
|
||||||
&ui,
|
&ui,
|
||||||
@@ -722,17 +731,26 @@ fn build_window(app: &adw::Application) {
|
|||||||
cancel_file_thumbnails(&ui_nav);
|
cancel_file_thumbnails(&ui_nav);
|
||||||
sync_sidebar(&ui_nav);
|
sync_sidebar(&ui_nav);
|
||||||
match st.visible_child_name().as_deref() {
|
match st.visible_child_name().as_deref() {
|
||||||
Some("browser") => load_browser(&ui_nav),
|
Some("browser") => reload_browser_view(&ui_nav),
|
||||||
// The Albums and Places listings are re-read in place. Loading the
|
// The Albums and Places listings are re-read in place. Loading the
|
||||||
// timeline instead would swap it in under the Albums or Places toggle.
|
// timeline instead would swap it in under the Albums or Places toggle.
|
||||||
|
// The timeline, an album or a place comes back as it was left,
|
||||||
|
// scrolled where it was, unless photos changed while it was out of
|
||||||
|
// sight (see `follow_changes`) or nothing is loaded yet.
|
||||||
Some("gallery") if !ui_nav.gallery.in_collection() => {
|
Some("gallery") if !ui_nav.gallery.in_collection() => {
|
||||||
match ui_nav.gallery.view_switch.active() {
|
match ui_nav.gallery.view_switch.active() {
|
||||||
VIEW_ALBUMS => load_albums(&ui_nav),
|
VIEW_ALBUMS => load_albums(&ui_nav),
|
||||||
VIEW_PLACES => load_places(&ui_nav),
|
VIEW_PLACES => load_places(&ui_nav),
|
||||||
_ => load_gallery(&ui_nav, false),
|
_ if ui_nav.gallery.timeline_stale.replace(false)
|
||||||
|
|| ui_nav.gallery.model.n_items() == 0 =>
|
||||||
|
{
|
||||||
|
load_gallery(&ui_nav, false)
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("gallery") => load_gallery(&ui_nav, false),
|
Some("gallery") if ui_nav.gallery.model.n_items() == 0 => load_gallery(&ui_nav, false),
|
||||||
|
Some("gallery") => {}
|
||||||
// Every visit reads the page again. The rows already on screen stay
|
// Every visit reads the page again. The rows already on screen stay
|
||||||
// up while it does, so arriving costs no "Loading…" flash.
|
// up while it does, so arriving costs no "Loading…" flash.
|
||||||
Some("sharedbyme") => load_shared_by_me(&ui_nav),
|
Some("sharedbyme") => load_shared_by_me(&ui_nav),
|
||||||
@@ -795,6 +813,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
/// split collapsed whatever the width.
|
/// split collapsed whatever the width.
|
||||||
fn set_narrow(ui: &Ui, narrow: bool) {
|
fn set_narrow(ui: &Ui, narrow: bool) {
|
||||||
ui.narrow.set(narrow);
|
ui.narrow.set(narrow);
|
||||||
|
set_photos_narrow(ui, narrow);
|
||||||
if ui.session.borrow().is_some() {
|
if ui.session.borrow().is_some() {
|
||||||
ui.nav.set_collapsed(narrow);
|
ui.nav.set_collapsed(narrow);
|
||||||
}
|
}
|
||||||
@@ -858,11 +877,7 @@ fn window_hidden(window: &adw::ApplicationWindow) -> bool {
|
|||||||
/// sync, set off by a separator.
|
/// sync, set off by a separator.
|
||||||
const DESTINATIONS: [(&str, &str, &str); 7] = [
|
const DESTINATIONS: [(&str, &str, &str); 7] = [
|
||||||
("browser", gettext_noop("My files"), "folder-symbolic"),
|
("browser", gettext_noop("My files"), "folder-symbolic"),
|
||||||
(
|
("gallery", gettext_noop("Photos"), "pdfs-photos-symbolic"),
|
||||||
"gallery",
|
|
||||||
gettext_noop("Photos"),
|
|
||||||
"image-x-generic-symbolic",
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"shared",
|
"shared",
|
||||||
gettext_noop("Shared with me"),
|
gettext_noop("Shared with me"),
|
||||||
@@ -1065,7 +1080,7 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
|||||||
match ui.stack.visible_child_name().as_deref() {
|
match ui.stack.visible_child_name().as_deref() {
|
||||||
Some("browser") => {
|
Some("browser") => {
|
||||||
let path = ui.browser.path.borrow().clone();
|
let path = ui.browser.path.borrow().clone();
|
||||||
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
refresh_then(ui, RefreshScope::Dir { path }, reload_browser_view);
|
||||||
}
|
}
|
||||||
// One scope covers the whole photos view, so Refresh reloads whichever of
|
// One scope covers the whole photos view, so Refresh reloads whichever of
|
||||||
// the views — a grid, or the timeline, an album or a place — is on screen.
|
// the views — a grid, or the timeline, an album or a place — is on screen.
|
||||||
@@ -1327,7 +1342,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
// The Albums view's New Album button.
|
// The Albums view's New Album button.
|
||||||
let new_album = gio::SimpleAction::new("new-album", None);
|
let new_album = gio::SimpleAction::new("new-album", None);
|
||||||
let ui_album = ui.clone();
|
let ui_album = ui.clone();
|
||||||
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new()));
|
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new(), None));
|
||||||
window.add_action(&new_album);
|
window.add_action(&new_album);
|
||||||
|
|
||||||
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
||||||
@@ -1505,7 +1520,7 @@ fn debug_info(ui: &Rc<Ui>) -> String {
|
|||||||
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
|
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
|
||||||
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
|
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
|
||||||
fn show_shortcuts(window: &adw::ApplicationWindow) {
|
fn show_shortcuts(window: &adw::ApplicationWindow) {
|
||||||
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
|
const GROUPS: [(&str, &[(&str, &str)]); 5] = [
|
||||||
(
|
(
|
||||||
gettext_noop("General"),
|
gettext_noop("General"),
|
||||||
&[
|
&[
|
||||||
@@ -1523,6 +1538,9 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
|
|||||||
("<Alt>Left <Alt>Right", gettext_noop("Back / forward")),
|
("<Alt>Left <Alt>Right", gettext_noop("Back / forward")),
|
||||||
("<Alt>Up", gettext_noop("Parent folder")),
|
("<Alt>Up", gettext_noop("Parent folder")),
|
||||||
("<Control>1 <Control>2", gettext_noop("Grid / list")),
|
("<Control>1 <Control>2", gettext_noop("Grid / list")),
|
||||||
|
("<Control>plus", gettext_noop("Larger thumbnails")),
|
||||||
|
("<Control>minus", gettext_noop("Smaller thumbnails")),
|
||||||
|
("<Control>0", gettext_noop("Reset thumbnail size")),
|
||||||
("F2", gettext_noop("Rename")),
|
("F2", gettext_noop("Rename")),
|
||||||
("Delete", gettext_noop("Move to Trash")),
|
("Delete", gettext_noop("Move to Trash")),
|
||||||
("Escape", gettext_noop("Clear the selection")),
|
("Escape", gettext_noop("Clear the selection")),
|
||||||
@@ -1535,6 +1553,7 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
|
|||||||
("<Control>minus", gettext_noop("Smaller thumbnails")),
|
("<Control>minus", gettext_noop("Smaller thumbnails")),
|
||||||
("<Control>0", gettext_noop("Reset thumbnail size")),
|
("<Control>0", gettext_noop("Reset thumbnail size")),
|
||||||
("<Control>a", gettext_noop("Select all")),
|
("<Control>a", gettext_noop("Select all")),
|
||||||
|
("Escape <Alt>Left", gettext_noop("Back")),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1544,8 +1563,20 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
|
|||||||
("Home End", gettext_noop("First / last photo")),
|
("Home End", gettext_noop("First / last photo")),
|
||||||
("i", gettext_noop("Show details")),
|
("i", gettext_noop("Show details")),
|
||||||
("f", gettext_noop("Fullscreen")),
|
("f", gettext_noop("Fullscreen")),
|
||||||
|
("F5", gettext_noop("Slideshow")),
|
||||||
|
("F10", gettext_noop("More")),
|
||||||
|
("<Control>c", gettext_noop("Copy Image")),
|
||||||
("Delete", gettext_noop("Move to Trash")),
|
("Delete", gettext_noop("Move to Trash")),
|
||||||
("Escape", gettext_noop("Close")),
|
("Escape <Alt>Left", gettext_noop("Close")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
gettext_noop("Video Player"),
|
||||||
|
&[
|
||||||
|
("space k", gettext_noop("Play / pause")),
|
||||||
|
("j l", gettext_noop("Back / forward 10 seconds")),
|
||||||
|
("m", gettext_noop("Mute")),
|
||||||
|
("f", gettext_noop("Fullscreen")),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -1571,8 +1602,17 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
controller.connect_key_pressed(move |_, key, _, state| {
|
controller.connect_key_pressed(move |_, key, _, state| {
|
||||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||||
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
||||||
let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
|
let page = ui.stack.visible_child_name();
|
||||||
let on_trash = ui.stack.visible_child_name().as_deref() == Some("trash");
|
let on_browser = page.as_deref() == Some("browser");
|
||||||
|
let on_trash = page.as_deref() == Some("trash");
|
||||||
|
let on_gallery = page.as_deref() == Some("gallery");
|
||||||
|
// Pages with a level to go back to, besides My files' own history.
|
||||||
|
let in_collection = on_gallery && ui.gallery.in_collection();
|
||||||
|
let in_shared_folder =
|
||||||
|
page.as_deref() == Some("shared") && !ui.shared.nav.borrow().is_empty();
|
||||||
|
let in_device_folder =
|
||||||
|
page.as_deref() == Some("devices") && !ui.devices.nav.borrow().is_empty();
|
||||||
|
let back_key = (key == gtk4::gdk::Key::Left && alt) || key == gtk4::gdk::Key::BackSpace;
|
||||||
match key {
|
match key {
|
||||||
// Refresh works on every page, so it is matched before the
|
// Refresh works on every page, so it is matched before the
|
||||||
// browser-only bindings.
|
// browser-only bindings.
|
||||||
@@ -1588,6 +1628,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
ui.browser.search.grab_focus();
|
ui.browser.search.grab_focus();
|
||||||
}
|
}
|
||||||
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
|
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
|
||||||
|
gtk4::gdk::Key::plus | gtk4::gdk::Key::equal | gtk4::gdk::Key::KP_Add
|
||||||
|
if ctrl && on_browser && !ui.browser.files.shows_list() =>
|
||||||
|
{
|
||||||
|
zoom_files(&ui, 1)
|
||||||
|
}
|
||||||
|
gtk4::gdk::Key::minus | gtk4::gdk::Key::KP_Subtract
|
||||||
|
if ctrl && on_browser && !ui.browser.files.shows_list() =>
|
||||||
|
{
|
||||||
|
zoom_files(&ui, -1)
|
||||||
|
}
|
||||||
|
gtk4::gdk::Key::_0 | gtk4::gdk::Key::KP_0
|
||||||
|
if ctrl && on_browser && !ui.browser.files.shows_list() =>
|
||||||
|
{
|
||||||
|
zoom_files(&ui, 0)
|
||||||
|
}
|
||||||
gtk4::gdk::Key::_1 | gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view(
|
gtk4::gdk::Key::_1 | gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view(
|
||||||
&ui,
|
&ui,
|
||||||
FilesView {
|
FilesView {
|
||||||
@@ -1614,6 +1669,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
gtk4::gdk::Key::BackSpace if on_browser => {
|
gtk4::gdk::Key::BackSpace if on_browser => {
|
||||||
ui.browser.actions.activate_action("back", None)
|
ui.browser.actions.activate_action("back", None)
|
||||||
}
|
}
|
||||||
|
// Selection mode ends before Escape leaves anything; the grid
|
||||||
|
// handles this itself while it has the focus.
|
||||||
|
gtk4::gdk::Key::Escape if on_gallery && ui.gallery.selecting.get() => {
|
||||||
|
set_selection_mode(&ui, false)
|
||||||
|
}
|
||||||
|
// An album or a place is a level below Photos, as a folder is
|
||||||
|
// below its parent: Back leaves it, and so does Escape.
|
||||||
|
_ if in_collection
|
||||||
|
&& !ui.gallery.selecting.get()
|
||||||
|
&& (back_key || key == gtk4::gdk::Key::Escape) =>
|
||||||
|
{
|
||||||
|
ui.gallery.back.emit_clicked()
|
||||||
|
}
|
||||||
|
_ if in_shared_folder && back_key => shared_back(&ui),
|
||||||
|
_ if in_device_folder && back_key => devices_back(&ui),
|
||||||
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
|
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
|
||||||
gtk4::gdk::Key::Menu if visible_file_list(&ui).is_some() => {
|
gtk4::gdk::Key::Menu if visible_file_list(&ui).is_some() => {
|
||||||
if let Some(list) = visible_file_list(&ui) {
|
if let Some(list) = visible_file_list(&ui) {
|
||||||
@@ -1955,6 +2025,7 @@ mod tests {
|
|||||||
favorite: false,
|
favorite: false,
|
||||||
group_size: 2,
|
group_size: 2,
|
||||||
has_raw: kind == PhotoKind::Raw,
|
has_raw: kind == PhotoKind::Raw,
|
||||||
|
place_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1977,6 +2048,40 @@ mod tests {
|
|||||||
assert!(next_group_member(&[], "jpeg").is_none());
|
assert!(next_group_member(&[], "jpeg").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The upload bar counts the upload under way, not the ones finished.
|
||||||
|
#[test]
|
||||||
|
fn the_upload_bar_counts_the_upload_under_way() {
|
||||||
|
assert_eq!(upload_progress_text(0, 12), "Uploading 1 of 12 items…");
|
||||||
|
assert_eq!(upload_progress_text(11, 12), "Uploading 12 of 12 items…");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pill names what kind of shot it switches through, and lights up
|
||||||
|
/// on the file that is not the shot's main one.
|
||||||
|
#[test]
|
||||||
|
fn the_group_pill_names_the_kind_of_shot() {
|
||||||
|
let pair = [
|
||||||
|
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
|
||||||
|
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
|
||||||
|
];
|
||||||
|
assert_eq!(group_pill(&pair, "jpeg"), Some(("RAW".into(), false)));
|
||||||
|
assert_eq!(group_pill(&pair, "raw"), Some(("RAW".into(), true)));
|
||||||
|
|
||||||
|
let live = [
|
||||||
|
member("still", Some("IMG_2.HEIC"), PhotoKind::Photo),
|
||||||
|
member("clip", Some("IMG_2.MOV"), PhotoKind::Video),
|
||||||
|
];
|
||||||
|
assert_eq!(group_pill(&live, "still"), Some(("Live".into(), false)));
|
||||||
|
assert_eq!(group_pill(&live, "clip"), Some(("Live".into(), true)));
|
||||||
|
|
||||||
|
let burst = [
|
||||||
|
member("a", None, PhotoKind::Photo),
|
||||||
|
member("b", None, PhotoKind::Photo),
|
||||||
|
member("c", None, PhotoKind::Photo),
|
||||||
|
];
|
||||||
|
assert_eq!(group_pill(&burst, "b"), Some(("2 of 3".into(), false)));
|
||||||
|
assert_eq!(group_pill(&burst, "elsewhere"), None);
|
||||||
|
}
|
||||||
|
|
||||||
/// The switch names what it will show; a file whose name the daemon has not
|
/// The switch names what it will show; a file whose name the daemon has not
|
||||||
/// resolved still gets a sentence rather than a blank.
|
/// resolved still gets a sentence rather than a blank.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
|||||||
if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") {
|
if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") {
|
||||||
albums_status(
|
albums_status(
|
||||||
ui,
|
ui,
|
||||||
"view-grid-symbolic",
|
"pdfs-album-symbolic",
|
||||||
&gettext("Loading albums…"),
|
&gettext("Loading albums…"),
|
||||||
&gettext("Reading your Proton Drive albums."),
|
&gettext("Reading your Proton Drive albums."),
|
||||||
);
|
);
|
||||||
@@ -62,7 +62,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
|||||||
),
|
),
|
||||||
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
|
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
|
||||||
&ui,
|
&ui,
|
||||||
"view-grid-symbolic",
|
"pdfs-album-symbolic",
|
||||||
&gettext("No albums"),
|
&gettext("No albums"),
|
||||||
&gettext("Create one with New Album, or in Proton Photos."),
|
&gettext("Create one with New Album, or in Proton Photos."),
|
||||||
),
|
),
|
||||||
@@ -136,7 +136,7 @@ fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
|
|||||||
picture.add_css_class("photo-thumb");
|
picture.add_css_class("photo-thumb");
|
||||||
|
|
||||||
let placeholder = gtk4::Image::builder()
|
let placeholder = gtk4::Image::builder()
|
||||||
.icon_name("view-grid-symbolic")
|
.icon_name("pdfs-album-symbolic")
|
||||||
.pixel_size(32)
|
.pixel_size(32)
|
||||||
.halign(gtk4::Align::Center)
|
.halign(gtk4::Align::Center)
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
@@ -225,12 +225,13 @@ fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: >k4::Widget, x: f64
|
|||||||
/// while the entry is blank.
|
/// while the entry is blank.
|
||||||
fn prompt_album_name(
|
fn prompt_album_name(
|
||||||
ui: &Rc<Ui>,
|
ui: &Rc<Ui>,
|
||||||
|
parent: Option<gtk4::Window>,
|
||||||
heading: &str,
|
heading: &str,
|
||||||
action: &str,
|
action: &str,
|
||||||
current: &str,
|
current: &str,
|
||||||
on_name: impl Fn(String) + 'static,
|
on_name: impl Fn(String) + 'static,
|
||||||
) {
|
) {
|
||||||
let win = ui_window(ui);
|
let win = parent.or_else(|| ui_window(ui));
|
||||||
let dialog = adw::AlertDialog::builder().heading(heading).build();
|
let dialog = adw::AlertDialog::builder().heading(heading).build();
|
||||||
let group = adw::PreferencesGroup::new();
|
let group = adw::PreferencesGroup::new();
|
||||||
let row = adw::EntryRow::builder()
|
let row = adw::EntryRow::builder()
|
||||||
@@ -260,10 +261,12 @@ fn prompt_album_name(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Ask for a name and create an album, then add `photos` to it, if any.
|
/// Ask for a name and create an album, then add `photos` to it, if any.
|
||||||
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
///
|
||||||
|
/// The dialogs open over `parent`, or over the main window without one.
|
||||||
|
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
|
||||||
let ui_c = ui.clone();
|
let ui_c = ui.clone();
|
||||||
let (heading, action) = (gettext("New Album"), gettext("Create"));
|
let (heading, action) = (gettext("New Album"), gettext("Create"));
|
||||||
prompt_album_name(ui, &heading, &action, "", move |name| {
|
prompt_album_name(ui, parent, &heading, &action, "", move |name| {
|
||||||
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
|
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
|
||||||
let ui = ui_c.clone();
|
let ui = ui_c.clone();
|
||||||
let photos = photos.clone();
|
let photos = photos.clone();
|
||||||
@@ -296,7 +299,7 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
|||||||
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||||
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||||
let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
|
let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
|
||||||
prompt_album_name(ui, &heading, &action, &album.name, move |name| {
|
prompt_album_name(ui, None, &heading, &action, &album.name, move |name| {
|
||||||
run_album_request(
|
run_album_request(
|
||||||
&ui_c,
|
&ui_c,
|
||||||
Request::RenameAlbum {
|
Request::RenameAlbum {
|
||||||
@@ -368,7 +371,7 @@ fn reload_album_grid(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Offer our own albums to add `photos` to, plus a new one.
|
/// Offer our own albums to add `photos` to, plus a new one.
|
||||||
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
@@ -390,7 +393,7 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if albums.is_empty() {
|
if albums.is_empty() {
|
||||||
prompt_new_album(&ui, photos);
|
prompt_new_album(&ui, photos, parent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,11 +439,11 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
|||||||
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
|
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let ui_c = ui.clone();
|
let (ui_c, parent_c) = (ui.clone(), parent.clone());
|
||||||
dialog.connect_response(Some("new"), move |_, _| {
|
dialog.connect_response(Some("new"), move |_, _| {
|
||||||
prompt_new_album(&ui_c, photos.clone())
|
prompt_new_album(&ui_c, photos.clone(), parent_c.clone())
|
||||||
});
|
});
|
||||||
dialog.present(ui_window(&ui).as_ref());
|
dialog.present(parent.or_else(|| ui_window(&ui)).as_ref());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,10 +598,7 @@ pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
|||||||
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
|
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery
|
ui.gallery.await_thumb(&uid, picture);
|
||||||
.thumb_wanted
|
|
||||||
.borrow_mut()
|
|
||||||
.insert(uid.clone(), picture.clone());
|
|
||||||
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
||||||
if !queue.contains(&uid) {
|
if !queue.contains(&uid) {
|
||||||
queue.push_back(uid);
|
queue.push_back(uid);
|
||||||
@@ -618,9 +618,7 @@ pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
|||||||
// would silently do something else. The Photos/Albums switcher goes too: an
|
// would silently do something else. The Photos/Albums switcher goes too: an
|
||||||
// album is a level below it, and back is the way out.
|
// album is a level below it, and back is the way out.
|
||||||
ui.gallery.filters.set_visible(false);
|
ui.gallery.filters.set_visible(false);
|
||||||
ui.gallery.view_switch.widget().set_visible(false);
|
sync_header(ui);
|
||||||
ui.gallery.upload.set_visible(false);
|
|
||||||
ui.gallery.back.set_visible(true);
|
|
||||||
// A stale kind/date filter would otherwise be sent with the album request.
|
// A stale kind/date filter would otherwise be sent with the album request.
|
||||||
ui.gallery.kind.set(None);
|
ui.gallery.kind.set(None);
|
||||||
ui.gallery.range.set(None);
|
ui.gallery.range.set(None);
|
||||||
@@ -636,9 +634,7 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.title.set_title(&gettext("Photos"));
|
ui.gallery.title.set_title(&gettext("Photos"));
|
||||||
ui.gallery.view_switch.widget().set_visible(true);
|
sync_header(ui);
|
||||||
ui.gallery.upload.set_visible(true);
|
|
||||||
ui.gallery.back.set_visible(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the album grid's own status page (loading, empty, or an error).
|
/// Show the album grid's own status page (loading, empty, or an error).
|
||||||
@@ -668,12 +664,25 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
close_album(&ui_photos);
|
close_album(&ui_photos);
|
||||||
close_place(&ui_photos);
|
close_place(&ui_photos);
|
||||||
ui_photos.gallery.filters.set_visible(true);
|
let gallery = &ui_photos.gallery;
|
||||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
gallery.filters.set_visible(true);
|
||||||
|
gallery.content.set_visible_child_name("timeline");
|
||||||
|
// An album or a place cleared the kind and the month it does not use;
|
||||||
|
// the filter controls still show them, so the timeline comes back
|
||||||
|
// filtered the way they say.
|
||||||
|
let kind = kind_for_tab(gallery.tabs.active() as usize);
|
||||||
|
let range = gallery
|
||||||
|
.date_ranges
|
||||||
|
.borrow()
|
||||||
|
.get(gallery.dates.selected() as usize)
|
||||||
|
.copied()
|
||||||
|
.flatten();
|
||||||
|
let kind_changed = gallery.kind.replace(kind) != kind;
|
||||||
|
let range_changed = gallery.range.replace(range) != range;
|
||||||
|
let refiltered = kind_changed || range_changed;
|
||||||
// The timeline comes back as it was left unless an album took over the
|
// The timeline comes back as it was left unless an album took over the
|
||||||
// model since; then it has to be reloaded.
|
// model since; then it has to be reloaded.
|
||||||
if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0
|
if gallery.timeline_stale.replace(false) || refiltered || gallery.model.n_items() == 0 {
|
||||||
{
|
|
||||||
load_gallery(&ui_photos, false);
|
load_gallery(&ui_photos, false);
|
||||||
} else {
|
} else {
|
||||||
update_gallery_subtitle(&ui_photos);
|
update_gallery_subtitle(&ui_photos);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within};
|
use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within};
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
|
/// How a folder in My files was left: scrolled `offset` px down, with the
|
||||||
|
/// entries named `selected` highlighted.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
|
pub(crate) struct FolderPlace {
|
||||||
|
pub(crate) offset: f64,
|
||||||
|
pub(crate) selected: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct BrowserState {
|
pub(crate) struct BrowserState {
|
||||||
// Files (browser) page.
|
// Files (browser) page.
|
||||||
/// The grid and list; the model is repopulated per directory.
|
/// The grid and list; the model is repopulated per directory.
|
||||||
@@ -10,6 +18,13 @@ pub(crate) struct BrowserState {
|
|||||||
/// breadcrumb.
|
/// breadcrumb.
|
||||||
pub(crate) history: RefCell<Vec<String>>,
|
pub(crate) history: RefCell<Vec<String>>,
|
||||||
pub(crate) future: RefCell<Vec<String>>,
|
pub(crate) future: RefCell<Vec<String>>,
|
||||||
|
/// Where each folder left by Back, Forward, Up or a descent was scrolled
|
||||||
|
/// and what was selected in it, by path. Back, Forward and Up put the
|
||||||
|
/// folder back that way; opening it afresh starts at the top.
|
||||||
|
pub(crate) places: RefCell<HashMap<String, FolderPlace>>,
|
||||||
|
/// The folder whose [`Self::places`] entry the next listing restores, set
|
||||||
|
/// by Back, Forward and Up.
|
||||||
|
pub(crate) restore: RefCell<Option<String>>,
|
||||||
/// Clickable breadcrumb trail (a button per path segment); rebuilt per load
|
/// Clickable breadcrumb trail (a button per path segment); rebuilt per load
|
||||||
/// by [`repaint_crumb`] so each ancestor folder navigates on click.
|
/// by [`repaint_crumb`] so each ancestor folder navigates on click.
|
||||||
pub(crate) crumb: gtk4::Box,
|
pub(crate) crumb: gtk4::Box,
|
||||||
@@ -77,6 +92,8 @@ pub(crate) struct BrowserState {
|
|||||||
/// zoom value, and Proton account storage usage.
|
/// zoom value, and Proton account storage usage.
|
||||||
pub(crate) summary: gtk4::Label,
|
pub(crate) summary: gtk4::Label,
|
||||||
pub(crate) zoom: gtk4::Scale,
|
pub(crate) zoom: gtk4::Scale,
|
||||||
|
/// Saves the zoom once the slider rests, not on every step.
|
||||||
|
pub(crate) zoom_save: RefCell<Option<glib::SourceId>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Idle pause after the last keystroke before a search query is sent, so typing
|
/// Idle pause after the last keystroke before a search query is sent, so typing
|
||||||
@@ -87,9 +104,11 @@ pub(crate) const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250);
|
|||||||
pub(crate) const SEARCH_LIMIT: usize = 200;
|
pub(crate) const SEARCH_LIMIT: usize = 200;
|
||||||
|
|
||||||
/// Browser grid thumbnail size controlled by the bottom zoom slider. The
|
/// Browser grid thumbnail size controlled by the bottom zoom slider. The
|
||||||
/// default preserves the former fixed 72 px presentation.
|
/// default preserves the former fixed 72 px presentation. The largest size is
|
||||||
|
/// big enough to tell photos apart by their thumbnail, and thumbnails are
|
||||||
|
/// 512 px, so they stay sharp up to it on a 2× screen.
|
||||||
pub(crate) const GRID_THUMB_MIN: i32 = 48;
|
pub(crate) const GRID_THUMB_MIN: i32 = 48;
|
||||||
pub(crate) const GRID_THUMB_MAX: i32 = 144;
|
pub(crate) const GRID_THUMB_MAX: i32 = 256;
|
||||||
pub(crate) const GRID_THUMB_DEFAULT: i32 = 72;
|
pub(crate) const GRID_THUMB_DEFAULT: i32 = 72;
|
||||||
pub(crate) const GRID_THUMB_STEP: i32 = 8;
|
pub(crate) const GRID_THUMB_STEP: i32 = 8;
|
||||||
|
|
||||||
@@ -801,7 +820,35 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>) {
|
|||||||
&[],
|
&[],
|
||||||
)));
|
)));
|
||||||
ui_zoom.browser.files.set_thumbnail_size(size);
|
ui_zoom.browser.files.set_thumbnail_size(size);
|
||||||
|
save_icon_size(&ui_zoom, size);
|
||||||
});
|
});
|
||||||
|
// Ctrl+scroll steps the icon size, as in Photos. Capture phase so the
|
||||||
|
// scrolled window does not scroll the grid as well. A touchpad sends many
|
||||||
|
// small deltas, so they add up to a whole notch before each step.
|
||||||
|
if let Some(grid_scroll) = files.views.child_by_name("grid") {
|
||||||
|
let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
|
||||||
|
wheel.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||||
|
let ui_wheel = ui.clone();
|
||||||
|
let pending = Cell::new(0.0_f64);
|
||||||
|
wheel.connect_scroll(move |controller, _dx, dy| {
|
||||||
|
if !controller
|
||||||
|
.current_event_state()
|
||||||
|
.contains(gtk4::gdk::ModifierType::CONTROL_MASK)
|
||||||
|
{
|
||||||
|
pending.set(0.0);
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
|
let total = pending.get() + dy;
|
||||||
|
let steps = total.trunc();
|
||||||
|
pending.set(total - steps);
|
||||||
|
if steps != 0.0 {
|
||||||
|
// Scrolling up (negative dy) makes the icons bigger.
|
||||||
|
zoom_files(&ui_wheel, -(steps as i32));
|
||||||
|
}
|
||||||
|
glib::Propagation::Stop
|
||||||
|
});
|
||||||
|
grid_scroll.add_controller(wheel);
|
||||||
|
}
|
||||||
|
|
||||||
files
|
files
|
||||||
.column_view
|
.column_view
|
||||||
@@ -851,27 +898,15 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
|||||||
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
let mut menu = ActionMenu::new();
|
let mut menu = ActionMenu::new();
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
// Open does what a double-click does: a video or a song plays in the app,
|
||||||
|
// and Open With is the way to another player.
|
||||||
menu.item(&pgettext("verb", "Open"), move || {
|
menu.item(&pgettext("verb", "Open"), move || {
|
||||||
// Open always means "download a local copy and hand off", even for a
|
activate_entry(&ui_c, &entry_c)
|
||||||
// media file — `activate_entry` would otherwise stream it.
|
|
||||||
if is_streamable_media_entry(&entry_c) {
|
|
||||||
download_and_open(&ui_c, &entry_c);
|
|
||||||
} else {
|
|
||||||
activate_entry(&ui_c, &entry_c);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
menu.item(&gettext("Open With…"), move || {
|
menu.item(&gettext("Open With…"), move || {
|
||||||
open_entry_with(&ui_c, &entry_c)
|
open_entry_with(&ui_c, &entry_c)
|
||||||
});
|
});
|
||||||
// Play streams from the mount, no download; Open above still fetches a
|
|
||||||
// local copy for anyone who wants one.
|
|
||||||
if is_streamable_media_entry(entry) {
|
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
|
||||||
menu.item(&pgettext("verb", "Play"), move || {
|
|
||||||
stream_entry(&ui_c, &entry_c)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
menu.section();
|
menu.section();
|
||||||
|
|
||||||
// Kept through a pinned folder above it, the entry has no pin of its own
|
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||||
@@ -1060,38 +1095,8 @@ pub(crate) fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Op
|
|||||||
Some(entry)
|
Some(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an entry can be opened through FUSE instead of fully materialized.
|
/// Open an entry the Nautilus way: folders descend, media plays in the app,
|
||||||
pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool {
|
/// streamed from the mount, other files download-and-open.
|
||||||
drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stream media straight from the mount, no download. Drive folders *are* part
|
|
||||||
/// of the FUSE mount, so a player pointed at `<mountpoint>/<rel>` reads the file
|
|
||||||
/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it
|
|
||||||
/// seeks and buffers — instead of waiting for the whole file to land like
|
|
||||||
/// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC
|
|
||||||
/// `.mkv` starts playing in seconds.
|
|
||||||
pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
|
||||||
let rel = entry_rel(ui, entry);
|
|
||||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
|
||||||
let abs = mounted_path(&mountpoint, &rel);
|
|
||||||
let Some(path) = abs.to_str() else {
|
|
||||||
toast_error(
|
|
||||||
ui,
|
|
||||||
&gettext("Couldn't play media"),
|
|
||||||
&gettext("The file path isn't valid UTF-8."),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
toast(
|
|
||||||
ui,
|
|
||||||
&gettext_f("Streaming “{name}”…", &[("name", &entry.name)]),
|
|
||||||
);
|
|
||||||
play_external(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open an entry the Nautilus way: folders descend, media streams from the mount,
|
|
||||||
/// other files download-and-open.
|
|
||||||
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
let rel = entry_rel(ui, entry);
|
let rel = entry_rel(ui, entry);
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
@@ -1104,16 +1109,16 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
} else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
|
} else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
|
||||||
// Media streams rather than downloads: that is exactly the "play it,
|
// Media streams rather than downloads: that is exactly the "play it,
|
||||||
// don't fetch the whole thing" behaviour this is for.
|
// don't fetch the whole thing" behaviour this is for.
|
||||||
stream_entry(ui, entry);
|
open_media_player(ui, entry);
|
||||||
} else {
|
} else {
|
||||||
download_and_open(ui, entry);
|
download_and_open(ui, entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand a file to the user's default application: through the mount when it is
|
/// Hand a file to the user's default application: through the mount when it is
|
||||||
/// there, otherwise downloaded into the cache first. The open path behind both
|
/// there, otherwise downloaded into the cache first. The open path behind a
|
||||||
/// a plain double-click and the context menu's "Open" (including for a video,
|
/// double-click and the context menu's "Open" for anything but a folder or
|
||||||
/// when the user wants their default application rather than the player).
|
/// media, and for media the mount doesn't show.
|
||||||
///
|
///
|
||||||
/// The mount comes first because the cache blob is a *copy* keyed by content
|
/// The mount comes first because the cache blob is a *copy* keyed by content
|
||||||
/// hash — an application saving into it writes where Drive will never look, and
|
/// hash — an application saving into it writes where Drive will never look, and
|
||||||
@@ -1306,6 +1311,11 @@ pub(crate) fn wire_browser_actions(ui: &Rc<Ui>, build_thumbnails: >k4::Button)
|
|||||||
|
|
||||||
let view = ui.dirs.load_config().files_view;
|
let view = ui.dirs.load_config().files_view;
|
||||||
ui.browser.view.set(view);
|
ui.browser.view.set(view);
|
||||||
|
if let Some(size) = view.icon_size {
|
||||||
|
ui.browser
|
||||||
|
.zoom
|
||||||
|
.set_value(f64::from(size.clamp(GRID_THUMB_MIN, GRID_THUMB_MAX)));
|
||||||
|
}
|
||||||
let layout = gio::SimpleAction::new_stateful(
|
let layout = gio::SimpleAction::new_stateful(
|
||||||
"view",
|
"view",
|
||||||
Some(glib::VariantTy::STRING),
|
Some(glib::VariantTy::STRING),
|
||||||
@@ -1407,6 +1417,41 @@ pub(crate) fn wire_browser_actions(ui: &Rc<Ui>, build_thumbnails: >k4::Button)
|
|||||||
ui.browser.content.add_controller(drop);
|
ui.browser.content.add_controller(drop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remember the grid's icon size, once the slider has rested for a moment.
|
||||||
|
fn save_icon_size(ui: &Rc<Ui>, size: i32) {
|
||||||
|
let mut view = ui.browser.view.get();
|
||||||
|
if view.icon_size == Some(size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view.icon_size = Some(size);
|
||||||
|
ui.browser.view.set(view);
|
||||||
|
if let Some(source) = ui.browser.zoom_save.borrow_mut().take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
let ui_save = ui.clone();
|
||||||
|
let source = glib::timeout_add_local_once(Duration::from_millis(500), move || {
|
||||||
|
ui_save.browser.zoom_save.borrow_mut().take();
|
||||||
|
let mut config = ui_save.dirs.load_config();
|
||||||
|
config.files_view.icon_size = ui_save.browser.view.get().icon_size;
|
||||||
|
if let Err(e) = ui_save.dirs.save_config(&config) {
|
||||||
|
tracing::warn!("cannot save the icon size: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*ui.browser.zoom_save.borrow_mut() = Some(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step the grid's icon size by `steps` slider notches; `0` resets it.
|
||||||
|
pub(crate) fn zoom_files(ui: &Rc<Ui>, steps: i32) {
|
||||||
|
let zoom = &ui.browser.zoom;
|
||||||
|
let size = if steps == 0 {
|
||||||
|
GRID_THUMB_DEFAULT
|
||||||
|
} else {
|
||||||
|
(zoom.value().round() as i32 + steps * GRID_THUMB_STEP)
|
||||||
|
.clamp(GRID_THUMB_MIN, GRID_THUMB_MAX)
|
||||||
|
};
|
||||||
|
zoom.set_value(f64::from(size));
|
||||||
|
}
|
||||||
|
|
||||||
fn layout_name(view: FilesView) -> &'static str {
|
fn layout_name(view: FilesView) -> &'static str {
|
||||||
if view.list { "list" } else { "grid" }
|
if view.list { "list" } else { "grid" }
|
||||||
}
|
}
|
||||||
@@ -1484,8 +1529,29 @@ pub(crate) fn sort_entries(entries: &mut [DirEntry], view: FilesView) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remember how the folder on screen is scrolled and what is selected in it,
|
||||||
|
/// for Back, Forward or Up to bring it back that way. Only a listing of the
|
||||||
|
/// folder itself counts; search hits are not its rows.
|
||||||
|
fn remember_place(ui: &Rc<Ui>) {
|
||||||
|
let path = ui.browser.path.borrow().clone();
|
||||||
|
if ui.browser.listed.borrow().as_deref() != Some(path.as_str()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let files = &ui.browser.files;
|
||||||
|
let place = FolderPlace {
|
||||||
|
offset: files.scroll_offset(),
|
||||||
|
selected: files
|
||||||
|
.selected()
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| entry.name)
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
ui.browser.places.borrow_mut().insert(path, place);
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the folder on screen in a new place, remembering where we were.
|
/// Open the folder on screen in a new place, remembering where we were.
|
||||||
pub(crate) fn browse_to(ui: &Rc<Ui>, target: String) {
|
pub(crate) fn browse_to(ui: &Rc<Ui>, target: String) {
|
||||||
|
remember_place(ui);
|
||||||
let left = ui.browser.path.replace(target.clone());
|
let left = ui.browser.path.replace(target.clone());
|
||||||
if left != target {
|
if left != target {
|
||||||
ui.browser.history.borrow_mut().push(left);
|
ui.browser.history.borrow_mut().push(left);
|
||||||
@@ -1510,6 +1576,8 @@ fn browse_back(ui: &Rc<Ui>) {
|
|||||||
let Some(previous) = ui.browser.history.borrow_mut().pop() else {
|
let Some(previous) = ui.browser.history.borrow_mut().pop() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
remember_place(ui);
|
||||||
|
*ui.browser.restore.borrow_mut() = Some(previous.clone());
|
||||||
let left = ui.browser.path.replace(previous);
|
let left = ui.browser.path.replace(previous);
|
||||||
ui.browser.future.borrow_mut().push(left);
|
ui.browser.future.borrow_mut().push(left);
|
||||||
load_browser(ui);
|
load_browser(ui);
|
||||||
@@ -1520,6 +1588,8 @@ fn browse_forward(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
ui.browser.search.set_text("");
|
ui.browser.search.set_text("");
|
||||||
|
remember_place(ui);
|
||||||
|
*ui.browser.restore.borrow_mut() = Some(next.clone());
|
||||||
let left = ui.browser.path.replace(next);
|
let left = ui.browser.path.replace(next);
|
||||||
ui.browser.history.borrow_mut().push(left);
|
ui.browser.history.borrow_mut().push(left);
|
||||||
load_browser(ui);
|
load_browser(ui);
|
||||||
@@ -1532,6 +1602,7 @@ fn browse_up(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
ui.browser.search.set_text("");
|
ui.browser.search.set_text("");
|
||||||
let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or_default();
|
let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or_default();
|
||||||
|
*ui.browser.restore.borrow_mut() = Some(parent.to_string());
|
||||||
browse_to(ui, parent.to_string());
|
browse_to(ui, parent.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1728,8 +1799,9 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
|
|||||||
let text = if status.scanning {
|
let text = if status.scanning {
|
||||||
ui.browser.thumbnail_progress.pulse();
|
ui.browser.thumbnail_progress.pulse();
|
||||||
let folders = ngettext_f("{n} folder", "{n} folders", status.folders_scanned, &[]);
|
let folders = ngettext_f("{n} folder", "{n} folders", status.folders_scanned, &[]);
|
||||||
let images = ngettext_f("{n} image", "{n} images", status.images_found, &[]);
|
// Translators: a count of images and videos that can have a thumbnail.
|
||||||
// Translators: {root} is a folder path or "Proton Drive"; {folders} and {images} are counts such as "3 folders" and "12 images".
|
let images = ngettext_f("{n} file", "{n} files", status.images_found, &[]);
|
||||||
|
// Translators: {root} is a folder path or "Proton Drive"; {folders} and {images} are counts such as "3 folders" and "12 files".
|
||||||
gettext_f(
|
gettext_f(
|
||||||
"Scanning {root}… {folders}, {images}",
|
"Scanning {root}… {folders}, {images}",
|
||||||
&[("root", root), ("folders", &folders), ("images", &images)],
|
&[("root", root), ("folders", &folders), ("images", &images)],
|
||||||
@@ -1743,7 +1815,7 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
|
|||||||
ui.browser
|
ui.browser
|
||||||
.thumbnail_progress
|
.thumbnail_progress
|
||||||
.set_fraction(fraction.clamp(0.0, 1.0));
|
.set_fraction(fraction.clamp(0.0, 1.0));
|
||||||
// Translators: {root} is a folder path or "Proton Drive"; {done} and {total} are image counts.
|
// Translators: {root} is a folder path or "Proton Drive"; {done} and {total} are counts of images and videos.
|
||||||
gettext_f(
|
gettext_f(
|
||||||
"Building thumbnails in {root}… {done} of {total}",
|
"Building thumbnails in {root}… {done} of {total}",
|
||||||
&[
|
&[
|
||||||
@@ -1756,17 +1828,17 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
|
|||||||
ui.browser.thumbnail_progress.set_fraction(1.0);
|
ui.browser.thumbnail_progress.set_fraction(1.0);
|
||||||
let available = status.completed.saturating_sub(status.failed);
|
let available = status.completed.saturating_sub(status.failed);
|
||||||
match status.failed {
|
match status.failed {
|
||||||
// Translators: {root} is a folder path or "Proton Drive".
|
// Translators: {root} is a folder path or "Proton Drive"; {n} counts images and videos.
|
||||||
0 => ngettext_f(
|
0 => ngettext_f(
|
||||||
"Thumbnails ready for {n} image in {root}",
|
"Thumbnails ready for {n} file in {root}",
|
||||||
"Thumbnails ready for {n} images in {root}",
|
"Thumbnails ready for {n} files in {root}",
|
||||||
available,
|
available,
|
||||||
&[("root", root)],
|
&[("root", root)],
|
||||||
),
|
),
|
||||||
// Translators: {root} is a folder path or "Proton Drive"; {failed} is how many images could not get a thumbnail.
|
// Translators: {root} is a folder path or "Proton Drive"; {failed} is how many images and videos could not get a thumbnail.
|
||||||
failed => ngettext_f(
|
failed => ngettext_f(
|
||||||
"Thumbnails ready for {n} image in {root}; {failed} unavailable",
|
"Thumbnails ready for {n} file in {root}; {failed} unavailable",
|
||||||
"Thumbnails ready for {n} images in {root}; {failed} unavailable",
|
"Thumbnails ready for {n} files in {root}; {failed} unavailable",
|
||||||
available,
|
available,
|
||||||
&[("root", root), ("failed", &failed.to_string())],
|
&[("root", root), ("failed", &failed.to_string())],
|
||||||
),
|
),
|
||||||
@@ -2765,6 +2837,11 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
|
|||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
return "folder";
|
return "folder";
|
||||||
}
|
}
|
||||||
|
// Every video type, including a `.ts` recording, which by its name alone
|
||||||
|
// is TypeScript.
|
||||||
|
if pdfs_core::control::is_video_file_name(&entry.name, Some(entry.size)) {
|
||||||
|
return "video-x-generic";
|
||||||
|
}
|
||||||
let ext = entry
|
let ext = entry
|
||||||
.name
|
.name
|
||||||
.rsplit('.')
|
.rsplit('.')
|
||||||
@@ -2775,7 +2852,6 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
|
|||||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "heic" | "tiff" => {
|
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "heic" | "tiff" => {
|
||||||
"image-x-generic"
|
"image-x-generic"
|
||||||
}
|
}
|
||||||
"mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" => "video-x-generic",
|
|
||||||
"mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" => "audio-x-generic",
|
"mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" => "audio-x-generic",
|
||||||
"pdf" | "doc" | "docx" | "odt" => "x-office-document",
|
"pdf" | "doc" | "docx" | "odt" => "x-office-document",
|
||||||
"xls" | "xlsx" | "ods" | "csv" => "x-office-spreadsheet",
|
"xls" | "xlsx" | "ods" | "csv" => "x-office-spreadsheet",
|
||||||
@@ -2844,6 +2920,18 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read again what My files shows: the search hits while there is a query,
|
||||||
|
/// the folder otherwise. [`load_browser`] alone would swap a search's hits
|
||||||
|
/// for the folder while the query stays in the box.
|
||||||
|
pub(crate) fn reload_browser_view(ui: &Rc<Ui>) {
|
||||||
|
let query = ui.browser.search.text().trim().to_string();
|
||||||
|
if query.is_empty() {
|
||||||
|
load_browser(ui);
|
||||||
|
} else {
|
||||||
|
run_search(ui, &query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear the model and show the daemon's error on the status page. Used for
|
/// Clear the model and show the daemon's error on the status page. Used for
|
||||||
/// in-band failures (a bad path, a permission error) — the mount is up, so Retry
|
/// in-band failures (a bad path, a permission error) — the mount is up, so Retry
|
||||||
/// (which restarts the service) wouldn't help and isn't offered.
|
/// (which restarts the service) wouldn't help and isn't offered.
|
||||||
@@ -2882,7 +2970,10 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
|
|||||||
pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
show_location_column(ui, false);
|
show_location_column(ui, false);
|
||||||
*ui.browser.listing.borrow_mut() = entries.to_vec();
|
*ui.browser.listing.borrow_mut() = entries.to_vec();
|
||||||
*ui.browser.listed.borrow_mut() = Some(ui.browser.path.borrow().clone());
|
// `listed` still names the folder the rows belong to only on a refresh;
|
||||||
|
// any other load forgot it (see `load_browser`).
|
||||||
|
let path = ui.browser.path.borrow().clone();
|
||||||
|
let another_folder = ui.browser.listed.replace(Some(path.clone())).as_ref() != Some(&path);
|
||||||
ui.browser.summary.set_label(&listing_summary(
|
ui.browser.summary.set_label(&listing_summary(
|
||||||
entries.iter().filter(|entry| !entry.is_dir).count(),
|
entries.iter().filter(|entry| !entry.is_dir).count(),
|
||||||
entries.iter().filter(|entry| entry.is_dir).count(),
|
entries.iter().filter(|entry| entry.is_dir).count(),
|
||||||
@@ -2911,6 +3002,16 @@ pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
// Only the rows that changed are swapped, so a refresh keeps the
|
// Only the rows that changed are swapped, so a refresh keeps the
|
||||||
// selection, the scroll position and the thumbnails already drawn.
|
// selection, the scroll position and the thumbnails already drawn.
|
||||||
replace_items(&ui.browser.files.model, &sorted);
|
replace_items(&ui.browser.files.model, &sorted);
|
||||||
|
// Back, Forward and Up return to a folder as it was left; any other way
|
||||||
|
// in starts at the top.
|
||||||
|
let restore = ui.browser.restore.take().filter(|target| *target == path);
|
||||||
|
if another_folder {
|
||||||
|
let place = restore.and_then(|path| ui.browser.places.borrow().get(&path).cloned());
|
||||||
|
match place {
|
||||||
|
Some(place) => ui.browser.files.restore(place.offset, &place.selected),
|
||||||
|
None => ui.browser.files.scroll_to_top(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget which folder the rows belong to and drop the selection, before the
|
/// Forget which folder the rows belong to and drop the selection, before the
|
||||||
@@ -3089,6 +3190,9 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
|||||||
.collect();
|
.collect();
|
||||||
sort_entries(&mut entries, ui.browser.view.get());
|
sort_entries(&mut entries, ui.browser.view.get());
|
||||||
replace_items(&ui.browser.files.model, &entries);
|
replace_items(&ui.browser.files.model, &entries);
|
||||||
|
// Every search starts over (`run_search` forgets the listing), so its hits
|
||||||
|
// read from the first one.
|
||||||
|
ui.browser.files.scroll_to_top();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
||||||
@@ -3122,7 +3226,7 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
MoveTarget, badge_for, copy_target_allowed, hit_location, listing_summary,
|
MoveTarget, badge_for, copy_target_allowed, hit_location, icon_base_for, listing_summary,
|
||||||
move_target_allowed, show_thumbnail_build_progress, sort_entries,
|
move_target_allowed, show_thumbnail_build_progress, sort_entries,
|
||||||
};
|
};
|
||||||
use pdfs_core::config::{FileSort, FilesView};
|
use pdfs_core::config::{FileSort, FilesView};
|
||||||
@@ -3149,6 +3253,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_video_type_gets_the_video_icon() {
|
||||||
|
const MIB: u64 = 1024 * 1024;
|
||||||
|
for name in [
|
||||||
|
"clip.mp4",
|
||||||
|
"clip.MKV",
|
||||||
|
"broadcast.m2ts",
|
||||||
|
"camera.mts",
|
||||||
|
"old.flv",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
icon_base_for(&entry(name, false, 10, 0)),
|
||||||
|
"video-x-generic",
|
||||||
|
"{name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
icon_base_for(&entry("recording.ts", false, 900 * MIB, 0)),
|
||||||
|
"video-x-generic"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
icon_base_for(&entry("app.ts", false, 4096, 0)),
|
||||||
|
"text-x-generic"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn names(entries: &[DirEntry]) -> Vec<&str> {
|
fn names(entries: &[DirEntry]) -> Vec<&str> {
|
||||||
entries.iter().map(|e| e.name.as_str()).collect()
|
entries.iter().map(|e| e.name.as_str()).collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,10 +190,9 @@ pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let ui_back = ui.clone();
|
let ui_back = ui.clone();
|
||||||
widgets.back.connect_clicked(move |_| {
|
widgets
|
||||||
ui_back.devices.nav.borrow_mut().pop();
|
.back
|
||||||
load_devices(&ui_back);
|
.connect_clicked(move |_| devices_back(&ui_back));
|
||||||
});
|
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
widgets
|
widgets
|
||||||
.retry
|
.retry
|
||||||
@@ -211,6 +210,14 @@ pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
|
|||||||
ui.devices.content.set_visible_child_name("status");
|
ui.devices.content.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Back out of a computer's folder to the level above: the page's Back button, and
|
||||||
|
/// Alt+Left or Backspace. A no-op at the top.
|
||||||
|
pub(crate) fn devices_back(ui: &Rc<Ui>) {
|
||||||
|
if ui.devices.nav.borrow_mut().pop().is_some() {
|
||||||
|
load_devices(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load the view [`DevicesState::nav`] points at: the device list, or a folder
|
/// Load the view [`DevicesState::nav`] points at: the device list, or a folder
|
||||||
/// of another computer's backup.
|
/// of another computer's backup.
|
||||||
pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||||
@@ -297,6 +304,7 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
|||||||
};
|
};
|
||||||
ui.devices.content.set_visible_child_name("browse");
|
ui.devices.content.set_visible_child_name("browse");
|
||||||
repaint_device_crumb(&ui, &nav);
|
repaint_device_crumb(&ui, &nav);
|
||||||
|
let another_folder = ui.devices.listed.borrow().as_ref() != Some(&nav);
|
||||||
*ui.devices.listed.borrow_mut() = Some(nav);
|
*ui.devices.listed.borrow_mut() = Some(nav);
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
ui.devices.files.model.remove_all();
|
ui.devices.files.model.remove_all();
|
||||||
@@ -304,6 +312,10 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
|||||||
} else {
|
} else {
|
||||||
ui.devices.files_content.set_visible_child_name("list");
|
ui.devices.files_content.set_visible_child_name("list");
|
||||||
replace_items(&ui.devices.files.model, &entries);
|
replace_items(&ui.devices.files.model, &entries);
|
||||||
|
// A refresh keeps its place; another folder starts at the top.
|
||||||
|
if another_folder {
|
||||||
|
ui.devices.files.scroll_to_top();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -588,6 +600,7 @@ fn platform_label(device_type: &str) -> &str {
|
|||||||
/// How many folders this machine backs up, and whether any of them needs
|
/// How many folders this machine backs up, and whether any of them needs
|
||||||
/// attention — the one fact worth surfacing away from the folder list itself.
|
/// attention — the one fact worth surfacing away from the folder list itself.
|
||||||
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||||
|
let folders: Vec<_> = folders.iter().filter(|f| f.kind == "device").collect();
|
||||||
if folders.is_empty() {
|
if folders.is_empty() {
|
||||||
return gettext("No folders backed up yet");
|
return gettext("No folders backed up yet");
|
||||||
}
|
}
|
||||||
@@ -939,7 +952,13 @@ pub(crate) fn reload_sync_pages(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
|
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
|
||||||
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
|
pub(crate) fn prompt_remove_sync_folder(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
id: i64,
|
||||||
|
path: &str,
|
||||||
|
ondemand: bool,
|
||||||
|
attached: bool,
|
||||||
|
) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
// The two modes leave the user in opposite places, so they can't share a
|
// The two modes leave the user in opposite places, so they can't share a
|
||||||
// sentence. A mirror folder's files are already on this disk and stay there.
|
// sentence. A mirror folder's files are already on this disk and stay there.
|
||||||
@@ -947,7 +966,12 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
|||||||
// in Proton Drive, so unmounting it leaves an empty directory — which is a
|
// in Proton Drive, so unmounting it leaves an empty directory — which is a
|
||||||
// nasty surprise if the dialog claimed the local files were safe, and is
|
// nasty surprise if the dialog claimed the local files were safe, and is
|
||||||
// recoverable only by turning On-demand off *first* and letting it download.
|
// recoverable only by turning On-demand off *first* and letting it download.
|
||||||
let body = if ondemand {
|
let body = if attached {
|
||||||
|
gettext_f(
|
||||||
|
"Stop syncing “{path}”? Both the local files and the folder in My files are kept.",
|
||||||
|
&[("path", path)],
|
||||||
|
)
|
||||||
|
} else if ondemand {
|
||||||
// Translators: {path} is a local folder path.
|
// Translators: {path} is a local folder path.
|
||||||
gettext_f(
|
gettext_f(
|
||||||
"Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.",
|
"Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.",
|
||||||
@@ -975,7 +999,9 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
|||||||
.active(false)
|
.active(false)
|
||||||
.build();
|
.build();
|
||||||
group.add(&delete_remote);
|
group.add(&delete_remote);
|
||||||
dialog.set_extra_child(Some(&group));
|
if !attached {
|
||||||
|
dialog.set_extra_child(Some(&group));
|
||||||
|
}
|
||||||
dialog.add_response("cancel", &gettext("Cancel"));
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
dialog.add_response("remove", &gettext("Stop Syncing"));
|
dialog.add_response("remove", &gettext("Stop Syncing"));
|
||||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||||
@@ -988,7 +1014,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
|||||||
&ui,
|
&ui,
|
||||||
Request::RemoveSyncFolder {
|
Request::RemoveSyncFolder {
|
||||||
id,
|
id,
|
||||||
delete_remote: delete_remote.is_active(),
|
delete_remote: !attached && delete_remote.is_active(),
|
||||||
},
|
},
|
||||||
&gettext("Stopped syncing folder"),
|
&gettext("Stopped syncing folder"),
|
||||||
&gettext("Couldn't stop syncing the folder"),
|
&gettext("Couldn't stop syncing the folder"),
|
||||||
@@ -1291,6 +1317,7 @@ mod tests {
|
|||||||
|
|
||||||
fn folder(state: &str) -> SyncFolderInfo {
|
fn folder(state: &str) -> SyncFolderInfo {
|
||||||
SyncFolderInfo {
|
SyncFolderInfo {
|
||||||
|
kind: "device".into(),
|
||||||
id: 1,
|
id: 1,
|
||||||
local_path: "/home/u/Documents".into(),
|
local_path: "/home/u/Documents".into(),
|
||||||
remote_uid: "vol~link".into(),
|
remote_uid: "vol~link".into(),
|
||||||
@@ -1303,6 +1330,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attached_my_files_folders_do_not_count_as_device_backups() {
|
||||||
|
let mut attached = folder("error");
|
||||||
|
attached.kind = "attached".into();
|
||||||
|
assert_eq!(
|
||||||
|
this_computer_subtitle(&[attached]),
|
||||||
|
"No folders backed up yet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_machine_with_no_folders_says_so() {
|
fn a_machine_with_no_folders_says_so() {
|
||||||
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
|
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
|
||||||
|
|||||||
@@ -1361,6 +1361,9 @@ fn location_row(ui: &Rc<Ui>, spec: &MountSpec) -> LocationRow {
|
|||||||
prompt_mountpoint(&ui)
|
prompt_mountpoint(&ui)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
MountKind::Attached { sync_folder_id } => {
|
||||||
|
add_device_items(ui, &mut menu, spec, *sync_folder_id);
|
||||||
|
}
|
||||||
MountKind::Device { sync_folder_id } => {
|
MountKind::Device { sync_folder_id } => {
|
||||||
row.add_suffix(&mode_button(ui, spec, *sync_folder_id));
|
row.add_suffix(&mode_button(ui, spec, *sync_folder_id));
|
||||||
add_device_items(ui, &mut menu, spec, *sync_folder_id);
|
add_device_items(ui, &mut menu, spec, *sync_folder_id);
|
||||||
@@ -1520,8 +1523,9 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
|
|||||||
// The folder's *current* mode, not a queued one: a switch that hasn't landed
|
// The folder's *current* mode, not a queued one: a switch that hasn't landed
|
||||||
// yet has not moved the files anywhere.
|
// yet has not moved the files anywhere.
|
||||||
let is_ondemand = spec.mode == MountMode::OnDemand;
|
let is_ondemand = spec.mode == MountMode::OnDemand;
|
||||||
|
let attached = matches!(spec.kind, MountKind::Attached { .. });
|
||||||
menu.item(&gettext("Stop Syncing…"), move || {
|
menu.item(&gettext("Stop Syncing…"), move || {
|
||||||
prompt_remove_sync_folder(&ui, id, &path, is_ondemand)
|
prompt_remove_sync_folder(&ui, id, &path, is_ondemand, attached)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1699,7 +1703,7 @@ fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
|
|||||||
pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
|
pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
MountKind::MyFiles => "folder-remote-symbolic",
|
MountKind::MyFiles => "folder-remote-symbolic",
|
||||||
MountKind::Device { .. } => "folder-symbolic",
|
MountKind::Device { .. } | MountKind::Attached { .. } => "folder-symbolic",
|
||||||
MountKind::Shared { .. } => "pdfs-people-symbolic",
|
MountKind::Shared { .. } => "pdfs-people-symbolic",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1716,6 +1720,7 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
|||||||
match &spec.kind {
|
match &spec.kind {
|
||||||
MountKind::MyFiles => parts.push(gettext("My files")),
|
MountKind::MyFiles => parts.push(gettext("My files")),
|
||||||
MountKind::Shared { .. } => parts.push(gettext("Shared folder")),
|
MountKind::Shared { .. } => parts.push(gettext("Shared folder")),
|
||||||
|
MountKind::Attached { .. } => parts.push(gettext("My files mirror")),
|
||||||
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
|
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
|
||||||
(Some(MountMode::OnDemand), _) => parts.push(gettext("Going online only")),
|
(Some(MountMode::OnDemand), _) => parts.push(gettext("Going online only")),
|
||||||
(Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")),
|
(Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")),
|
||||||
@@ -1725,7 +1730,10 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
|||||||
(Some(MountMode::Unknown) | None, _) => parts.push(folder_mode_label(false)),
|
(Some(MountMode::Unknown) | None, _) => parts.push(folder_mode_label(false)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if matches!(spec.kind, MountKind::Device { .. }) {
|
if matches!(
|
||||||
|
spec.kind,
|
||||||
|
MountKind::Device { .. } | MountKind::Attached { .. }
|
||||||
|
) {
|
||||||
parts.push(match &spec.progress {
|
parts.push(match &spec.progress {
|
||||||
Some(p) => sync_progress_label(p),
|
Some(p) => sync_progress_label(p),
|
||||||
None if spec.paused => pgettext("state", "paused"),
|
None if spec.paused => pgettext("state", "paused"),
|
||||||
@@ -1737,8 +1745,10 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
|||||||
}
|
}
|
||||||
// Only the locations that are supposed to have a session report its absence:
|
// Only the locations that are supposed to have a session report its absence:
|
||||||
// a mirror folder never has one.
|
// a mirror folder never has one.
|
||||||
let expects_session = !matches!(spec.kind, MountKind::Device { .. })
|
let expects_session = !matches!(
|
||||||
|| spec.mode == MountMode::OnDemand
|
spec.kind,
|
||||||
|
MountKind::Device { .. } | MountKind::Attached { .. }
|
||||||
|
) || spec.mode == MountMode::OnDemand
|
||||||
|| spec.pending_mode == Some(MountMode::OnDemand);
|
|| spec.pending_mode == Some(MountMode::OnDemand);
|
||||||
if expects_session && !spec.mounted {
|
if expects_session && !spec.mounted {
|
||||||
// Translators: a folder's files can't be opened right now.
|
// Translators: a folder's files can't be opened right now.
|
||||||
@@ -1769,6 +1779,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_attached_mirror_is_available_without_a_fuse_session() {
|
||||||
|
let spec = spec(
|
||||||
|
MountKind::Attached { sync_folder_id: 7 },
|
||||||
|
MountMode::Mirror,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let subtitle = location_subtitle(&spec);
|
||||||
|
assert!(subtitle.contains("My files mirror"), "{subtitle}");
|
||||||
|
assert!(!subtitle.contains("not available"), "{subtitle}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_mirror_folder_is_never_reported_as_unmounted() {
|
fn a_mirror_folder_is_never_reported_as_unmounted() {
|
||||||
// It is a plain local directory: no FUSE session is expected, so saying
|
// It is a plain local directory: no FUSE session is expected, so saying
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ pub(crate) struct Viewer {
|
|||||||
/// Details panel: the toggle that reveals it, the rows it fills in, and the
|
/// Details panel: the toggle that reveals it, the rows it fills in, and the
|
||||||
/// "Show on map" button (hidden when the photo carries no GPS tags).
|
/// "Show on map" button (hidden when the photo carries no GPS tags).
|
||||||
pub(crate) info_toggle: gtk4::ToggleButton,
|
pub(crate) info_toggle: gtk4::ToggleButton,
|
||||||
pub(crate) info_revealer: gtk4::Revealer,
|
/// Lays the details panel beside the photo, or over it in a narrow
|
||||||
|
/// window.
|
||||||
|
pub(crate) info_split: adw::OverlaySplitView,
|
||||||
pub(crate) info_rows: gtk4::Box,
|
pub(crate) info_rows: gtk4::Box,
|
||||||
pub(crate) info_map: gtk4::Button,
|
pub(crate) info_map: gtk4::Button,
|
||||||
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
|
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
|
||||||
@@ -50,9 +52,9 @@ pub(crate) struct Viewer {
|
|||||||
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
|
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
|
||||||
pub(crate) favorite: gtk4::ToggleButton,
|
pub(crate) favorite: gtk4::ToggleButton,
|
||||||
pub(crate) favorite_suppress: Cell<bool>,
|
pub(crate) favorite_suppress: Cell<bool>,
|
||||||
/// The button that switches between the files of one shot, and the files it
|
/// The pill that switches between the files of one shot, and the files it
|
||||||
/// switches between (the JPEG and the RAW of a photo, a live photo and its
|
/// switches between (the JPEG and the RAW of a photo, a live photo and its
|
||||||
/// clip). Empty, and the button hidden, for a photo stored as one file.
|
/// clip). Empty, and the pill hidden, for a photo stored as one file.
|
||||||
pub(crate) group_btn: gtk4::Button,
|
pub(crate) group_btn: gtk4::Button,
|
||||||
pub(crate) group: RefCell<Vec<PhotoItem>>,
|
pub(crate) group: RefCell<Vec<PhotoItem>>,
|
||||||
/// uid of the photo currently on screen.
|
/// uid of the photo currently on screen.
|
||||||
@@ -79,14 +81,25 @@ pub(crate) struct Viewer {
|
|||||||
/// rests; whether the pointer is over one of them (which keeps them up);
|
/// rests; whether the pointer is over one of them (which keeps them up);
|
||||||
/// and the pending fade-out.
|
/// and the pending fade-out.
|
||||||
pub(crate) chrome: Vec<gtk4::Revealer>,
|
pub(crate) chrome: Vec<gtk4::Revealer>,
|
||||||
|
/// The strip of thumbnails along the bottom, its selection (the photo on
|
||||||
|
/// screen), and the revealer it fades with. Hidden in a slideshow.
|
||||||
|
pub(crate) strip: gtk4::ListView,
|
||||||
|
pub(crate) strip_selection: gtk4::SingleSelection,
|
||||||
|
pub(crate) strip_reveal: gtk4::Revealer,
|
||||||
pub(crate) chrome_hover: Cell<bool>,
|
pub(crate) chrome_hover: Cell<bool>,
|
||||||
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
|
||||||
/// The slideshow toggle; the timer that moves it on; and whether it put
|
/// The ⋮ button, and whether its menu is open, which keeps the controls
|
||||||
/// the window in fullscreen, so stopping it only leaves a fullscreen it
|
/// up the way resting the pointer on them does.
|
||||||
|
pub(crate) more: gtk4::Button,
|
||||||
|
pub(crate) menu_open: Cell<bool>,
|
||||||
|
/// Whether a slideshow runs; the timer that moves it on; and whether it
|
||||||
|
/// put the window in fullscreen, so stopping it only leaves a fullscreen it
|
||||||
/// entered itself.
|
/// entered itself.
|
||||||
pub(crate) slideshow: gtk4::ToggleButton,
|
pub(crate) slideshow: Cell<bool>,
|
||||||
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
|
||||||
pub(crate) slide_fullscreened: Cell<bool>,
|
pub(crate) slide_fullscreened: Cell<bool>,
|
||||||
|
/// Toasts over the lightbox. The main window's would land behind it.
|
||||||
|
pub(crate) toasts: adw::ToastOverlay,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
|
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
|
||||||
@@ -161,6 +174,14 @@ fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
|
|||||||
.group_btn
|
.group_btn
|
||||||
.set_tooltip_text(Some(&member_tooltip(next)));
|
.set_tooltip_text(Some(&member_tooltip(next)));
|
||||||
}
|
}
|
||||||
|
if let Some((label, lit)) = group_pill(&items, &uid) {
|
||||||
|
viewer.group_btn.set_label(&label);
|
||||||
|
if lit {
|
||||||
|
viewer.group_btn.add_css_class("lit");
|
||||||
|
} else {
|
||||||
|
viewer.group_btn.remove_css_class("lit");
|
||||||
|
}
|
||||||
|
}
|
||||||
*viewer.group.borrow_mut() = items;
|
*viewer.group.borrow_mut() = items;
|
||||||
viewer.group_btn.set_visible(true);
|
viewer.group_btn.set_visible(true);
|
||||||
});
|
});
|
||||||
@@ -173,6 +194,34 @@ pub(crate) fn next_group_member<'a>(members: &'a [PhotoItem], uid: &str) -> Opti
|
|||||||
members.get((at + 1) % members.len())
|
members.get((at + 1) % members.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the group pill reads for the shot whose files are `members`, with
|
||||||
|
/// `uid` on screen, and whether it is lit. A raw pair reads "RAW", lit while
|
||||||
|
/// the raw file is shown; a live photo reads "Live", lit while its clip plays;
|
||||||
|
/// a burst counts its frames.
|
||||||
|
pub(crate) fn group_pill(members: &[PhotoItem], uid: &str) -> Option<(String, bool)> {
|
||||||
|
let at = members.iter().position(|item| item.uid == uid)?;
|
||||||
|
let shown = members[at].kind;
|
||||||
|
if members.iter().any(|item| item.kind == PhotoKind::Raw) {
|
||||||
|
// Translators: badge on a photo tile whose shot includes a raw camera file.
|
||||||
|
return Some((gettext("RAW"), shown == PhotoKind::Raw));
|
||||||
|
}
|
||||||
|
if members.len() == 2 && members.iter().any(|item| item.kind == PhotoKind::Video) {
|
||||||
|
// Translators: the switch between a live photo and its short clip in the photo viewer.
|
||||||
|
return Some((pgettext("live photo", "Live"), shown == PhotoKind::Video));
|
||||||
|
}
|
||||||
|
Some((
|
||||||
|
// Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are.
|
||||||
|
gettext_f(
|
||||||
|
"{position} of {total}",
|
||||||
|
&[
|
||||||
|
("position", &thousands(at + 1)),
|
||||||
|
("total", &thousands(members.len())),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// What to call one file of a shot: its name, or its kind when the daemon has
|
/// What to call one file of a shot: its name, or its kind when the daemon has
|
||||||
/// not resolved a name for it.
|
/// not resolved a name for it.
|
||||||
pub(crate) fn member_label(item: &PhotoItem) -> String {
|
pub(crate) fn member_label(item: &PhotoItem) -> String {
|
||||||
@@ -230,6 +279,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
clear_info(viewer);
|
clear_info(viewer);
|
||||||
stop_video(viewer);
|
stop_video(viewer);
|
||||||
viewer.media.set_visible_child_name("photo");
|
viewer.media.set_visible_child_name("photo");
|
||||||
|
sync_strip(viewer);
|
||||||
reset_zoom(viewer);
|
reset_zoom(viewer);
|
||||||
// A slideshow waits for this photo to land before it starts counting.
|
// A slideshow waits for this photo to land before it starts counting.
|
||||||
disarm_slide(viewer);
|
disarm_slide(viewer);
|
||||||
@@ -307,6 +357,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
None => {
|
None => {
|
||||||
viewer.video.set_filename(Some(&path));
|
viewer.video.set_filename(Some(&path));
|
||||||
viewer.media.set_visible_child_name("video");
|
viewer.media.set_visible_child_name("video");
|
||||||
|
sync_strip(&viewer);
|
||||||
*viewer.path.borrow_mut() = Some(path.clone());
|
*viewer.path.borrow_mut() = Some(path.clone());
|
||||||
show_info(&viewer, size, ExifInfo::default());
|
show_info(&viewer, size, ExifInfo::default());
|
||||||
}
|
}
|
||||||
@@ -339,14 +390,15 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
|
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
|
||||||
/// screen counted down once it has loaded.
|
/// screen counted down once it has loaded.
|
||||||
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
if viewer.slideshow.replace(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !viewer.window.is_fullscreen() {
|
if !viewer.window.is_fullscreen() {
|
||||||
viewer.window.fullscreen();
|
viewer.window.fullscreen();
|
||||||
viewer.slide_fullscreened.set(true);
|
viewer.slide_fullscreened.set(true);
|
||||||
}
|
}
|
||||||
viewer
|
|
||||||
.slideshow
|
|
||||||
.set_icon_name("media-playback-pause-symbolic");
|
|
||||||
hide_chrome(viewer);
|
hide_chrome(viewer);
|
||||||
|
sync_strip(viewer);
|
||||||
if !viewer.loading.get() {
|
if !viewer.loading.get() {
|
||||||
arm_slide(ui, viewer);
|
arm_slide(ui, viewer);
|
||||||
}
|
}
|
||||||
@@ -354,15 +406,20 @@ fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|||||||
|
|
||||||
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
|
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
|
||||||
fn stop_slideshow(viewer: &Rc<Viewer>) {
|
fn stop_slideshow(viewer: &Rc<Viewer>) {
|
||||||
|
viewer.slideshow.set(false);
|
||||||
|
sync_strip(viewer);
|
||||||
disarm_slide(viewer);
|
disarm_slide(viewer);
|
||||||
if viewer.slide_fullscreened.replace(false) {
|
if viewer.slide_fullscreened.replace(false) {
|
||||||
viewer.window.unfullscreen();
|
viewer.window.unfullscreen();
|
||||||
}
|
}
|
||||||
viewer
|
}
|
||||||
.slideshow
|
|
||||||
.set_icon_name("media-playback-start-symbolic");
|
/// Start the slideshow, or stop the one running.
|
||||||
if viewer.slideshow.is_active() {
|
fn toggle_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
viewer.slideshow.set_active(false);
|
if viewer.slideshow.get() {
|
||||||
|
stop_slideshow(viewer);
|
||||||
|
} else {
|
||||||
|
start_slideshow(ui, viewer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +427,7 @@ fn stop_slideshow(viewer: &Rc<Viewer>) {
|
|||||||
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
|
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
|
||||||
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
disarm_slide(viewer);
|
disarm_slide(viewer);
|
||||||
if !viewer.slideshow.is_active() {
|
if !viewer.slideshow.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
|
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
|
||||||
@@ -790,7 +847,7 @@ fn show_chrome(viewer: &Rc<Viewer>) {
|
|||||||
};
|
};
|
||||||
// This timer has fired, so its id must not be removed again.
|
// This timer has fired, so its id must not be removed again.
|
||||||
viewer.chrome_source.borrow_mut().take();
|
viewer.chrome_source.borrow_mut().take();
|
||||||
if !viewer.chrome_hover.get() {
|
if !viewer.chrome_hover.get() && !viewer.menu_open.get() {
|
||||||
hide_chrome(&viewer);
|
hide_chrome(&viewer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -808,14 +865,14 @@ fn hide_chrome(viewer: &Rc<Viewer>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Copy the photo on screen to where the user picks, under its Drive name.
|
/// Copy the photo on screen to where the user picks, under its Drive name.
|
||||||
pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: >k4::Window, source: &str, name: &str) {
|
pub(crate) fn save_photo_to_disk(viewer: &Rc<Viewer>, source: &str, name: &str) {
|
||||||
let dialog = gtk4::FileDialog::builder()
|
let dialog = gtk4::FileDialog::builder()
|
||||||
.title(gettext("Save a Copy"))
|
.title(gettext("Save a Copy"))
|
||||||
.initial_name(name)
|
.initial_name(name)
|
||||||
.build();
|
.build();
|
||||||
let source = source.to_string();
|
let source = source.to_string();
|
||||||
let ui = ui.clone();
|
let toasts = viewer.toasts.clone();
|
||||||
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
|
dialog.save(Some(&viewer.window), gio::Cancellable::NONE, move |res| {
|
||||||
// An error here is the dialog being dismissed.
|
// An error here is the dialog being dismissed.
|
||||||
let Some(dest) = res.ok().and_then(|file| file.path()) else {
|
let Some(dest) = res.ok().and_then(|file| file.path()) else {
|
||||||
return;
|
return;
|
||||||
@@ -824,26 +881,67 @@ pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: >k4::Window, source: &st
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let copied =
|
let copied =
|
||||||
gio::spawn_blocking(move || std::fs::copy(&source, &dest).map(|_| dest)).await;
|
gio::spawn_blocking(move || std::fs::copy(&source, &dest).map(|_| dest)).await;
|
||||||
match copied {
|
let message = match copied {
|
||||||
Ok(Ok(dest)) => {
|
Ok(Ok(dest)) => {
|
||||||
let saved = dest.file_name().map_or_else(
|
let saved = dest.file_name().map_or_else(
|
||||||
|| dest.display().to_string(),
|
|| dest.display().to_string(),
|
||||||
|name| name.to_string_lossy().into_owned(),
|
|name| name.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
// Translators: {name} is the saved file's name.
|
// Translators: {name} is the saved file's name.
|
||||||
toast(&ui, &gettext_f("Saved “{name}”", &[("name", &saved)]));
|
gettext_f("Saved “{name}”", &[("name", &saved)])
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => toast_error(&ui, &gettext("Couldn't save the copy"), &e.to_string()),
|
Ok(Err(e)) => failure(&gettext("Couldn't save the copy"), &e.to_string()),
|
||||||
Err(_) => toast_error(
|
Err(_) => failure(
|
||||||
&ui,
|
|
||||||
&gettext("Couldn't save the copy"),
|
&gettext("Couldn't save the copy"),
|
||||||
&gettext("The copy was interrupted."),
|
&gettext("The copy was interrupted."),
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
toasts.add_toast(
|
||||||
|
adw::Toast::builder()
|
||||||
|
.title(&message)
|
||||||
|
.use_markup(false)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A failed action's toast text, worded like [`toast_error`]'s.
|
||||||
|
fn failure(what: &str, detail: &str) -> String {
|
||||||
|
tracing::warn!("{what}: {detail}");
|
||||||
|
// Translators: a failed action's toast. {what} says what failed, such as
|
||||||
|
// "Couldn't rename"; {detail} is the reason, usually untranslated text
|
||||||
|
// from the mount service.
|
||||||
|
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put the photo on screen on the clipboard as an image, for pasting into a
|
||||||
|
/// chat or a document.
|
||||||
|
fn copy_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let Some(texture) = viewer
|
||||||
|
.picture
|
||||||
|
.paintable()
|
||||||
|
.and_downcast::<gtk4::gdk::Texture>()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
viewer.window.clipboard().set_texture(&texture);
|
||||||
|
viewer.toasts.add_toast(
|
||||||
|
adw::Toast::builder()
|
||||||
|
// Translators: toast after the photo viewer put the photo on the clipboard.
|
||||||
|
.title(gettext("Image copied"))
|
||||||
|
.use_markup(false)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the Share dialog for the photo on screen, over the lightbox.
|
||||||
|
fn share_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let uid = viewer.uid.borrow().clone();
|
||||||
|
let name = viewer.name.borrow().clone().unwrap_or_default();
|
||||||
|
share_photo(ui, uid, name, Some(viewer.window.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
/// A symlink to the cached file `path` under its Drive `name`, in a directory
|
/// A symlink to the cached file `path` under its Drive `name`, in a directory
|
||||||
/// of its own below `base`. The cache names files by content hash, with no
|
/// of its own below `base`. The cache names files by content hash, with no
|
||||||
/// extension; the app chooser and the app it launches both need the real name
|
/// extension; the app chooser and the app it launches both need the real name
|
||||||
@@ -983,16 +1081,124 @@ pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
|
/// The lightbox's title: when the photo was taken, as "Wednesday, October 7,
|
||||||
|
/// 2026, 13:42".
|
||||||
|
fn photo_title(capture_time: i64) -> String {
|
||||||
|
let title = dates::full(capture_time);
|
||||||
|
if title.is_empty() {
|
||||||
|
gettext("Unknown Date")
|
||||||
|
} else {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the lightbox's title (the photo's date) and the line under it: the town
|
||||||
|
/// it was taken in, when known, and its "12 of 340" counter.
|
||||||
fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_time: i64) {
|
fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_time: i64) {
|
||||||
let loaded = ui.gallery.model.n_items();
|
let loaded = ui.gallery.model.n_items();
|
||||||
let more = ui.gallery.has_more.get();
|
let more = ui.gallery.has_more.get();
|
||||||
viewer.prev.set_sensitive(index > 0);
|
viewer.prev.set_sensitive(index > 0);
|
||||||
viewer.next.set_sensitive(index + 1 < loaded || more);
|
viewer.next.set_sensitive(index + 1 < loaded || more);
|
||||||
viewer.title.set_label(&format_capture_time(capture_time));
|
viewer.title.set_label(&photo_title(capture_time));
|
||||||
|
let position = position_label(index, loaded, more, gallery_total(ui));
|
||||||
|
let place = viewer_item(ui, viewer, &viewer.uid.borrow())
|
||||||
|
.and_then(|item| item.place_id)
|
||||||
|
.and_then(pdfs_core::places::city)
|
||||||
|
.map(|city| city.name);
|
||||||
|
let line = match place {
|
||||||
|
Some(place) => {
|
||||||
|
let args = [("place", place), ("position", position.as_str())];
|
||||||
|
// Translators: the line under the photo viewer's title, such as "Berlin · 12 of 340"; {place} is a town, {position} the counter.
|
||||||
|
gettext_f("{place} · {position}", &args)
|
||||||
|
}
|
||||||
|
None => position,
|
||||||
|
};
|
||||||
|
viewer.counter.set_label(&line);
|
||||||
|
sync_strip(viewer);
|
||||||
|
mark_in_strip(viewer, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the strip of thumbnails when there is more than one photo to pick
|
||||||
|
/// from. Not in a slideshow, and not over a video, where it would cover the
|
||||||
|
/// player's seek bar.
|
||||||
|
fn sync_strip(viewer: &Rc<Viewer>) {
|
||||||
|
let video = viewer.media.visible_child_name().as_deref() == Some("video");
|
||||||
viewer
|
viewer
|
||||||
.counter
|
.strip_reveal
|
||||||
.set_label(&position_label(index, loaded, more, gallery_total(ui)));
|
.set_visible(viewer.strip_selection.n_items() > 1 && !viewer.slideshow.get() && !video);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the photo at `index` in the strip and bring it into view.
|
||||||
|
fn mark_in_strip(viewer: &Rc<Viewer>, index: u32) {
|
||||||
|
if index >= viewer.strip_selection.n_items() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewer.strip_selection.set_selected(index);
|
||||||
|
viewer
|
||||||
|
.strip
|
||||||
|
.scroll_to(index, gtk4::ListScrollFlags::NONE, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The strip of thumbnails along the lightbox's bottom edge: the gallery's
|
||||||
|
/// photos in its order, the one on screen marked. A click steps straight to a
|
||||||
|
/// photo; the thumbnails come through the gallery's own pipeline and cache.
|
||||||
|
fn build_strip(ui: &Rc<Ui>) -> (gtk4::ListView, gtk4::SingleSelection) {
|
||||||
|
let selection = gtk4::SingleSelection::new(Some(ui.gallery.model.clone()));
|
||||||
|
selection.set_autoselect(false);
|
||||||
|
selection.set_can_unselect(true);
|
||||||
|
|
||||||
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
|
factory.connect_setup(|_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let picture = gtk4::Picture::builder()
|
||||||
|
.content_fit(gtk4::ContentFit::Cover)
|
||||||
|
.width_request(56)
|
||||||
|
.height_request(56)
|
||||||
|
.can_shrink(true)
|
||||||
|
.build();
|
||||||
|
picture.add_css_class("viewer-strip-thumb");
|
||||||
|
// The arrow keys belong to the lightbox, not to the strip.
|
||||||
|
item.set_focusable(false);
|
||||||
|
item.set_child(Some(&picture));
|
||||||
|
});
|
||||||
|
let ui_bind = ui.clone();
|
||||||
|
factory.connect_bind(move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let Some(picture) = item.child().and_downcast::<gtk4::Picture>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(boxed) = item.item().and_downcast::<BoxedAnyObject>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let photo = boxed.borrow::<PhotoItem>().clone();
|
||||||
|
picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
||||||
|
picture.set_tooltip_text(Some(&dates::full(photo.capture_time)));
|
||||||
|
want_thumb(&ui_bind, &photo, &picture);
|
||||||
|
schedule_thumbs(&ui_bind);
|
||||||
|
});
|
||||||
|
let ui_unbind = ui.clone();
|
||||||
|
factory.connect_unbind(move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let (Some(picture), Some(boxed)) = (
|
||||||
|
item.child().and_downcast::<gtk4::Picture>(),
|
||||||
|
item.item().and_downcast::<BoxedAnyObject>(),
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let uid = boxed.borrow::<PhotoItem>().uid.clone();
|
||||||
|
ui_unbind
|
||||||
|
.gallery
|
||||||
|
.release_thumb(&uid, |waiting| waiting == &picture);
|
||||||
|
});
|
||||||
|
|
||||||
|
let list = gtk4::ListView::builder()
|
||||||
|
.model(&selection)
|
||||||
|
.factory(&factory)
|
||||||
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
.focusable(false)
|
||||||
|
.build();
|
||||||
|
list.add_css_class("viewer-strip");
|
||||||
|
(list, selection)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The in-app lightbox: the photo or video, edge-to-edge on a dark backdrop,
|
/// The in-app lightbox: the photo or video, edge-to-edge on a dark backdrop,
|
||||||
@@ -1001,16 +1207,22 @@ fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_tim
|
|||||||
///
|
///
|
||||||
/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close
|
/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close
|
||||||
/// button, or a click on the backdrop beside the photo all dismiss it.
|
/// button, or a click on the backdrop beside the photo all dismiss it.
|
||||||
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
///
|
||||||
|
/// Returns the lightbox's window, for a caller that has to put the page
|
||||||
|
/// behind it back once it closes.
|
||||||
|
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Window {
|
||||||
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
|
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
|
||||||
|
|
||||||
let window = gtk4::Window::builder()
|
// An `adw::Window` for its breakpoints: the details panel lies over the
|
||||||
|
// photo once the window is too narrow to show both side by side.
|
||||||
|
let adw_window = adw::Window::builder()
|
||||||
.title(gettext("Photo"))
|
.title(gettext("Photo"))
|
||||||
.modal(true)
|
.modal(true)
|
||||||
.transient_for(&parent)
|
.transient_for(&parent)
|
||||||
.default_width(1100)
|
.default_width(1100)
|
||||||
.default_height(760)
|
.default_height(760)
|
||||||
.build();
|
.build();
|
||||||
|
let window: gtk4::Window = adw_window.clone().upcast();
|
||||||
window.add_css_class("photo-viewer-window");
|
window.add_css_class("photo-viewer-window");
|
||||||
|
|
||||||
let overlay = gtk4::Overlay::new();
|
let overlay = gtk4::Overlay::new();
|
||||||
@@ -1035,6 +1247,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.build();
|
.build();
|
||||||
|
wire_player(&video, &window);
|
||||||
let media = gtk4::Stack::new();
|
let media = gtk4::Stack::new();
|
||||||
media.add_named(&scroller, Some("photo"));
|
media.add_named(&scroller, Some("photo"));
|
||||||
media.add_named(&video, Some("video"));
|
media.add_named(&video, Some("video"));
|
||||||
@@ -1119,22 +1332,21 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
favorite_btn.add_css_class("flat");
|
favorite_btn.add_css_class("flat");
|
||||||
favorite_btn.add_css_class("viewer-action-btn");
|
favorite_btn.add_css_class("viewer-action-btn");
|
||||||
|
|
||||||
let slideshow_btn = gtk4::ToggleButton::builder()
|
|
||||||
.icon_name("media-playback-start-symbolic")
|
|
||||||
.tooltip_text(gettext("Slideshow (F5)"))
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
slideshow_btn.add_css_class("flat");
|
|
||||||
slideshow_btn.add_css_class("viewer-action-btn");
|
|
||||||
|
|
||||||
// Only shown for a shot stored as more than one file; `load_photo_group`
|
// Only shown for a shot stored as more than one file; `load_photo_group`
|
||||||
// decides that per photo.
|
// decides that per photo and labels it ("RAW", "Live", "2 of 3").
|
||||||
let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
|
let group_btn = gtk4::Button::builder()
|
||||||
group_btn.set_visible(false);
|
.valign(gtk4::Align::Center)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
group_btn.add_css_class("flat");
|
||||||
|
group_btn.add_css_class("viewer-group-pill");
|
||||||
|
|
||||||
let download_btn = action("document-save-symbolic", &gettext("Save a Copy…"));
|
// Everything used less often than the favorite and the details sits in
|
||||||
let delete_btn = action("user-trash-symbolic", &gettext("Move to Trash (Delete)"));
|
// one menu, named in words rather than in icons.
|
||||||
let open_ext_btn = action("document-open-symbolic", &gettext("Open With…"));
|
let more_btn = action("view-more-symbolic", &gettext("More"));
|
||||||
|
let fullscreen_btn = fullscreen_button(&window);
|
||||||
|
fullscreen_btn.add_css_class("flat");
|
||||||
|
fullscreen_btn.add_css_class("viewer-action-btn");
|
||||||
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
|
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
|
||||||
close_btn.add_css_class("viewer-close-btn");
|
close_btn.add_css_class("viewer-close-btn");
|
||||||
|
|
||||||
@@ -1144,14 +1356,26 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
top_bar.append(&group_btn);
|
top_bar.append(&group_btn);
|
||||||
top_bar.append(&favorite_btn);
|
top_bar.append(&favorite_btn);
|
||||||
top_bar.append(&info_toggle);
|
top_bar.append(&info_toggle);
|
||||||
top_bar.append(&slideshow_btn);
|
top_bar.append(&fullscreen_btn);
|
||||||
top_bar.append(&download_btn);
|
top_bar.append(&more_btn);
|
||||||
top_bar.append(&delete_btn);
|
|
||||||
top_bar.append(&open_ext_btn);
|
|
||||||
top_bar.append(&close_btn);
|
top_bar.append(&close_btn);
|
||||||
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
|
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
|
||||||
overlay.add_overlay(&top_reveal);
|
overlay.add_overlay(&top_reveal);
|
||||||
|
|
||||||
|
let (strip, strip_selection) = build_strip(ui);
|
||||||
|
let strip_scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::External)
|
||||||
|
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.child(&strip)
|
||||||
|
.build();
|
||||||
|
strip_scroll.add_css_class("viewer-strip-bar");
|
||||||
|
let strip_reveal = fade(
|
||||||
|
strip_scroll.upcast_ref(),
|
||||||
|
gtk4::Align::Fill,
|
||||||
|
gtk4::Align::End,
|
||||||
|
);
|
||||||
|
overlay.add_overlay(&strip_reveal);
|
||||||
|
|
||||||
// Details panel: docked beside the image as a real surface, so opening it
|
// Details panel: docked beside the image as a real surface, so opening it
|
||||||
// narrows the photo instead of covering part of it.
|
// narrows the photo instead of covering part of it.
|
||||||
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
@@ -1205,17 +1429,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
|
|
||||||
let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||||
info_panel.add_css_class("viewer-info-panel");
|
info_panel.add_css_class("viewer-info-panel");
|
||||||
info_panel.set_width_request(320);
|
|
||||||
info_panel.append(&info_scroll);
|
info_panel.append(&info_scroll);
|
||||||
|
|
||||||
// Set explicitly: the title's hexpand would otherwise propagate up and have
|
|
||||||
// the revealer take half the window even while it is closed.
|
|
||||||
let info_revealer = gtk4::Revealer::builder()
|
|
||||||
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
|
|
||||||
.hexpand(false)
|
|
||||||
.child(&info_panel)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let spinner = spinner();
|
let spinner = spinner();
|
||||||
spinner.set_halign(gtk4::Align::Center);
|
spinner.set_halign(gtk4::Align::Center);
|
||||||
spinner.set_valign(gtk4::Align::Center);
|
spinner.set_valign(gtk4::Align::Center);
|
||||||
@@ -1232,10 +1447,25 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
status_label.add_css_class("viewer-status");
|
status_label.add_css_class("viewer-status");
|
||||||
overlay.add_overlay(&status_label);
|
overlay.add_overlay(&status_label);
|
||||||
|
|
||||||
let body = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
// The panel docks beside the photo, or lies over it in a narrow window,
|
||||||
body.append(&overlay);
|
// where a click on the photo closes it again.
|
||||||
body.append(&info_revealer);
|
let info_split = adw::OverlaySplitView::builder()
|
||||||
window.set_child(Some(&body));
|
.content(&overlay)
|
||||||
|
.sidebar(&info_panel)
|
||||||
|
.sidebar_position(gtk4::PackType::End)
|
||||||
|
.min_sidebar_width(300.0)
|
||||||
|
.max_sidebar_width(360.0)
|
||||||
|
.show_sidebar(false)
|
||||||
|
.build();
|
||||||
|
let narrow = adw::Breakpoint::new(
|
||||||
|
adw::BreakpointCondition::parse("max-width: 800sp").expect("static breakpoint"),
|
||||||
|
);
|
||||||
|
narrow.add_setter(&info_split, "collapsed", Some(&true.to_value()));
|
||||||
|
adw_window.add_breakpoint(narrow);
|
||||||
|
let body = info_split.clone();
|
||||||
|
let toasts = adw::ToastOverlay::new();
|
||||||
|
toasts.set_child(Some(&body));
|
||||||
|
adw_window.set_content(Some(&toasts));
|
||||||
|
|
||||||
let viewer = Rc::new(Viewer {
|
let viewer = Rc::new(Viewer {
|
||||||
window: window.clone(),
|
window: window.clone(),
|
||||||
@@ -1250,7 +1480,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
prev: prev_btn.clone(),
|
prev: prev_btn.clone(),
|
||||||
next: next_btn.clone(),
|
next: next_btn.clone(),
|
||||||
info_toggle: info_toggle.clone(),
|
info_toggle: info_toggle.clone(),
|
||||||
info_revealer: info_revealer.clone(),
|
info_split: info_split.clone(),
|
||||||
info_rows,
|
info_rows,
|
||||||
info_map: info_map.clone(),
|
info_map: info_map.clone(),
|
||||||
favorite: favorite_btn.clone(),
|
favorite: favorite_btn.clone(),
|
||||||
@@ -1266,12 +1496,23 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
pinch_start: Cell::new(1.0),
|
pinch_start: Cell::new(1.0),
|
||||||
drag_start: Cell::new((0.0, 0.0)),
|
drag_start: Cell::new((0.0, 0.0)),
|
||||||
pointer: Cell::new((0.0, 0.0)),
|
pointer: Cell::new((0.0, 0.0)),
|
||||||
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
|
chrome: vec![
|
||||||
|
top_reveal.clone(),
|
||||||
|
prev_reveal.clone(),
|
||||||
|
next_reveal.clone(),
|
||||||
|
strip_reveal.clone(),
|
||||||
|
],
|
||||||
|
strip: strip.clone(),
|
||||||
|
strip_selection: strip_selection.clone(),
|
||||||
|
strip_reveal: strip_reveal.clone(),
|
||||||
chrome_hover: Cell::new(false),
|
chrome_hover: Cell::new(false),
|
||||||
chrome_source: RefCell::new(None),
|
chrome_source: RefCell::new(None),
|
||||||
slideshow: slideshow_btn.clone(),
|
more: more_btn.clone(),
|
||||||
|
menu_open: Cell::new(false),
|
||||||
|
slideshow: Cell::new(false),
|
||||||
slide_source: RefCell::new(None),
|
slide_source: RefCell::new(None),
|
||||||
slide_fullscreened: Cell::new(false),
|
slide_fullscreened: Cell::new(false),
|
||||||
|
toasts,
|
||||||
});
|
});
|
||||||
|
|
||||||
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
|
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
|
||||||
@@ -1297,7 +1538,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
body.add_controller(motion);
|
body.add_controller(motion);
|
||||||
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
|
for revealer in [&top_reveal, &prev_reveal, &next_reveal, &strip_reveal] {
|
||||||
let hover = gtk4::EventControllerMotion::new();
|
let hover = gtk4::EventControllerMotion::new();
|
||||||
let viewer_enter = Rc::downgrade(&viewer);
|
let viewer_enter = Rc::downgrade(&viewer);
|
||||||
hover.connect_enter(move |_, _, _| {
|
hover.connect_enter(move |_, _, _| {
|
||||||
@@ -1413,13 +1654,52 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
w_close.close();
|
w_close.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A click on a thumbnail selects it; step there. Every selection the
|
||||||
|
// lightbox sets itself is the photo on screen, and the model's own changes
|
||||||
|
// (a page landing, a favorite rewritten in place) keep the selected photo
|
||||||
|
// or drop it, so a selection elsewhere can only be the user's.
|
||||||
|
let ui_strip = ui.clone();
|
||||||
|
let viewer_strip = Rc::downgrade(&viewer);
|
||||||
|
strip_selection.connect_selection_changed(move |selection, _, _| {
|
||||||
|
let Some(viewer) = viewer_strip.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let picked = selection.selected();
|
||||||
|
let Some(current) = find_photo_index(&ui_strip.gallery.model, &viewer.uid.borrow()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if picked != gtk4::INVALID_LIST_POSITION && picked != current {
|
||||||
|
navigate_photo(&ui_strip, &viewer, picked as i32 - current as i32);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let viewer_items = Rc::downgrade(&viewer);
|
||||||
|
let items_handler = ui
|
||||||
|
.gallery
|
||||||
|
.model
|
||||||
|
.connect_items_changed(move |model, _, _, _| {
|
||||||
|
let Some(viewer) = viewer_items.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(index) = find_photo_index(model, &viewer.uid.borrow()) {
|
||||||
|
viewer.strip_selection.set_selected(index);
|
||||||
|
}
|
||||||
|
sync_strip(&viewer);
|
||||||
|
});
|
||||||
|
|
||||||
// The one strong handle on the viewer. Every handler holds a weak one: a
|
// The one strong handle on the viewer. Every handler holds a weak one: a
|
||||||
// strong one in a handler of a widget the viewer holds is a cycle, which
|
// strong one in a handler of a widget the viewer holds is a cycle, which
|
||||||
// kept every closed lightbox, its window and its full-size photo in memory
|
// kept every closed lightbox, its window and its full-size photo in memory
|
||||||
// for the life of the app.
|
// for the life of the app.
|
||||||
let keep = RefCell::new(Some(viewer.clone()));
|
let keep = RefCell::new(Some(viewer.clone()));
|
||||||
|
let ui_close = ui.clone();
|
||||||
|
let items_handler = RefCell::new(Some(items_handler));
|
||||||
window.connect_close_request(move |_| {
|
window.connect_close_request(move |_| {
|
||||||
|
// The gallery's model outlives the lightbox.
|
||||||
|
if let Some(handler) = items_handler.borrow_mut().take() {
|
||||||
|
ui_close.gallery.model.disconnect(handler);
|
||||||
|
}
|
||||||
if let Some(viewer) = keep.borrow_mut().take() {
|
if let Some(viewer) = keep.borrow_mut().take() {
|
||||||
|
reveal_photo(&ui_close, &viewer.uid.borrow());
|
||||||
stop_video(&viewer);
|
stop_video(&viewer);
|
||||||
disarm_slide(&viewer);
|
disarm_slide(&viewer);
|
||||||
if let Some(source) = viewer.chrome_source.borrow_mut().take() {
|
if let Some(source) = viewer.chrome_source.borrow_mut().take() {
|
||||||
@@ -1435,21 +1715,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
let Some(viewer_info) = viewer_info.upgrade() else {
|
let Some(viewer_info) = viewer_info.upgrade() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
viewer_info
|
viewer_info.info_split.set_show_sidebar(toggle.is_active());
|
||||||
.info_revealer
|
|
||||||
.set_reveal_child(toggle.is_active());
|
|
||||||
});
|
});
|
||||||
|
// A collapsed panel also closes on its own, by a click on the photo.
|
||||||
let ui_slides = ui.clone();
|
let toggle_info = info_toggle.clone();
|
||||||
let viewer_slides = Rc::downgrade(&viewer);
|
info_split.connect_show_sidebar_notify(move |split| {
|
||||||
slideshow_btn.connect_toggled(move |toggle| {
|
if toggle_info.is_active() != split.shows_sidebar() {
|
||||||
let Some(viewer_slides) = viewer_slides.upgrade() else {
|
toggle_info.set_active(split.shows_sidebar());
|
||||||
return;
|
|
||||||
};
|
|
||||||
if toggle.is_active() {
|
|
||||||
start_slideshow(&ui_slides, &viewer_slides);
|
|
||||||
} else {
|
|
||||||
stop_slideshow(&viewer_slides);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1458,18 +1730,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
|
|
||||||
let ui_map = ui.clone();
|
let ui_map = ui.clone();
|
||||||
let viewer_map = Rc::downgrade(&viewer);
|
let viewer_map = Rc::downgrade(&viewer);
|
||||||
let w_map = window.downgrade();
|
|
||||||
info_map.connect_clicked(move |_| {
|
info_map.connect_clicked(move |_| {
|
||||||
let Some(w_map) = w_map.upgrade() else {
|
if let Some(viewer_map) = viewer_map.upgrade() {
|
||||||
return;
|
show_photo_on_map(&ui_map, &viewer_map);
|
||||||
};
|
|
||||||
let Some(viewer_map) = viewer_map.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let coords = *viewer_map.coords.borrow();
|
|
||||||
if let Some((lat, lon)) = coords {
|
|
||||||
w_map.close();
|
|
||||||
show_on_map(&ui_map, lat, lon);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1498,6 +1761,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
let failed = match rx.recv().await {
|
let failed = match rx.recv().await {
|
||||||
Ok(Ok(Response::Ok { .. })) => {
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
set_gallery_favorite(&ui_result, &uid, favorite);
|
set_gallery_favorite(&ui_result, &uid, favorite);
|
||||||
|
// The tiles hold their own copy of the photo, so the star
|
||||||
|
// shows on the grid only once it is laid out again.
|
||||||
|
repaint_gallery(&ui_result);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, .. })) => Some(message),
|
Ok(Ok(Response::Error { message, .. })) => Some(message),
|
||||||
@@ -1518,24 +1784,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deleting the photo on screen closes the lightbox: what it was showing is
|
|
||||||
// gone, and leaving it open on a trashed photo invites a second Delete on
|
|
||||||
// something the user cannot see.
|
|
||||||
let ui_delete = ui.clone();
|
|
||||||
let viewer_delete = Rc::downgrade(&viewer);
|
|
||||||
let w_delete = window.downgrade();
|
|
||||||
delete_btn.connect_clicked(move |_| {
|
|
||||||
let Some(w_delete) = w_delete.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(viewer_delete) = viewer_delete.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let uid = viewer_delete.uid.borrow().clone();
|
|
||||||
w_delete.close();
|
|
||||||
trash_photos(&ui_delete, vec![uid], false);
|
|
||||||
});
|
|
||||||
|
|
||||||
let ui_group = ui.clone();
|
let ui_group = ui.clone();
|
||||||
let viewer_group = Rc::downgrade(&viewer);
|
let viewer_group = Rc::downgrade(&viewer);
|
||||||
group_btn.connect_clicked(move |_| {
|
group_btn.connect_clicked(move |_| {
|
||||||
@@ -1551,38 +1799,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_download = ui.clone();
|
let ui_more = ui.clone();
|
||||||
let w_download = window.downgrade();
|
let viewer_more = Rc::downgrade(&viewer);
|
||||||
let viewer_download = Rc::downgrade(&viewer);
|
more_btn.connect_clicked(move |_| {
|
||||||
download_btn.connect_clicked(move |_| {
|
if let Some(viewer_more) = viewer_more.upgrade() {
|
||||||
let Some(w_download) = w_download.upgrade() else {
|
open_viewer_menu(&ui_more, &viewer_more);
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(viewer_download) = viewer_download.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(path) = viewer_download.path.borrow().as_deref() {
|
|
||||||
let name = viewer_download.name.borrow();
|
|
||||||
save_photo_to_disk(
|
|
||||||
&ui_download,
|
|
||||||
&w_download,
|
|
||||||
path,
|
|
||||||
name.as_deref().unwrap_or("photo"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let w_ext = window.downgrade();
|
|
||||||
let viewer_ext = Rc::downgrade(&viewer);
|
|
||||||
open_ext_btn.connect_clicked(move |_| {
|
|
||||||
let Some(w_ext) = w_ext.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(viewer_ext) = viewer_ext.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(path) = viewer_ext.path.borrow().as_deref() {
|
|
||||||
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1660,13 +1881,22 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
return glib::Propagation::Proceed;
|
return glib::Propagation::Proceed;
|
||||||
};
|
};
|
||||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||||
|
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
||||||
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
||||||
|
if playing_video
|
||||||
|
&& let Some(name) = key.name()
|
||||||
|
&& player_key(&viewer_key.video, &name, state)
|
||||||
|
{
|
||||||
|
return glib::Propagation::Stop;
|
||||||
|
}
|
||||||
match key.name().as_deref() {
|
match key.name().as_deref() {
|
||||||
Some("space") if playing_video => {
|
Some("space") if playing_video => {
|
||||||
if let Some(stream) = viewer_key.video.media_stream() {
|
if let Some(stream) = viewer_key.video.media_stream() {
|
||||||
stream.set_playing(!stream.is_playing());
|
stream.set_playing(!stream.is_playing());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Alt+Left is Back everywhere else in the app.
|
||||||
|
Some("Left") if alt => w_key.close(),
|
||||||
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
|
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
|
||||||
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
|
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
|
||||||
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
|
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
|
||||||
@@ -1675,30 +1905,20 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
|
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
|
||||||
Some("0" | "KP_0") => reset_zoom(&viewer_key),
|
Some("0" | "KP_0") => reset_zoom(&viewer_key),
|
||||||
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
|
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
|
||||||
Some("F5") => viewer_key
|
Some("F5") => toggle_slideshow(&ui_key, &viewer_key),
|
||||||
.slideshow
|
|
||||||
.set_active(!viewer_key.slideshow.is_active()),
|
|
||||||
// Escape ends a slideshow before it closes anything.
|
// Escape ends a slideshow before it closes anything.
|
||||||
Some("Escape") if viewer_key.slideshow.is_active() => {
|
Some("Escape") if viewer_key.slideshow.get() => stop_slideshow(&viewer_key),
|
||||||
viewer_key.slideshow.set_active(false)
|
Some("F10" | "Menu") => open_viewer_menu(&ui_key, &viewer_key),
|
||||||
}
|
|
||||||
Some("i") => viewer_key
|
Some("i") => viewer_key
|
||||||
.info_toggle
|
.info_toggle
|
||||||
.set_active(!viewer_key.info_toggle.is_active()),
|
.set_active(!viewer_key.info_toggle.is_active()),
|
||||||
Some("f" | "F11") => {
|
Some("f" | "F11") => toggle_fullscreen(&w_key),
|
||||||
if w_key.is_fullscreen() {
|
Some("Delete" | "KP_Delete") => trash_shown_photo(&ui_key, &viewer_key),
|
||||||
w_key.unfullscreen();
|
|
||||||
} else {
|
|
||||||
w_key.fullscreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some("Delete" | "KP_Delete") => {
|
|
||||||
let uid = viewer_key.uid.borrow().clone();
|
|
||||||
w_key.close();
|
|
||||||
trash_photos(&ui_key, vec![uid], false);
|
|
||||||
}
|
|
||||||
Some("Escape" | "q") => w_key.close(),
|
Some("Escape" | "q") => w_key.close(),
|
||||||
Some("w") if ctrl => w_key.close(),
|
Some("w") if ctrl => w_key.close(),
|
||||||
|
Some("c") if ctrl && viewer_key.path.borrow().is_some() && !playing_video => {
|
||||||
|
copy_shown_photo(&ui_key, &viewer_key)
|
||||||
|
}
|
||||||
_ => return glib::Propagation::Proceed,
|
_ => return glib::Propagation::Proceed,
|
||||||
}
|
}
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
@@ -1706,6 +1926,113 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
window.add_controller(key_controller);
|
window.add_controller(key_controller);
|
||||||
|
|
||||||
window.present();
|
window.present();
|
||||||
|
window
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ⋮ menu: what the lightbox can do with the photo on screen besides
|
||||||
|
/// starring it and showing its details. Built when it opens, so it offers
|
||||||
|
/// only what applies to this photo.
|
||||||
|
fn open_viewer_menu(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let weak = Rc::downgrade(viewer);
|
||||||
|
let with_viewer = move |run: fn(&Rc<Ui>, &Rc<Viewer>), ui: &Rc<Ui>| {
|
||||||
|
let (ui, weak) = (ui.clone(), weak.clone());
|
||||||
|
move || {
|
||||||
|
if let Some(viewer) = weak.upgrade() {
|
||||||
|
run(&ui, &viewer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
menu.item(&gettext("Slideshow"), with_viewer(toggle_slideshow, ui));
|
||||||
|
if viewer.media.visible_child_name().as_deref() == Some("video") {
|
||||||
|
let video = viewer.video.clone();
|
||||||
|
menu.toggle(&gettext("Repeat"), video.is_loop(), move |on| {
|
||||||
|
video.set_loop(on)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
menu.section();
|
||||||
|
// These need the full-size file, which may still be downloading.
|
||||||
|
if viewer.path.borrow().is_some() {
|
||||||
|
let still = viewer.media.visible_child_name().as_deref() == Some("photo");
|
||||||
|
if still {
|
||||||
|
// Translators: menu item that puts the photo on the clipboard as an image.
|
||||||
|
menu.item(&gettext("Copy Image"), with_viewer(copy_shown_photo, ui));
|
||||||
|
}
|
||||||
|
menu.item(&gettext("Save a Copy…"), with_viewer(save_shown_photo, ui));
|
||||||
|
menu.item(&gettext("Open With…"), with_viewer(open_shown_photo, ui));
|
||||||
|
}
|
||||||
|
if photo_shareable(ui) {
|
||||||
|
menu.item(&gettext("Share…"), with_viewer(share_shown_photo, ui));
|
||||||
|
}
|
||||||
|
// A RAW is not on the timeline, and an album holds the shot it belongs to.
|
||||||
|
if find_photo_index(&ui.gallery.model, &viewer.uid.borrow()).is_some() {
|
||||||
|
menu.item(
|
||||||
|
&gettext("Add to Album…"),
|
||||||
|
with_viewer(add_shown_photo_to_album, ui),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if viewer.coords.borrow().is_some() {
|
||||||
|
menu.item(&gettext("Show on Map"), with_viewer(show_photo_on_map, ui));
|
||||||
|
}
|
||||||
|
menu.section();
|
||||||
|
menu.item(
|
||||||
|
&gettext("Move to Trash…"),
|
||||||
|
with_viewer(trash_shown_photo, ui),
|
||||||
|
);
|
||||||
|
|
||||||
|
let button = &viewer.more;
|
||||||
|
let popover = menu.popup_at(
|
||||||
|
button,
|
||||||
|
f64::from(button.width()) / 2.0,
|
||||||
|
f64::from(button.height()),
|
||||||
|
);
|
||||||
|
viewer.menu_open.set(true);
|
||||||
|
let weak = Rc::downgrade(viewer);
|
||||||
|
popover.connect_closed(move |_| {
|
||||||
|
if let Some(viewer) = weak.upgrade() {
|
||||||
|
viewer.menu_open.set(false);
|
||||||
|
// A slideshow started from the menu keeps the controls hidden.
|
||||||
|
if !viewer.slideshow.get() {
|
||||||
|
show_chrome(&viewer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
if let Some(path) = viewer.path.borrow().as_deref() {
|
||||||
|
let name = viewer.name.borrow();
|
||||||
|
save_photo_to_disk(viewer, path, name.as_deref().unwrap_or("photo"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
if let Some(path) = viewer.path.borrow().as_deref() {
|
||||||
|
open_with(&viewer.window, path, viewer.name.borrow().as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_shown_photo_to_album(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let uid = viewer.uid.borrow().clone();
|
||||||
|
prompt_add_to_album(ui, vec![uid], Some(viewer.window.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the lightbox and show where the photo was taken.
|
||||||
|
fn show_photo_on_map(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let coords = *viewer.coords.borrow();
|
||||||
|
if let Some((lat, lon)) = coords {
|
||||||
|
viewer.window.close();
|
||||||
|
show_on_map(ui, lat, lon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask, then move the photo on screen to the trash. Confirmed, the lightbox
|
||||||
|
/// closes first: what it was showing is gone, and leaving it open on a
|
||||||
|
/// trashed photo invites a second Delete on something the user cannot see.
|
||||||
|
fn trash_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let uid = viewer.uid.borrow().clone();
|
||||||
|
let window = viewer.window.clone();
|
||||||
|
ask_trash_photos(ui, vec![uid], Some(window.clone()), move || window.close());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
|
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
|
||||||
|
|||||||
+1369
-254
File diff suppressed because it is too large.
Load diff
@@ -267,7 +267,12 @@ fn want_map_thumb(ui: &Rc<Ui>, photo: &PhotoItem) {
|
|||||||
}
|
}
|
||||||
// A town's card may be waiting for the same photo: share its picture
|
// A town's card may be waiting for the same photo: share its picture
|
||||||
// rather than take its place in the queue.
|
// rather than take its place in the queue.
|
||||||
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
let waiting = ui
|
||||||
|
.gallery
|
||||||
|
.thumb_wanted
|
||||||
|
.borrow()
|
||||||
|
.get(&photo.uid)
|
||||||
|
.and_then(|waiting| waiting.first().cloned());
|
||||||
let picture = waiting.unwrap_or_else(|| {
|
let picture = waiting.unwrap_or_else(|| {
|
||||||
let picture = gtk4::Picture::new();
|
let picture = gtk4::Picture::new();
|
||||||
want_thumb(ui, photo, &picture);
|
want_thumb(ui, photo, &picture);
|
||||||
@@ -362,7 +367,12 @@ fn show_spot_photos(ui: &Rc<Ui>, photos: Vec<(PlaceInfo, PhotoItem)>, (x, y): (f
|
|||||||
/// Show photo's thumbnail in `picture`, sharing one another widget is
|
/// Show photo's thumbnail in `picture`, sharing one another widget is
|
||||||
/// already waiting for.
|
/// already waiting for.
|
||||||
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
||||||
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
let waiting = ui
|
||||||
|
.gallery
|
||||||
|
.thumb_wanted
|
||||||
|
.borrow()
|
||||||
|
.get(&photo.uid)
|
||||||
|
.and_then(|waiting| waiting.first().cloned());
|
||||||
match waiting {
|
match waiting {
|
||||||
Some(waiting) => {
|
Some(waiting) => {
|
||||||
waiting
|
waiting
|
||||||
@@ -392,21 +402,45 @@ fn load_place_locations(ui: &Rc<Ui>, id: u32) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open `place` and then photo `uid` in it, paging through the town until
|
/// Open photo `uid` of `place` from the map or a spot's list, paging through
|
||||||
/// the photo has loaded.
|
/// the town until the photo has loaded.
|
||||||
|
///
|
||||||
|
/// The lightbox steps through the gallery's model, so the town is opened
|
||||||
|
/// behind it. Closing the lightbox goes back to the map rather than leaving
|
||||||
|
/// the user in a town timeline they never asked for.
|
||||||
fn open_place_photo(ui: &Rc<Ui>, place: PlaceInfo, uid: String) {
|
fn open_place_photo(ui: &Rc<Ui>, place: PlaceInfo, uid: String) {
|
||||||
|
let id = place.id;
|
||||||
open_place(ui, place);
|
open_place(ui, place);
|
||||||
view_when_loaded(ui, uid);
|
view_when_loaded(ui, id, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_when_loaded(ui: &Rc<Ui>, uid: String) {
|
/// The open place's id, if a place is open.
|
||||||
|
fn open_place_id(ui: &Ui) -> Option<u32> {
|
||||||
|
ui.gallery.place.borrow().as_ref().map(|place| place.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_when_loaded(ui: &Rc<Ui>, id: u32, uid: String) {
|
||||||
let ui_page = ui.clone();
|
let ui_page = ui.clone();
|
||||||
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
|
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
|
||||||
let ui = ui_page;
|
let ui = ui_page;
|
||||||
|
// The user went elsewhere while the town loaded: whatever page lands
|
||||||
|
// now belongs to that, and must not pop the lightbox up over it.
|
||||||
|
if open_place_id(&ui) != Some(id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if find_photo_index(&ui.gallery.model, &uid).is_some() {
|
if find_photo_index(&ui.gallery.model, &uid).is_some() {
|
||||||
open_photo_viewer(&ui, uid);
|
let window = open_photo_viewer(&ui, uid);
|
||||||
} else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() {
|
let ui_close = ui.clone();
|
||||||
view_when_loaded(&ui, uid);
|
window.connect_close_request(move |_| {
|
||||||
|
// Only if the town is still what the lightbox was showing: "Show
|
||||||
|
// on map" in the lightbox has already moved on by itself.
|
||||||
|
if open_place_id(&ui_close) == Some(id) {
|
||||||
|
show_place_grid(&ui_close);
|
||||||
|
}
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
} else if ui.gallery.has_more.get() {
|
||||||
|
view_when_loaded(&ui, id, uid);
|
||||||
ui.gallery.burst.set(true);
|
ui.gallery.burst.set(true);
|
||||||
load_gallery(&ui, true);
|
load_gallery(&ui, true);
|
||||||
}
|
}
|
||||||
@@ -422,9 +456,7 @@ fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
|
|||||||
|
|
||||||
// As in an album: no filters, no Upload, no view switch — back leads out.
|
// As in an album: no filters, no Upload, no view switch — back leads out.
|
||||||
ui.gallery.filters.set_visible(false);
|
ui.gallery.filters.set_visible(false);
|
||||||
ui.gallery.view_switch.widget().set_visible(false);
|
sync_header(ui);
|
||||||
ui.gallery.upload.set_visible(false);
|
|
||||||
ui.gallery.back.set_visible(true);
|
|
||||||
ui.gallery.kind.set(None);
|
ui.gallery.kind.set(None);
|
||||||
ui.gallery.range.set(None);
|
ui.gallery.range.set(None);
|
||||||
|
|
||||||
@@ -439,9 +471,7 @@ pub(crate) fn close_place(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.title.set_title(&gettext("Photos"));
|
ui.gallery.title.set_title(&gettext("Photos"));
|
||||||
ui.gallery.view_switch.widget().set_visible(true);
|
sync_header(ui);
|
||||||
ui.gallery.upload.set_visible(true);
|
|
||||||
ui.gallery.back.set_visible(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the place grid's own status page (loading, empty, or an error).
|
/// Show the place grid's own status page (loading, empty, or an error).
|
||||||
|
|||||||
@@ -279,10 +279,7 @@ pub(crate) fn wire_shared(ui: &Rc<Ui>, widgets: &SharedWidgets) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let ui_back = ui.clone();
|
let ui_back = ui.clone();
|
||||||
widgets.back.connect_clicked(move |_| {
|
widgets.back.connect_clicked(move |_| shared_back(&ui_back));
|
||||||
ui_back.shared.nav.borrow_mut().pop();
|
|
||||||
load_shared(&ui_back);
|
|
||||||
});
|
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
widgets
|
widgets
|
||||||
.retry
|
.retry
|
||||||
@@ -318,6 +315,14 @@ pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &
|
|||||||
ui.shared.content.set_visible_child_name("status");
|
ui.shared.content.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Back out of a shared folder to the level above: the page's Back button, and
|
||||||
|
/// Alt+Left or Backspace. A no-op at the top.
|
||||||
|
pub(crate) fn shared_back(ui: &Rc<Ui>) {
|
||||||
|
if ui.shared.nav.borrow_mut().pop().is_some() {
|
||||||
|
load_shared(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch the top level (shared-with-me, invitations, saved links) in parallel
|
/// Fetch the top level (shared-with-me, invitations, saved links) in parallel
|
||||||
/// and repaint the page once all three land.
|
/// and repaint the page once all three land.
|
||||||
///
|
///
|
||||||
@@ -443,6 +448,7 @@ fn repaint_shared_files(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
let nav = ui.shared.nav.borrow().clone();
|
let nav = ui.shared.nav.borrow().clone();
|
||||||
repaint_shared_crumb(ui, &nav);
|
repaint_shared_crumb(ui, &nav);
|
||||||
let top = nav.is_empty();
|
let top = nav.is_empty();
|
||||||
|
let another_folder = ui.shared.listed.borrow().as_ref() != Some(&nav);
|
||||||
*ui.shared.listed.borrow_mut() = Some(nav);
|
*ui.shared.listed.borrow_mut() = Some(nav);
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
ui.shared.files.model.remove_all();
|
ui.shared.files.model.remove_all();
|
||||||
@@ -467,8 +473,11 @@ fn repaint_shared_files(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
}
|
}
|
||||||
ui.shared.files_content.set_visible_child_name("list");
|
ui.shared.files_content.set_visible_child_name("list");
|
||||||
// Only the rows that changed are swapped, so a refresh keeps the selection
|
// Only the rows that changed are swapped, so a refresh keeps the selection
|
||||||
// and the scroll position.
|
// and the scroll position. Another folder starts from its first entry.
|
||||||
replace_items(&ui.shared.files.model, entries);
|
replace_items(&ui.shared.files.model, entries);
|
||||||
|
if another_folder {
|
||||||
|
ui.shared.files.scroll_to_top();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the breadcrumb trail for `nav`: "Shared with me", then each folder
|
/// Rebuild the breadcrumb trail for `nav`: "Shared with me", then each folder
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ use crate::*;
|
|||||||
pub(crate) struct ToggleGroup {
|
pub(crate) struct ToggleGroup {
|
||||||
widget: gtk4::Widget,
|
widget: gtk4::Widget,
|
||||||
options: Options,
|
options: Options,
|
||||||
|
/// Each option's label, kept while [`Self::set_compact`] shows the icons
|
||||||
|
/// alone.
|
||||||
|
labels: Rc<RefCell<Vec<String>>>,
|
||||||
|
/// Each option's own tooltip, as given.
|
||||||
|
tooltips: Rc<[Option<String>]>,
|
||||||
|
compact: Rc<Cell<bool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -47,6 +53,9 @@ impl ToggleGroup {
|
|||||||
Self {
|
Self {
|
||||||
widget,
|
widget,
|
||||||
options: Options::Toggles(toggles),
|
options: Options::Toggles(toggles),
|
||||||
|
labels: Rc::new(RefCell::new(labels_of(options))),
|
||||||
|
tooltips: tooltips_of(options),
|
||||||
|
compact: Rc::default(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||||
@@ -71,6 +80,9 @@ impl ToggleGroup {
|
|||||||
Self {
|
Self {
|
||||||
widget: bar.upcast(),
|
widget: bar.upcast(),
|
||||||
options: Options::Buttons(buttons),
|
options: Options::Buttons(buttons),
|
||||||
|
labels: Rc::new(RefCell::new(labels_of(options))),
|
||||||
|
tooltips: tooltips_of(options),
|
||||||
|
compact: Rc::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,15 +127,69 @@ impl ToggleGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn set_label(&self, index: u32, label: &str) {
|
pub(crate) fn set_label(&self, index: u32, label: &str) {
|
||||||
|
if let Some(stored) = self.labels.borrow_mut().get_mut(index as usize) {
|
||||||
|
*stored = label.to_string();
|
||||||
|
}
|
||||||
|
self.show_label(index, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put an icon before each option's label, in option order.
|
||||||
|
pub(crate) fn set_icons(&self, icons: &[&str]) {
|
||||||
|
match &self.options {
|
||||||
|
Options::Toggles(toggles) => {
|
||||||
|
for (toggle, icon) in toggles.iter().zip(icons) {
|
||||||
|
toggle.set_property("icon-name", *icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Options::Buttons(buttons) => {
|
||||||
|
let labels = self.labels.borrow();
|
||||||
|
for ((button, icon), label) in buttons.iter().zip(icons).zip(labels.iter()) {
|
||||||
|
let face = adw::ButtonContent::builder()
|
||||||
|
.icon_name(*icon)
|
||||||
|
.label(label.as_str())
|
||||||
|
.build();
|
||||||
|
button.set_child(Some(&face));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the icons alone, for a narrow window, with each label moved to
|
||||||
|
/// the option's tooltip; or bring the labels back.
|
||||||
|
pub(crate) fn set_compact(&self, compact: bool) {
|
||||||
|
if self.compact.replace(compact) == compact {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let labels = self.labels.borrow().clone();
|
||||||
|
for (index, label) in labels.iter().enumerate() {
|
||||||
|
self.show_label(index as u32, label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show `label` on option `index`, or only in its tooltip while compact.
|
||||||
|
fn show_label(&self, index: u32, label: &str) {
|
||||||
|
let compact = self.compact.get();
|
||||||
|
let shown = if compact { "" } else { label };
|
||||||
|
let given = self.tooltips.get(index as usize).cloned().flatten();
|
||||||
|
let tooltip = if compact {
|
||||||
|
Some(label.to_string())
|
||||||
|
} else {
|
||||||
|
given
|
||||||
|
};
|
||||||
match &self.options {
|
match &self.options {
|
||||||
Options::Toggles(toggles) => {
|
Options::Toggles(toggles) => {
|
||||||
if let Some(toggle) = toggles.get(index as usize) {
|
if let Some(toggle) = toggles.get(index as usize) {
|
||||||
toggle.set_property("label", label);
|
toggle.set_property("label", shown);
|
||||||
|
toggle.set_property("tooltip", tooltip.unwrap_or_default());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Options::Buttons(buttons) => {
|
Options::Buttons(buttons) => {
|
||||||
if let Some(button) = buttons.get(index as usize) {
|
if let Some(button) = buttons.get(index as usize) {
|
||||||
button.set_label(label);
|
match button.child().and_downcast::<adw::ButtonContent>() {
|
||||||
|
Some(face) => face.set_label(shown),
|
||||||
|
None => button.set_label(shown),
|
||||||
|
}
|
||||||
|
button.set_tooltip_text(tooltip.as_deref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,6 +337,19 @@ fn shortcuts_dialog(groups: &[(String, Vec<(&str, String)>)]) -> adw::Dialog {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The labels of `(label, tooltip)` options.
|
||||||
|
fn labels_of(options: &[(&str, Option<&str>)]) -> Vec<String> {
|
||||||
|
options.iter().map(|(label, _)| label.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tooltips of `(label, tooltip)` options.
|
||||||
|
fn tooltips_of(options: &[(&str, Option<&str>)]) -> Rc<[Option<String>]> {
|
||||||
|
options
|
||||||
|
.iter()
|
||||||
|
.map(|(_, tooltip)| tooltip.map(str::to_string))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -224,6 +224,49 @@ impl FileList {
|
|||||||
.set_visible_child_name(if list { "list" } else { "grid" });
|
.set_visible_child_name(if list { "list" } else { "grid" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bring both views back to the first entry. For a listing of something
|
||||||
|
/// else: [`replace_items`] keeps the scroll position, which is right for a
|
||||||
|
/// refresh, but another folder would open where the last one was left —
|
||||||
|
/// often at its bottom.
|
||||||
|
pub(crate) fn scroll_to_top(&self) {
|
||||||
|
if self.model.n_items() == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.grid.scroll_to(0, gtk4::ListScrollFlags::NONE, None);
|
||||||
|
self.column_view
|
||||||
|
.scroll_to(0, None, gtk4::ListScrollFlags::NONE, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far the view on screen is scrolled, in px from the top.
|
||||||
|
pub(crate) fn scroll_offset(&self) -> f64 {
|
||||||
|
self.views
|
||||||
|
.visible_child()
|
||||||
|
.and_downcast::<gtk4::ScrolledWindow>()
|
||||||
|
.map_or(0.0, |scroll| scroll.vadjustment().value())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the entries named `selected` and scroll the view on screen to
|
||||||
|
/// `offset`, as they were when the folder was left. The scroll waits for
|
||||||
|
/// an idle, when the views have measured the rows just put in.
|
||||||
|
pub(crate) fn restore(&self, offset: f64, selected: &[String]) {
|
||||||
|
self.selection.unselect_all();
|
||||||
|
for i in 0..self.selection.n_items() {
|
||||||
|
if entry_at(Some(&self.selection), i)
|
||||||
|
.is_some_and(|entry| selected.contains(&entry.name))
|
||||||
|
{
|
||||||
|
self.selection.select_item(i, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(scroll) = self
|
||||||
|
.views
|
||||||
|
.visible_child()
|
||||||
|
.and_downcast::<gtk4::ScrolledWindow>()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
glib::idle_add_local_once(move || scroll.vadjustment().set_value(offset));
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the list, rather than the grid, is on screen.
|
/// Whether the list, rather than the grid, is on screen.
|
||||||
pub(crate) fn shows_list(&self) -> bool {
|
pub(crate) fn shows_list(&self) -> bool {
|
||||||
self.views.visible_child_name().as_deref() == Some("list")
|
self.views.visible_child_name().as_deref() == Some("list")
|
||||||
@@ -478,7 +521,7 @@ pub(crate) fn text_column(
|
|||||||
|
|
||||||
fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) {
|
fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) {
|
||||||
resize_file_thumbnail(thumbnail, size, grid_fallback_size(size));
|
resize_file_thumbnail(thumbnail, size, grid_fallback_size(size));
|
||||||
let name_width = (size / 6 + 1).clamp(8, 24);
|
let name_width = (size / 6 + 1).clamp(8, 40);
|
||||||
label.set_width_chars(name_width);
|
label.set_width_chars(name_width);
|
||||||
label.set_max_width_chars(name_width);
|
label.set_max_width_chars(name_width);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,14 @@ impl ActionMenu {
|
|||||||
|
|
||||||
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
|
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
|
||||||
/// popover removes itself once dismissed; it waits for an idle turn, so the
|
/// popover removes itself once dismissed; it waits for an idle turn, so the
|
||||||
/// item that closed it still finds its action.
|
/// item that closed it still finds its action. Returned for a caller that
|
||||||
pub(crate) fn popup_at(self, anchor: &impl IsA<gtk4::Widget>, x: f64, y: f64) {
|
/// has to know when it closes.
|
||||||
|
pub(crate) fn popup_at(
|
||||||
|
self,
|
||||||
|
anchor: &impl IsA<gtk4::Widget>,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
) -> gtk4::PopoverMenu {
|
||||||
let (menu, group) = self.finish();
|
let (menu, group) = self.finish();
|
||||||
let anchor = anchor.as_ref();
|
let anchor = anchor.as_ref();
|
||||||
let host = popover_host(anchor);
|
let host = popover_host(anchor);
|
||||||
@@ -122,6 +128,7 @@ impl ActionMenu {
|
|||||||
glib::idle_add_local_once(move || p.unparent());
|
glib::idle_add_local_once(move || p.unparent());
|
||||||
});
|
});
|
||||||
popover.popup();
|
popover.popup();
|
||||||
|
popover
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A flat ⋮ button that opens this menu.
|
/// A flat ⋮ button that opens this menu.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ pub(crate) mod crumb;
|
|||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
pub(crate) mod file_list;
|
pub(crate) mod file_list;
|
||||||
pub(crate) mod menu;
|
pub(crate) mod menu;
|
||||||
|
pub(crate) mod photo_drag;
|
||||||
|
pub(crate) mod player;
|
||||||
pub(crate) mod scrubber;
|
pub(crate) mod scrubber;
|
||||||
pub(crate) mod share_dialog;
|
pub(crate) mod share_dialog;
|
||||||
pub(crate) mod thumbnails;
|
pub(crate) mod thumbnails;
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
//! Dragging photos out of the timeline into a file manager or another app.
|
||||||
|
//!
|
||||||
|
//! A photo is not a file anywhere the receiver can read until it has been
|
||||||
|
//! downloaded, and a drag has to start the moment the pointer moves. So the
|
||||||
|
//! drag offers a list of files without having them: [`PhotoDrag`] downloads
|
||||||
|
//! the photos only when the receiver asks for the list, which it does once the
|
||||||
|
//! photos are dropped on it, and then hands over copies under their own names.
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
use gtk4::gdk;
|
||||||
|
use gtk4::gdk::subclass::prelude::*;
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// The one format a drag of photos offers. GTK turns it into a file list for
|
||||||
|
/// a receiver in this app, and every file manager reads it.
|
||||||
|
const URI_LIST: &str = "text/uri-list";
|
||||||
|
|
||||||
|
/// How long the copies handed to a receiver are kept. The receiver copies
|
||||||
|
/// them on drop, so a day is far more than it needs.
|
||||||
|
const KEEP_DRAGGED: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct PhotoDrag {
|
||||||
|
/// The photos dragged, as uid and Drive name.
|
||||||
|
pub(super) photos: RefCell<Vec<(String, Option<String>)>>,
|
||||||
|
pub(super) socket: RefCell<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for PhotoDrag {
|
||||||
|
const NAME: &'static str = "PdfsPhotoDrag";
|
||||||
|
type Type = super::PhotoDrag;
|
||||||
|
type ParentType = gdk::ContentProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for PhotoDrag {}
|
||||||
|
|
||||||
|
impl ContentProviderImpl for PhotoDrag {
|
||||||
|
fn formats(&self) -> gdk::ContentFormats {
|
||||||
|
gdk::ContentFormats::new(&[URI_LIST])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_mime_type_future(
|
||||||
|
&self,
|
||||||
|
mime_type: &str,
|
||||||
|
stream: &gio::OutputStream,
|
||||||
|
io_priority: glib::Priority,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<(), glib::Error>> + 'static>> {
|
||||||
|
let photos = self.photos.borrow().clone();
|
||||||
|
let socket = self.socket.borrow().clone();
|
||||||
|
let stream = stream.clone();
|
||||||
|
let wanted = mime_type == URI_LIST;
|
||||||
|
Box::pin(async move {
|
||||||
|
if !wanted {
|
||||||
|
return Err(glib::Error::new(
|
||||||
|
gio::IOErrorEnum::NotSupported,
|
||||||
|
"photos are offered as a list of files only",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let files = fetch_dragged(socket, photos).await?;
|
||||||
|
let list: String = files
|
||||||
|
.iter()
|
||||||
|
.map(|path| format!("{}\r\n", gio::File::for_path(path).uri()))
|
||||||
|
.collect();
|
||||||
|
stream
|
||||||
|
.write_all_future(list.into_bytes(), io_priority)
|
||||||
|
.await
|
||||||
|
.map_err(|(_, e)| e)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
/// The content of a drag of photos: their files, fetched when dropped.
|
||||||
|
pub struct PhotoDrag(ObjectSubclass<imp::PhotoDrag>)
|
||||||
|
@extends gdk::ContentProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PhotoDrag {
|
||||||
|
pub(crate) fn new(socket: PathBuf, photos: Vec<(String, Option<String>)>) -> Self {
|
||||||
|
let drag: Self = glib::Object::new();
|
||||||
|
*drag.imp().photos.borrow_mut() = photos;
|
||||||
|
*drag.imp().socket.borrow_mut() = socket;
|
||||||
|
drag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download each dragged photo and put a copy under its Drive name where the
|
||||||
|
/// receiver can read it. A photo that fails is left out; only when every one
|
||||||
|
/// fails does the drop fail.
|
||||||
|
async fn fetch_dragged(
|
||||||
|
socket: PathBuf,
|
||||||
|
photos: Vec<(String, Option<String>)>,
|
||||||
|
) -> Result<Vec<PathBuf>, glib::Error> {
|
||||||
|
let base = glib::user_cache_dir().join("pdfs-drag");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut last_error = None;
|
||||||
|
for (uid, name) in photos {
|
||||||
|
let rx = spawn_request(socket.clone(), Request::OpenPhoto { uid: uid.clone() });
|
||||||
|
let source = match rx.recv().await {
|
||||||
|
Ok(Ok(Response::FilePath { path })) => path,
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
last_error = Some(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
last_error = Some("the Proton Drive service didn't respond".to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let base = base.clone();
|
||||||
|
let copied = gio::spawn_blocking(move || {
|
||||||
|
prune_dragged(&base, SystemTime::now());
|
||||||
|
let name = name.unwrap_or_else(|| "photo".to_string());
|
||||||
|
hand_over(&base, Path::new(&source), &uid, &name)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match copied {
|
||||||
|
Ok(Ok(path)) => files.push(path),
|
||||||
|
Ok(Err(e)) => last_error = Some(e.to_string()),
|
||||||
|
Err(_) => last_error = Some("the copy was interrupted".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if files.is_empty() {
|
||||||
|
let message = last_error.unwrap_or_else(|| "nothing to drop".to_string());
|
||||||
|
tracing::warn!("dragging photos out failed: {message}");
|
||||||
|
return Err(glib::Error::new(gio::IOErrorEnum::Failed, &message));
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put the cached photo `source` at `base/<uid>/<name>`, where a receiver
|
||||||
|
/// copies it from.
|
||||||
|
///
|
||||||
|
/// A hard link when the cache is on the same file system, so a large video is
|
||||||
|
/// not copied twice; a copy otherwise. Not a symlink: a file manager copies a
|
||||||
|
/// symlink as a link, which would leave the user a link into the cache.
|
||||||
|
pub(crate) fn hand_over(
|
||||||
|
base: &Path,
|
||||||
|
source: &Path,
|
||||||
|
uid: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> std::io::Result<PathBuf> {
|
||||||
|
let file_name = Path::new(name)
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| std::io::Error::other("the photo has no usable name"))?;
|
||||||
|
let dir = base.join(uid.replace('/', "_"));
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
let dest = dir.join(file_name);
|
||||||
|
let _ = std::fs::remove_file(&dest);
|
||||||
|
if std::fs::hard_link(source, &dest).is_err() {
|
||||||
|
std::fs::copy(source, &dest)?;
|
||||||
|
}
|
||||||
|
Ok(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the copies handed over more than [`KEEP_DRAGGED`] before `now`.
|
||||||
|
pub(crate) fn prune_dragged(base: &Path, now: SystemTime) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(base) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let old = entry
|
||||||
|
.metadata()
|
||||||
|
.and_then(|meta| meta.modified())
|
||||||
|
.is_ok_and(|modified| {
|
||||||
|
now.duration_since(modified)
|
||||||
|
.is_ok_and(|age| age > KEEP_DRAGGED)
|
||||||
|
});
|
||||||
|
if old {
|
||||||
|
let _ = std::fs::remove_dir_all(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn scratch(name: &str) -> PathBuf {
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("pdfs-drag-test-{}-{name}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dragged_photo_is_handed_over_under_its_own_name() {
|
||||||
|
let dir = scratch("name");
|
||||||
|
let source = dir.join("0f3a9c");
|
||||||
|
std::fs::write(&source, b"jpeg bytes").unwrap();
|
||||||
|
let base = dir.join("out");
|
||||||
|
|
||||||
|
let path = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
|
||||||
|
assert_eq!(path, base.join("vol~link").join("IMG_0001.JPG"));
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"jpeg bytes");
|
||||||
|
assert!(!path.is_symlink());
|
||||||
|
|
||||||
|
// Dragged again, it replaces the copy rather than failing on it.
|
||||||
|
std::fs::write(&source, b"new bytes").unwrap();
|
||||||
|
let again = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
|
||||||
|
assert_eq!(std::fs::read(again).unwrap(), b"new bytes");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_cannot_climb_out_of_its_folder() {
|
||||||
|
let dir = scratch("climb");
|
||||||
|
let source = dir.join("blob");
|
||||||
|
std::fs::write(&source, b"x").unwrap();
|
||||||
|
let base = dir.join("out");
|
||||||
|
let path = hand_over(&base, &source, "u", "../../escape.jpg").unwrap();
|
||||||
|
assert_eq!(path, base.join("u").join("escape.jpg"));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_old_handovers_are_pruned() {
|
||||||
|
let dir = scratch("prune");
|
||||||
|
std::fs::create_dir_all(dir.join("fresh")).unwrap();
|
||||||
|
prune_dragged(&dir, SystemTime::now());
|
||||||
|
assert!(dir.join("fresh").exists());
|
||||||
|
prune_dragged(&dir, SystemTime::now() + KEEP_DRAGGED * 2);
|
||||||
|
assert!(!dir.join("fresh").exists());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
//! Playing videos and music inside the app.
|
||||||
|
//!
|
||||||
|
//! The pieces both players share live here: the one My files opens a video or
|
||||||
|
//! a song in ([`open_media_player`]) and the Photos lightbox. A GTK
|
||||||
|
//! [`gtk4::Video`] brings play, pause, a seek bar and the volume; this adds
|
||||||
|
//! fullscreen, the keys every video player answers to, a repeat switch and a
|
||||||
|
//! volume that carries over from one video to the next.
|
||||||
|
|
||||||
|
use crate::activation::{mounted_path, present_within};
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// How far `J` and `L` (or Shift+←/→) jump.
|
||||||
|
const SEEK_LONG: i64 = 10;
|
||||||
|
|
||||||
|
/// How far ← and → jump in the My files player, where they don't move to
|
||||||
|
/// another photo.
|
||||||
|
const SEEK_SHORT: i64 = 5;
|
||||||
|
|
||||||
|
/// How much ↑ and ↓ change the volume in the My files player.
|
||||||
|
const VOLUME_STEP: f64 = 0.1;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// The volume and mute the user last set, applied to the next stream so
|
||||||
|
/// muting one video doesn't leave the next one blaring. Kept for the run
|
||||||
|
/// of the app only.
|
||||||
|
static LEVEL: Cell<Option<(f64, bool)>> = const { Cell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a jump of `delta_secs` from `now` lands, in microseconds, as a
|
||||||
|
/// [`gtk4::MediaStream`] counts. Kept inside the stream; a `duration` of 0
|
||||||
|
/// means the stream doesn't know its length yet, which bounds only the start.
|
||||||
|
pub(crate) fn seek_target(now: i64, duration: i64, delta_secs: i64) -> i64 {
|
||||||
|
let target = now
|
||||||
|
.saturating_add(delta_secs.saturating_mul(1_000_000))
|
||||||
|
.max(0);
|
||||||
|
if duration > 0 {
|
||||||
|
target.min(duration)
|
||||||
|
} else {
|
||||||
|
target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jump the stream `delta_secs` forward, or back when negative.
|
||||||
|
pub(crate) fn seek_by(video: >k4::Video, delta_secs: i64) {
|
||||||
|
if let Some(stream) = video.media_stream()
|
||||||
|
&& stream.is_seekable()
|
||||||
|
{
|
||||||
|
stream.seek(seek_target(
|
||||||
|
stream.timestamp(),
|
||||||
|
stream.duration(),
|
||||||
|
delta_secs,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play a paused stream, pause a playing one.
|
||||||
|
pub(crate) fn toggle_playing(video: >k4::Video) {
|
||||||
|
if let Some(stream) = video.media_stream() {
|
||||||
|
stream.set_playing(!stream.is_playing());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_muted(video: >k4::Video) {
|
||||||
|
if let Some(stream) = video.media_stream() {
|
||||||
|
stream.set_muted(!stream.is_muted());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn change_volume(video: >k4::Video, delta: f64) {
|
||||||
|
if let Some(stream) = video.media_stream() {
|
||||||
|
stream.set_muted(false);
|
||||||
|
stream.set_volume((stream.volume() + delta).clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter fullscreen, or leave it.
|
||||||
|
pub(crate) fn toggle_fullscreen(window: >k4::Window) {
|
||||||
|
if window.is_fullscreen() {
|
||||||
|
window.unfullscreen();
|
||||||
|
} else {
|
||||||
|
window.fullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keys both players answer to the same way: `K` plays and pauses, `J` and
|
||||||
|
/// `L` (or Shift+← and Shift+→) jump ten seconds, `M` mutes. True when `key`
|
||||||
|
/// was one of them.
|
||||||
|
pub(crate) fn player_key(video: >k4::Video, key: &str, state: gtk4::gdk::ModifierType) -> bool {
|
||||||
|
let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
|
||||||
|
let plain = !state
|
||||||
|
.intersects(gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::ALT_MASK);
|
||||||
|
if !plain {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match key {
|
||||||
|
"k" | "K" => toggle_playing(video),
|
||||||
|
"j" | "J" => seek_by(video, -SEEK_LONG),
|
||||||
|
"l" | "L" => seek_by(video, SEEK_LONG),
|
||||||
|
"Left" if shift => seek_by(video, -SEEK_LONG),
|
||||||
|
"Right" if shift => seek_by(video, SEEK_LONG),
|
||||||
|
"m" | "M" => toggle_muted(video),
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `video` carry the volume over between streams and toggle `window`'s
|
||||||
|
/// fullscreen on a double-click.
|
||||||
|
pub(crate) fn wire_player(video: >k4::Video, window: >k4::Window) {
|
||||||
|
video.connect_media_stream_notify(|video| {
|
||||||
|
let Some(stream) = video.media_stream() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some((volume, muted)) = LEVEL.get() {
|
||||||
|
stream.set_volume(volume);
|
||||||
|
stream.set_muted(muted);
|
||||||
|
}
|
||||||
|
let remember = |stream: >k4::MediaStream| {
|
||||||
|
LEVEL.set(Some((stream.volume(), stream.is_muted())));
|
||||||
|
};
|
||||||
|
stream.connect_volume_notify(remember);
|
||||||
|
stream.connect_muted_notify(remember);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The controls' own buttons and seek bar claim their clicks, so this only
|
||||||
|
// sees a double-click on the picture.
|
||||||
|
let click = gtk4::GestureClick::new();
|
||||||
|
let window = window.downgrade();
|
||||||
|
click.connect_pressed(move |_, n_press, _, _| {
|
||||||
|
if n_press == 2
|
||||||
|
&& let Some(window) = window.upgrade()
|
||||||
|
{
|
||||||
|
toggle_fullscreen(&window);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
video.add_controller(click);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A button that enters and leaves `window`'s fullscreen, showing which one
|
||||||
|
/// it will do.
|
||||||
|
pub(crate) fn fullscreen_button(window: >k4::Window) -> gtk4::Button {
|
||||||
|
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
|
||||||
|
let paint = |button: >k4::Button, fullscreen: bool| {
|
||||||
|
if fullscreen {
|
||||||
|
button.set_icon_name("view-restore-symbolic");
|
||||||
|
button.set_tooltip_text(Some(&gettext("Leave Fullscreen (F)")));
|
||||||
|
} else {
|
||||||
|
button.set_icon_name("view-fullscreen-symbolic");
|
||||||
|
button.set_tooltip_text(Some(&gettext("Fullscreen (F)")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
paint(&button, window.is_fullscreen());
|
||||||
|
let weak = button.downgrade();
|
||||||
|
window.connect_fullscreened_notify(move |window| {
|
||||||
|
if let Some(button) = weak.upgrade() {
|
||||||
|
paint(&button, window.is_fullscreen());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let target = window.downgrade();
|
||||||
|
button.connect_clicked(move |_| {
|
||||||
|
if let Some(window) = target.upgrade() {
|
||||||
|
toggle_fullscreen(&window);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
button
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play a video or a song from My files in a window of its own, streamed from
|
||||||
|
/// the mount: the player reads the blocks it needs as it plays and seeks, so a
|
||||||
|
/// long video starts in seconds instead of after a full download. A file the
|
||||||
|
/// mount doesn't show, or the service is down, opens in the default
|
||||||
|
/// application instead, after a download.
|
||||||
|
pub(crate) fn open_media_player(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
let rel = entry_rel(ui, entry);
|
||||||
|
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||||
|
let (ui, entry) = (ui.clone(), entry.clone());
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match present_within(mounted_path(&mountpoint, &rel)).await {
|
||||||
|
Some(path) => build_media_player(&ui, &entry, &path),
|
||||||
|
None => download_and_open(&ui, &entry),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_media_player(ui: &Rc<Ui>, entry: &DirEntry, path: &Path) {
|
||||||
|
let window = adw::Window::builder()
|
||||||
|
.title(&entry.name)
|
||||||
|
.default_width(960)
|
||||||
|
.default_height(600)
|
||||||
|
.build();
|
||||||
|
if let Some(parent) = ui_window(ui) {
|
||||||
|
window.set_transient_for(Some(&parent));
|
||||||
|
}
|
||||||
|
window.add_css_class("media-player-window");
|
||||||
|
let gtk_window: gtk4::Window = window.clone().upcast();
|
||||||
|
|
||||||
|
let video = gtk4::Video::builder()
|
||||||
|
.autoplay(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
wire_player(&video, >k_window);
|
||||||
|
|
||||||
|
// A song has no picture, which would leave a black window above the
|
||||||
|
// controls: a note stands in for it.
|
||||||
|
let note = gtk4::Image::builder()
|
||||||
|
.icon_name("audio-x-generic-symbolic")
|
||||||
|
.pixel_size(128)
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.can_target(false)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
note.add_css_class("media-player-note");
|
||||||
|
let overlay = gtk4::Overlay::new();
|
||||||
|
overlay.set_child(Some(&video));
|
||||||
|
overlay.add_overlay(¬e);
|
||||||
|
|
||||||
|
let failed = adw::StatusPage::builder()
|
||||||
|
.icon_name("dialog-warning-symbolic")
|
||||||
|
.title(gettext("Couldn't play this file"))
|
||||||
|
.build();
|
||||||
|
let open_with = gtk4::Button::builder()
|
||||||
|
.label(gettext("Open With…"))
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
open_with.add_css_class("pill");
|
||||||
|
open_with.add_css_class("suggested-action");
|
||||||
|
failed.set_child(Some(&open_with));
|
||||||
|
|
||||||
|
let stack = gtk4::Stack::new();
|
||||||
|
stack.add_named(&overlay, Some("player"));
|
||||||
|
stack.add_named(&failed, Some("failed"));
|
||||||
|
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
header.pack_end(&fullscreen_button(>k_window));
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&gettext("Open With…"), move || {
|
||||||
|
open_entry_with(&ui_c, &entry_c)
|
||||||
|
});
|
||||||
|
let video_c = video.clone();
|
||||||
|
// Translators: menu item of the video player that plays the file again
|
||||||
|
// and again.
|
||||||
|
menu.toggle(&gettext("Repeat"), false, move |on| video_c.set_loop(on));
|
||||||
|
header.pack_end(&menu.button());
|
||||||
|
|
||||||
|
let toolbar = adw::ToolbarView::new();
|
||||||
|
toolbar.add_top_bar(&header);
|
||||||
|
toolbar.set_content(Some(&stack));
|
||||||
|
// Fullscreen is the video alone, its own controls still at hand.
|
||||||
|
window
|
||||||
|
.bind_property("fullscreened", &toolbar, "reveal-top-bars")
|
||||||
|
.invert_boolean()
|
||||||
|
.sync_create()
|
||||||
|
.build();
|
||||||
|
window.set_content(Some(&toolbar));
|
||||||
|
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
open_with.connect_clicked(move |_| open_entry_with(&ui_c, &entry_c));
|
||||||
|
|
||||||
|
let (stack_c, failed_c, note_c) = (stack.clone(), failed.clone(), note.clone());
|
||||||
|
video.connect_media_stream_notify(move |video| {
|
||||||
|
let Some(stream) = video.media_stream() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (stack, failed) = (stack_c.clone(), failed_c.clone());
|
||||||
|
stream.connect_error_notify(move |stream| {
|
||||||
|
if let Some(error) = stream.error() {
|
||||||
|
tracing::warn!("in-app player failed: {error}");
|
||||||
|
failed.set_description(Some(&error.to_string()));
|
||||||
|
stack.set_visible_child_name("failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let note = note_c.clone();
|
||||||
|
stream.connect_prepared_notify(move |stream| {
|
||||||
|
note.set_visible(stream.is_prepared() && stream.has_audio() && !stream.has_video());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
video.set_filename(Some(path));
|
||||||
|
|
||||||
|
let keys = gtk4::EventControllerKey::new();
|
||||||
|
let (video_k, window_k) = (video.clone(), gtk_window.downgrade());
|
||||||
|
keys.connect_key_pressed(move |_, key, _code, state| {
|
||||||
|
let Some(window) = window_k.upgrade() else {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
};
|
||||||
|
let Some(name) = key.name() else {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
};
|
||||||
|
// The open menu keeps its arrows and Enter.
|
||||||
|
let in_menu = gtk4::prelude::GtkWindowExt::focus(&window)
|
||||||
|
.is_some_and(|focus| focus.ancestor(gtk4::Popover::static_type()).is_some());
|
||||||
|
if in_menu {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
|
if player_key(&video_k, &name, state) {
|
||||||
|
return glib::Propagation::Stop;
|
||||||
|
}
|
||||||
|
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||||
|
match name.as_str() {
|
||||||
|
"space" => toggle_playing(&video_k),
|
||||||
|
"Left" => seek_by(&video_k, -SEEK_SHORT),
|
||||||
|
"Right" => seek_by(&video_k, SEEK_SHORT),
|
||||||
|
"Up" => change_volume(&video_k, VOLUME_STEP),
|
||||||
|
"Down" => change_volume(&video_k, -VOLUME_STEP),
|
||||||
|
"f" | "F11" => toggle_fullscreen(&window),
|
||||||
|
// Escape leaves fullscreen before it closes anything.
|
||||||
|
"Escape" if window.is_fullscreen() => window.unfullscreen(),
|
||||||
|
"Escape" | "q" => window.close(),
|
||||||
|
"w" if ctrl => window.close(),
|
||||||
|
_ => return glib::Propagation::Proceed,
|
||||||
|
}
|
||||||
|
glib::Propagation::Stop
|
||||||
|
});
|
||||||
|
// Capture, so the arrows and Space reach the player rather than moving the
|
||||||
|
// focus between its controls.
|
||||||
|
keys.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||||
|
window.add_controller(keys);
|
||||||
|
|
||||||
|
// Stop the sound with the window, not when the window is finally freed.
|
||||||
|
let video_c = video.clone();
|
||||||
|
window.connect_close_request(move |_| {
|
||||||
|
video_c.set_media_stream(gtk4::MediaStream::NONE);
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
window.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SEC: i64 = 1_000_000;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_jump_stays_inside_the_video() {
|
||||||
|
assert_eq!(seek_target(30 * SEC, 60 * SEC, 10), 40 * SEC);
|
||||||
|
assert_eq!(seek_target(30 * SEC, 60 * SEC, -10), 20 * SEC);
|
||||||
|
assert_eq!(seek_target(5 * SEC, 60 * SEC, -10), 0);
|
||||||
|
assert_eq!(seek_target(55 * SEC, 60 * SEC, 10), 60 * SEC);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_jump_forward_in_a_stream_of_unknown_length_is_not_held_back() {
|
||||||
|
assert_eq!(seek_target(30 * SEC, 0, 10), 40 * SEC);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -334,6 +334,13 @@ pub(crate) struct ShareDialog {
|
|||||||
/// Open the per-node Share dialog: invite Proton/external users, manage who has
|
/// Open the per-node Share dialog: invite Proton/external users, manage who has
|
||||||
/// access and their roles, and create/copy/remove a public link.
|
/// access and their roles, and create/copy/remove a public link.
|
||||||
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
open_share_dialog_over(ui, entry, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`open_share_dialog`] over `parent` rather than the main window. The photo
|
||||||
|
/// viewer is a window of its own, and a dialog on the main window would open
|
||||||
|
/// behind it.
|
||||||
|
pub(crate) fn open_share_dialog_over(ui: &Rc<Ui>, entry: &DirEntry, parent: Option<gtk4::Window>) {
|
||||||
if !*ui.mounted.borrow() {
|
if !*ui.mounted.borrow() {
|
||||||
toast_error(
|
toast_error(
|
||||||
ui,
|
ui,
|
||||||
@@ -491,7 +498,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
share_dialog_reload(&state);
|
share_dialog_reload(&state);
|
||||||
dialog.present(ui_window(ui).as_ref());
|
dialog.present(parent.or_else(|| ui_window(ui)).as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Shared thumbnails for ordinary Drive image files.
|
//! Shared thumbnails for ordinary Drive image and video files.
|
||||||
//!
|
//!
|
||||||
//! The Photos gallery has timeline-specific batching and aspect-ratio state.
|
//! The Photos gallery has timeline-specific batching and aspect-ratio state.
|
||||||
//! File listings need a smaller, reusable surface: one loader backs My files,
|
//! File listings need a smaller, reusable surface: one loader backs My files,
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
//! so a replaced image never flashes its previous contents.
|
//! so a replaced image never flashes its previous contents.
|
||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name};
|
use pdfs_core::control::{FileThumbRequest, is_thumbnail_media_name, is_video_file_name};
|
||||||
|
|
||||||
const THUMB_BATCH: usize = 32;
|
const THUMB_BATCH: usize = 32;
|
||||||
const THUMB_CACHE_MAX: usize = 256;
|
const THUMB_CACHE_MAX: usize = 256;
|
||||||
@@ -125,6 +125,18 @@ pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Over
|
|||||||
.build();
|
.build();
|
||||||
picture.add_css_class("file-thumbnail");
|
picture.add_css_class("file-thumbnail");
|
||||||
|
|
||||||
|
// Over a video's frame, so it does not pass for a photo.
|
||||||
|
let play = gtk4::Image::builder()
|
||||||
|
.icon_name("media-playback-start-symbolic")
|
||||||
|
.pixel_size(16)
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.can_target(false)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
play.add_css_class("photo-video-badge");
|
||||||
|
play.add_css_class("photo-video-badge-small");
|
||||||
|
|
||||||
let overlay = gtk4::Overlay::new();
|
let overlay = gtk4::Overlay::new();
|
||||||
overlay.set_width_request(size);
|
overlay.set_width_request(size);
|
||||||
overlay.set_height_request(size);
|
overlay.set_height_request(size);
|
||||||
@@ -133,9 +145,21 @@ pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Over
|
|||||||
overlay.set_overflow(gtk4::Overflow::Hidden);
|
overlay.set_overflow(gtk4::Overflow::Hidden);
|
||||||
overlay.set_child(Some(&fallback));
|
overlay.set_child(Some(&fallback));
|
||||||
overlay.add_overlay(&picture);
|
overlay.add_overlay(&picture);
|
||||||
|
overlay.add_overlay(&play);
|
||||||
overlay
|
overlay
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The play badge [`file_thumbnail_widget`] puts after `picture`.
|
||||||
|
fn play_badge(picture: >k4::Picture) -> Option<gtk4::Image> {
|
||||||
|
picture
|
||||||
|
.next_sibling()
|
||||||
|
.and_downcast::<gtk4::Image>()
|
||||||
|
.filter(|image| image.has_css_class("photo-video-badge"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a play badge to show once its video's frame is painted.
|
||||||
|
const VIDEO_BADGE: &str = "video";
|
||||||
|
|
||||||
/// Resize an existing thumbnail surface. Grid rows are recycled, so changing
|
/// Resize an existing thumbnail surface. Grid rows are recycled, so changing
|
||||||
/// these requests in place and rebinding the model is enough to implement the
|
/// these requests in place and rebinding the model is enough to implement the
|
||||||
/// status-bar zoom without downloading or decoding an image again.
|
/// status-bar zoom without downloading or decoding an image again.
|
||||||
@@ -179,6 +203,16 @@ pub(crate) fn bind_file_thumbnail(
|
|||||||
fallback.set_visible(true);
|
fallback.set_visible(true);
|
||||||
picture.set_visible(false);
|
picture.set_visible(false);
|
||||||
picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
||||||
|
// List rows are too small for a badge; their icon says video already.
|
||||||
|
let video = !entry.is_dir && is_video_file_name(&entry.name, Some(entry.size));
|
||||||
|
if let Some(play) = play_badge(&picture) {
|
||||||
|
play.set_visible(false);
|
||||||
|
play.set_widget_name(if video && !symbolic_fallback {
|
||||||
|
VIDEO_BADGE
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// The widget name is a cheap binding token. List factories recycle their
|
// The widget name is a cheap binding token. List factories recycle their
|
||||||
// children; a reply for the previous entry must not paint into the new one.
|
// children; a reply for the previous entry must not paint into the new one.
|
||||||
@@ -186,7 +220,10 @@ pub(crate) fn bind_file_thumbnail(
|
|||||||
picture.set_widget_name(&key);
|
picture.set_widget_name(&key);
|
||||||
remove_waiter(ui, &picture);
|
remove_waiter(ui, &picture);
|
||||||
|
|
||||||
if entry.is_dir || !is_image_name(&entry.name) || entry.uid.is_empty() {
|
if entry.is_dir
|
||||||
|
|| !is_thumbnail_media_name(&entry.name, Some(entry.size))
|
||||||
|
|| entry.uid.is_empty()
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(texture) = ui.file_thumbs.textures.borrow().get(&key) {
|
if let Some(texture) = ui.file_thumbs.textures.borrow().get(&key) {
|
||||||
@@ -214,12 +251,6 @@ pub(crate) fn bind_file_thumbnail(
|
|||||||
schedule_file_thumbs(ui);
|
schedule_file_thumbs(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the file name denotes an image format for which Proton or the local
|
|
||||||
/// decoder can reasonably provide a thumbnail.
|
|
||||||
pub(crate) fn is_image_name(name: &str) -> bool {
|
|
||||||
is_thumbnail_image_name(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn thumbnail_key(uid: &str, modified: i64) -> String {
|
fn thumbnail_key(uid: &str, modified: i64) -> String {
|
||||||
format!("{uid}:{modified}")
|
format!("{uid}:{modified}")
|
||||||
}
|
}
|
||||||
@@ -238,6 +269,9 @@ fn paint(picture: >k4::Picture, key: &str, texture: >k4::gdk::Texture) {
|
|||||||
}
|
}
|
||||||
picture.set_paintable(Some(texture));
|
picture.set_paintable(Some(texture));
|
||||||
picture.set_visible(true);
|
picture.set_visible(true);
|
||||||
|
if let Some(play) = play_badge(picture) {
|
||||||
|
play.set_visible(play.widget_name() == VIDEO_BADGE);
|
||||||
|
}
|
||||||
if let Some(overlay) = picture.parent().and_downcast::<gtk4::Overlay>()
|
if let Some(overlay) = picture.parent().and_downcast::<gtk4::Overlay>()
|
||||||
&& let Some(fallback) = overlay.child().and_downcast::<gtk4::Image>()
|
&& let Some(fallback) = overlay.child().and_downcast::<gtk4::Image>()
|
||||||
{
|
{
|
||||||
@@ -413,7 +447,11 @@ fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ReplyDisposition, is_image_name, reply_disposition};
|
use super::{ReplyDisposition, reply_disposition};
|
||||||
|
|
||||||
|
fn is_image_name(name: &str) -> bool {
|
||||||
|
pdfs_core::control::is_thumbnail_image_name(name)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn image_extensions_are_case_insensitive_and_specific() {
|
fn image_extensions_are_case_insensitive_and_specific() {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::time::Duration;
|
|||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk4::{gio, glib};
|
use gtk4::{gio, glib};
|
||||||
|
|
||||||
use super::row::{fill_slot, thumb_slot};
|
use super::row::{content_type_for, fill_slot, thumb_slot};
|
||||||
use super::thumbs::Thumbs;
|
use super::thumbs::Thumbs;
|
||||||
use crate::{Hit, dates, gettext, human_bytes, pgettext};
|
use crate::{Hit, dates, gettext, human_bytes, pgettext};
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ impl Preview {
|
|||||||
let content_type = if hit.is_dir() {
|
let content_type = if hit.is_dir() {
|
||||||
glib::GString::from("inode/directory")
|
glib::GString::from("inode/directory")
|
||||||
} else {
|
} else {
|
||||||
gio::functions::content_type_guess(Some(hit.name()), None).0
|
content_type_for(hit.name(), hit.size())
|
||||||
};
|
};
|
||||||
self.kind
|
self.kind
|
||||||
.set_label(&gio::functions::content_type_get_description(&content_type));
|
.set_label(&gio::functions::content_type_get_description(&content_type));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk4::{gdk, gio};
|
use gtk4::{gdk, gio, glib};
|
||||||
|
|
||||||
use super::highlight;
|
use super::highlight;
|
||||||
use super::thumbs::Thumbs;
|
use super::thumbs::Thumbs;
|
||||||
@@ -53,7 +53,7 @@ pub(crate) fn fill_slot(slot: >k4::Overlay, hit: &Hit, thumbs: &Rc<Thumbs>, mo
|
|||||||
let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() else {
|
let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
fallback.set_from_gicon(&icon_for(hit.name(), hit.is_dir()));
|
fallback.set_from_gicon(&icon_for(hit.name(), hit.is_dir(), hit.size()));
|
||||||
fallback.set_visible(true);
|
fallback.set_visible(true);
|
||||||
picture.set_visible(false);
|
picture.set_visible(false);
|
||||||
picture.set_paintable(gdk::Paintable::NONE);
|
picture.set_paintable(gdk::Paintable::NONE);
|
||||||
@@ -63,7 +63,7 @@ pub(crate) fn fill_slot(slot: >k4::Overlay, hit: &Hit, thumbs: &Rc<Thumbs>, mo
|
|||||||
/// The desktop's own full-colour icon for a file name, so results look like
|
/// The desktop's own full-colour icon for a file name, so results look like
|
||||||
/// the user's file manager. Folders are Proton's purple ones while the Proton
|
/// the user's file manager. Folders are Proton's purple ones while the Proton
|
||||||
/// theme is on, as in the app's browser.
|
/// theme is on, as in the app's browser.
|
||||||
pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
|
pub(crate) fn icon_for(name: &str, is_dir: bool, size: u64) -> gio::Icon {
|
||||||
if is_dir {
|
if is_dir {
|
||||||
let folder = if proton_theme_active() {
|
let folder = if proton_theme_active() {
|
||||||
"pdfs-proton-folder"
|
"pdfs-proton-folder"
|
||||||
@@ -72,8 +72,21 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
|
|||||||
};
|
};
|
||||||
return gio::ThemedIcon::new(folder).upcast();
|
return gio::ThemedIcon::new(folder).upcast();
|
||||||
}
|
}
|
||||||
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None);
|
gio::functions::content_type_get_icon(&content_type_for(name, size))
|
||||||
gio::functions::content_type_get_icon(&content_type)
|
}
|
||||||
|
|
||||||
|
/// The content type of a file named `name` of `size` bytes, guessed from the
|
||||||
|
/// name. GIO takes every `.ts` for TypeScript (or Qt translations); a
|
||||||
|
/// recording's size says otherwise (see
|
||||||
|
/// [`is_video_file_name`](pdfs_core::control::is_video_file_name)).
|
||||||
|
pub(crate) fn content_type_for(name: &str, size: u64) -> glib::GString {
|
||||||
|
let transport_stream = std::path::Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|extension| extension.eq_ignore_ascii_case("ts"));
|
||||||
|
if transport_stream && pdfs_core::control::is_video_file_name(name, Some(size)) {
|
||||||
|
return glib::GString::from("video/mp2t");
|
||||||
|
}
|
||||||
|
gio::functions::content_type_guess(Some(name), None).0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sync badge of a Drive file, as in the app's browser: pinned, cached,
|
/// The sync badge of a Drive file, as in the app's browser: pinned, cached,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
//! matches the file's;
|
//! matches the file's;
|
||||||
//! 2. for local images, decoding the file itself, and writing the result back
|
//! 2. for local images, decoding the file itself, and writing the result back
|
||||||
//! to that cache for the next time and for every other application;
|
//! to that cache for the next time and for every other application;
|
||||||
//! 3. for Drive images, the daemon's `FileThumbs`, batched after a short quiet
|
//! 3. for Drive images and videos, the daemon's `FileThumbs`, batched after a
|
||||||
//! period.
|
//! short quiet period.
|
||||||
//!
|
//!
|
||||||
//! Workers hand back `gdk::Texture`s, never a `Pixbuf`, which may not cross
|
//! Workers hand back `gdk::Texture`s, never a `Pixbuf`, which may not cross
|
||||||
//! threads. Each job carries the render epoch it was queued in, and workers
|
//! threads. Each job carries the render epoch it was queued in, and workers
|
||||||
@@ -30,7 +30,9 @@ use gtk4::gdk_pixbuf::Pixbuf;
|
|||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4::{gdk, glib};
|
use gtk4::{gdk, glib};
|
||||||
|
|
||||||
use pdfs_core::control::{FileThumbRequest, Request, Response, is_thumbnail_image_name};
|
use pdfs_core::control::{
|
||||||
|
FileThumbRequest, Request, Response, is_thumbnail_image_name, is_thumbnail_media_name,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{Hit, spawn_request};
|
use crate::{Hit, spawn_request};
|
||||||
|
|
||||||
@@ -165,7 +167,13 @@ impl Thumbs {
|
|||||||
// The widget name binds the picture to one key: a late reply for
|
// The widget name binds the picture to one key: a late reply for
|
||||||
// another hit must not paint into it.
|
// another hit must not paint into it.
|
||||||
picture.set_widget_name(&key);
|
picture.set_widget_name(&key);
|
||||||
if hit.is_dir() || !is_thumbnail_image_name(hit.name()) {
|
// A video's frame comes from the daemon, which has ffmpeg's help; a
|
||||||
|
// local video has only what another application cached.
|
||||||
|
let thumbnailed = match hit {
|
||||||
|
Hit::Drive(drive) => is_thumbnail_media_name(&drive.name, Some(drive.size)),
|
||||||
|
Hit::Local(_) => is_thumbnail_image_name(hit.name()),
|
||||||
|
};
|
||||||
|
if hit.is_dir() || !thumbnailed {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if matches!(hit, Hit::Drive(drive) if drive.uid.is_empty()) {
|
if matches!(hit, Hit::Drive(drive) if drive.uid.is_empty()) {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ pub(crate) fn icon_name(hit: &Hit) -> String {
|
|||||||
let name = hit.name();
|
let name = hit.name();
|
||||||
if is_image(name) {
|
if is_image(name) {
|
||||||
"image-x-generic"
|
"image-x-generic"
|
||||||
} else if is_media(name) {
|
} else if is_media(name) || pdfs_core::control::is_video_file_name(name, Some(hit.size())) {
|
||||||
"video-x-generic"
|
"video-x-generic"
|
||||||
} else if is_document(name) {
|
} else if is_document(name) {
|
||||||
"text-x-generic"
|
"text-x-generic"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const STOCK: &[&str] = &[
|
|||||||
// GTK's built-in icons
|
// GTK's built-in icons
|
||||||
"info-outline-symbolic",
|
"info-outline-symbolic",
|
||||||
// Adwaita
|
// Adwaita
|
||||||
|
"audio-x-generic-symbolic",
|
||||||
"checkbox-checked-symbolic",
|
"checkbox-checked-symbolic",
|
||||||
"checkbox-symbolic",
|
"checkbox-symbolic",
|
||||||
"computer-symbolic",
|
"computer-symbolic",
|
||||||
@@ -52,6 +53,7 @@ const STOCK: &[&str] = &[
|
|||||||
"image-missing-symbolic",
|
"image-missing-symbolic",
|
||||||
"image-x-generic-symbolic",
|
"image-x-generic-symbolic",
|
||||||
"list-add-symbolic",
|
"list-add-symbolic",
|
||||||
|
"list-remove-symbolic",
|
||||||
"mark-location-symbolic",
|
"mark-location-symbolic",
|
||||||
"media-playback-pause-symbolic",
|
"media-playback-pause-symbolic",
|
||||||
"media-playback-start-symbolic",
|
"media-playback-start-symbolic",
|
||||||
@@ -61,16 +63,20 @@ const STOCK: &[&str] = &[
|
|||||||
"object-select-symbolic",
|
"object-select-symbolic",
|
||||||
"preferences-system-symbolic",
|
"preferences-system-symbolic",
|
||||||
"process-stop-symbolic",
|
"process-stop-symbolic",
|
||||||
|
"selection-mode-symbolic",
|
||||||
"starred-symbolic",
|
"starred-symbolic",
|
||||||
"system-search-symbolic",
|
"system-search-symbolic",
|
||||||
"text-x-generic-symbolic",
|
"text-x-generic-symbolic",
|
||||||
"user-trash-symbolic",
|
"user-trash-symbolic",
|
||||||
|
"video-x-generic-symbolic",
|
||||||
|
"view-fullscreen-symbolic",
|
||||||
"view-grid-symbolic",
|
"view-grid-symbolic",
|
||||||
"view-list-symbolic",
|
"view-list-symbolic",
|
||||||
"view-more-symbolic",
|
"view-more-symbolic",
|
||||||
"view-paged-symbolic",
|
"view-paged-symbolic",
|
||||||
"view-pin-symbolic",
|
"view-pin-symbolic",
|
||||||
"view-refresh-symbolic",
|
"view-refresh-symbolic",
|
||||||
|
"view-restore-symbolic",
|
||||||
"window-close-symbolic",
|
"window-close-symbolic",
|
||||||
"zoom-fit-best-symbolic",
|
"zoom-fit-best-symbolic",
|
||||||
"zoom-in-symbolic",
|
"zoom-in-symbolic",
|
||||||
|
|||||||
@@ -467,6 +467,7 @@ both with `pdfs_core::search::relevance_score`, so the prompt can merge them int
|
|||||||
| `pdfs-sync`, `pdfs-sync-poll` | Mirror passes and the remote poll (§7) |
|
| `pdfs-sync`, `pdfs-sync-poll` | Mirror passes and the remote poll (§7) |
|
||||||
| Tokio runtime | Remote calls, and the Drive and photos event streams (§6) |
|
| Tokio runtime | Remote calls, and the Drive and photos event streams (§6) |
|
||||||
| `pdfs-online-probe` | Probes Drive while offline |
|
| `pdfs-online-probe` | Probes Drive while offline |
|
||||||
|
| `pdfs-pin-keeper` | Checks pins once a second for work: every pin at startup, when the link returns and after the event feed loses continuity, and the pins a batch of remote events changed. Downloads files kept offline that are missing or stale; failures retry with a backoff of 30 s to 30 min (`docs/BUGS.md` B282) |
|
||||||
| `pdfs-supervisor` | Logs workers that hold one job for minutes, a queue that stops draining, and control requests that never return; samples memory; pings the systemd watchdog only after a round trip over the control socket succeeds (`WatchdogSec=120`) |
|
| `pdfs-supervisor` | Logs workers that hold one job for minutes, a queue that stops draining, and control requests that never return; samples memory; pings the systemd watchdog only after a round trip over the control socket succeeds (`WatchdogSec=120`) |
|
||||||
| `pdfs-conflict-sweep` | Optional. After 30 s, then every 5 minutes, finds `(sync-conflict …)` copies identical (size and SHA-1) to their original. `conflict_sweep` decides: `off`, `report` (default) or `enforce`, which trashes them after re-checking each one |
|
| `pdfs-conflict-sweep` | Optional. After 30 s, then every 5 minutes, finds `(sync-conflict …)` copies identical (size and SHA-1) to their original. `conflict_sweep` decides: `off`, `report` (default) or `enforce`, which trashes them after re-checking each one |
|
||||||
| Smaller helpers | `pdfs-localindex` (home index for search), `pdfs-pause` (ends a timed pause), `pdfs-similar` (similar photos, on request), `pdfs-diagnostics` |
|
| Smaller helpers | `pdfs-localindex` (home index for search), `pdfs-pause` (ends a timed pause), `pdfs-similar` (similar photos, on request), `pdfs-diagnostics` |
|
||||||
|
|||||||
+219
@@ -109,6 +109,12 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [B284](#b284--a-keyring-entry-reported-missing-discards-refreshed-session-tokens) | A keyring entry reported missing discards refreshed session tokens |
|
||||||
|
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
|
||||||
|
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
|
||||||
|
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
|
||||||
|
| [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 |
|
| [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds |
|
||||||
| [B277](#b277--my-files-mounted-over-a-folder-with-files-in-it-and-hid-them) | My files mounted over a folder with files in it, and hid them |
|
| [B277](#b277--my-files-mounted-over-a-folder-with-files-in-it-and-hid-them) | My files mounted over a folder with files in it, and hid them |
|
||||||
| [B274](#b274--opening-a-file-for-writing-fails-with-eio-while-its-queued-revision-lands) | Opening a file for writing fails with EIO while its queued revision lands |
|
| [B274](#b274--opening-a-file-for-writing-fails-with-eio-while-its-queued-revision-lands) | Opening a file for writing fails with EIO while its queued revision lands |
|
||||||
@@ -381,6 +387,219 @@ a live account since.
|
|||||||
|
|
||||||
Newest first.
|
Newest first.
|
||||||
|
|
||||||
|
### B284 — A keyring entry reported missing discards refreshed session tokens
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified)
|
||||||
|
**Found:** 2026-11-14, investigating [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78)
|
||||||
|
after the reporter's oo7 0.7.0 retest ruled out the original persistence fix as a sufficient
|
||||||
|
explanation. See [the investigation and isolated reproducer](ISSUE-78.md).
|
||||||
|
**Where:** `crates/pdfs-core/src/auth.rs`, `TokenSink::flush`
|
||||||
|
|
||||||
|
**Repro:** Refresh a session while the keyring lookup reports `NoEntry` for its existing blob.
|
||||||
|
oo7 0.7.0 does this for a locked collection (upstream #614), reproduced with fake tokens on a
|
||||||
|
private D-Bus connection. pdfs dropped the pending rotation and returned success, leaving the
|
||||||
|
spent refresh token in the saved blob without triggering B61's retry or kernel recovery paths.
|
||||||
|
The running session can still work, but restarting it exposes the stale saved credentials.
|
||||||
|
|
||||||
|
**Fix:** Propagate the missing-session error so the rotation stays pending and is retried.
|
||||||
|
Persistence still requires the matching existing blob; a retry never recreates a deleted
|
||||||
|
session or overwrites a new login. This does not make a locked keyring writable, and pending
|
||||||
|
tokens still need to reach it before power-off to survive a cold boot.
|
||||||
|
|
||||||
|
**Verified:** The two regression tests
|
||||||
|
`auth::tests::a_temporarily_missing_session_does_not_discard_a_rotation` and
|
||||||
|
`auth::tests::retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`
|
||||||
|
failed before the fix and pass afterward. The oo7 fixture confirms its locked lookup behavior
|
||||||
|
and repeated binary `SetSecret` persistence. The reporter's complete Fedora cold-boot sequence
|
||||||
|
has not been reproduced; issue #78's precise trigger remains unconfirmed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-14, from a user report: large `.ts` video recordings in My files showed a text
|
||||||
|
icon, and no video in My files had a thumbnail although the same videos had one in Photos.
|
||||||
|
**Where:** `is_video_file_name` in `crates/pdfs-core/src/control.rs`; `generate_thumbs` and
|
||||||
|
`sample_video` in `crates/pdfs-fuse/src/photos.rs`; `bind_file_thumbnail` in
|
||||||
|
`crates/pdfs-gui/src/app/widgets/thumbnails.rs`
|
||||||
|
|
||||||
|
**Repro:** Put an `.mp4` and a recorded `.ts` of a few GB in My files and open the folder in the
|
||||||
|
app's grid view. Both show a generic icon, and the `.ts` shows a text one.
|
||||||
|
|
||||||
|
**Cause.** Four things:
|
||||||
|
- File views asked the daemon for thumbnails of images only (`is_thumbnail_image_name`), so a
|
||||||
|
video was never asked for.
|
||||||
|
- `.ts` is TypeScript as often as it is an MPEG transport stream. Drive stores both as
|
||||||
|
`application/octet-stream`, and every place that names a file's type went by the extension:
|
||||||
|
the browser's icon list had no `.ts`, GIO guesses text, and the opener's `@text` class listed
|
||||||
|
`.ts`.
|
||||||
|
- The daemon downloaded a video whole into memory for one frame, so it refused anything over
|
||||||
|
256 MiB, and the size check behind that refusal asked the Photos client, which knows nothing
|
||||||
|
of a My files node.
|
||||||
|
- `file_thumbs` never asked for the thumbnail an uploader attached, on the belief that only
|
||||||
|
Photos nodes have one. This client attaches one to every upload it can make one for.
|
||||||
|
|
||||||
|
**Fix.** `is_video_file_name(name, size)` is the one rule: a video extension, and for `.ts` a
|
||||||
|
size of at least 1 MiB. The browser icon, the thumbnail gate, the launcher's icon and type, and
|
||||||
|
the opener's classes use it. For a file view, the daemon first asks Drive for the file's own
|
||||||
|
thumbnail. Without one, a video of up to 24 MiB is downloaded whole. A larger one brings its first
|
||||||
|
16 MiB, and for an MP4 or MOV whose `moov` box lies further in, the box headers on the way and
|
||||||
|
that box. The parts are laid out at their offsets in a sparse staging file, which ffmpeg reads.
|
||||||
|
A `.ts` whose head lacks the 0x47 sync byte is recorded as having no thumbnail.
|
||||||
|
|
||||||
|
**Test:** `a_ts_file_is_a_video_only_at_the_size_of_one` and
|
||||||
|
`listings_thumbnail_images_and_videos` in `crates/pdfs-core/src/control.rs`;
|
||||||
|
`a_ts_file_opens_as_media_or_text_by_its_size` in `crates/pdfs-core/src/opener.rs`;
|
||||||
|
`a_large_mp4_downloads_its_head_and_its_index` and the other `sample_video` tests in
|
||||||
|
`crates/pdfs-fuse/src/photos.rs`; the simulation
|
||||||
|
`a_video_thumbnail_downloads_a_small_part_of_the_video`, which reads under 34 MiB for two
|
||||||
|
videos of over 70 MiB and takes nothing from a video with an uploader's thumbnail.
|
||||||
|
`real_video_fixture_yields_a_frame` passed on 2026-11-14 with ffmpeg 9.0 for a 46 MB MP4 with its
|
||||||
|
index at the end, an MPEG-TS and an MKV.
|
||||||
|
|
||||||
|
### B282 — A file kept offline is downloaded again after a change made elsewhere
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-13, while looking into B281. Nothing downloaded a pinned file again after the
|
||||||
|
pin had downloaded it once.
|
||||||
|
**Where:** `apply_event` in `crates/pdfs-fuse/src/background.rs`; the new pin keeper in
|
||||||
|
`crates/pdfs-fuse/src/keeper.rs`
|
||||||
|
|
||||||
|
**Repro:** Pin a file, then change it from another device. The event deletes the cached copy.
|
||||||
|
The file stays listed as kept offline, `cached` is false, and the next read downloads it, or
|
||||||
|
fails without a network. Rename or move it elsewhere and the same happens, though its content
|
||||||
|
did not change. Add a file to a pinned folder from another device and it is never downloaded.
|
||||||
|
A folder pin whose downloads failed while the link was down never retries them.
|
||||||
|
|
||||||
|
**Cause:** A remote change to a known node called `ContentCache::evict`, which does not check
|
||||||
|
pins, and only the pin request itself downloaded anything. The copy was also stored under the
|
||||||
|
size the listing held at pin time. That is the ciphertext size until the size upgrade lands
|
||||||
|
(B12), and a copy stored under it never matches the plaintext it holds.
|
||||||
|
|
||||||
|
**Fix:** A remote change no longer evicts a pinned file's copy. The copy is checked against the
|
||||||
|
revision's `(mtime, size)`, so a read of a newer revision does not take it. A rename keeps the
|
||||||
|
revision's mtime (B123), so the copy stays valid. A pin keeper thread checks pins again and
|
||||||
|
downloads files that are missing or stale:
|
||||||
|
|
||||||
|
- the pins a batch of remote events changed, after the batch's listings are dropped;
|
||||||
|
- a pinned folder that gets a new file;
|
||||||
|
- every pin at startup, when the link comes back, and after the event feed loses continuity;
|
||||||
|
- a file whose version is restored.
|
||||||
|
|
||||||
|
Failed downloads are tried again with a backoff from 30 s to 30 min. Pinning settles a
|
||||||
|
provisional size before it stores the copy.
|
||||||
|
|
||||||
|
**Test:** `a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere` in
|
||||||
|
`crates/pdfs-fuse/src/sim/daemon.rs`. Another device edits a pinned file and a file in a pinned
|
||||||
|
folder, and adds a file to that folder. The test waits until all three are cached, then reads
|
||||||
|
them with the link down. With the keeper disabled, it fails with all three uncached.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B281 — A file in a synced folder cannot be kept offline
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-13. An image was made available offline in the app, and it opened from the app
|
||||||
|
with no download. `feh` opened it from a terminal and downloaded it each time. The two were not
|
||||||
|
the same file. The app had kept `~/ProtonDrive/Pictures/photo.png`, in My files. The
|
||||||
|
terminal opened `~/Pictures/photo.png`, in the on-demand synced folder `~/Pictures`. That
|
||||||
|
copy is a separate node under Computers, uploaded on its own, with the same name and size but a
|
||||||
|
different mtime. The daemon treats every program the same way: it does not look at the process
|
||||||
|
that reads.
|
||||||
|
**Where:** the `Pin` and `Unpin` handlers in `crates/pdfs-fuse/src/control.rs`
|
||||||
|
|
||||||
|
**Repro:** Add a synced folder in on-demand mode and put a file in it. `pdfs pin` on its absolute
|
||||||
|
path fails with "is not under the mountpoint". A relative path is resolved against My files, so
|
||||||
|
it keeps a file of the same name there, if one exists, or fails. Reading the file in the synced
|
||||||
|
folder then downloads it.
|
||||||
|
|
||||||
|
**Cause:** Both handlers turned the path into a mount-relative one with `rel_to_mount`, which only
|
||||||
|
knows the primary mount. B86 moved `ls`, `refresh`, `rm` and `mv` to `route_to_mount`, which finds
|
||||||
|
the mount that serves an absolute path. Pin and unpin were left out.
|
||||||
|
|
||||||
|
**Fix:** Pin and unpin route through `route_to_mount` and run on the mount that owns the path. A
|
||||||
|
path in a mirror folder gets an error that says its files are already on this computer. The pin
|
||||||
|
registry records the absolute local path for a pin made through an on-demand folder, so
|
||||||
|
`pdfs pins`, the prompt and the HUD open the file through the folder it was kept from. Pins
|
||||||
|
under the primary mount still record the path relative to it.
|
||||||
|
|
||||||
|
**Test:** `pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy` in
|
||||||
|
`crates/pdfs-fuse/src/sim/daemon.rs`. It pins a file through an on-demand folder's absolute path,
|
||||||
|
takes the link down, and reads the file through that folder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-12, after B279. 868 new pictures (15.6 GiB) were copied into
|
||||||
|
`~/ProtonDrive/Pictures`. Each restart reached 1.7 GiB resident and 9.8 GiB of swap within 40 s.
|
||||||
|
The 16 `pdfs-drain-*` threads were all busy, cgroup memory pressure (`full avg10`) was 77 %, and
|
||||||
|
the system's 15 GiB of swap was full. The watchdog killed the daemon, and the next start did the
|
||||||
|
same. The largest picture was a 97 MB JPEG of 11656×8742 pixels.
|
||||||
|
**Where:** `upload_thumbnails_with` and `scale_thumbnail` in `crates/pdfs-fuse/src/photos.rs`
|
||||||
|
|
||||||
|
**Repro:** Queue more large photos than there are drain workers, for example by copying a folder
|
||||||
|
of 100-megapixel JPEGs into the mount, and start the daemon.
|
||||||
|
|
||||||
|
**Cause.** Every upload of an image builds its thumbnail by decoding the whole file. Nothing
|
||||||
|
limited how many of these decodes ran at once, so all 16 drain workers did it together. One
|
||||||
|
102-megapixel JPEG holds about 300 MB of pixels, and twice that when it has to be turned upright.
|
||||||
|
Sixteen of them do not fit in memory, and `MemoryHigh=2G` throttled the daemon into swap until it
|
||||||
|
stopped answering.
|
||||||
|
|
||||||
|
**Fix.** Each thumbnail decode first takes a share of a process-wide 512 MiB budget. The share is
|
||||||
|
the encoded size plus 8 bytes per pixel, read from the image header. A 12-megapixel phone photo
|
||||||
|
still decodes beside four others. A 100-megapixel photo waits until no other decode runs, and
|
||||||
|
then runs alone. The upload path takes its share before it reads the file.
|
||||||
|
|
||||||
|
**Test:** `decode_budget_tests` in `crates/pdfs-fuse/src/photos.rs`. On 2026-11-12 the same queue
|
||||||
|
on the fixed build peaked at 1.3 GiB resident and 90 MB of swap, with memory pressure near 0 %.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B279 — After a watchdog kill the mount never comes back
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-12, copying a folder of pictures into `~/ProtonDrive/Pictures`. The daemon
|
||||||
|
stopped answering, the watchdog aborted it after 4 h 39 min (2.5 GiB memory peak, 11.8 GiB swap
|
||||||
|
peak), and every restart then logged
|
||||||
|
`create mountpoint /home/narl/ProtonDrive: File exists (os error 17)`. After the dead mounts were
|
||||||
|
cleared by hand, the next start mounted all six folders and was aborted again, 120 s after it
|
||||||
|
began, leaving six dead mounts behind once more.
|
||||||
|
**Where:** `mount_once` in `crates/pdfs-cli/src/main.rs`; `prepare_mountpoint` in
|
||||||
|
`crates/pdfs-fuse/src/mount.rs`; `crates/pdfs-fuse/src/supervisor.rs`;
|
||||||
|
`crates/pdfs-fuse/src/systemd.rs`
|
||||||
|
|
||||||
|
**Repro:** Kill the daemon with `SIGABRT` while it is mounted. Each restart fails with `EEXIST`
|
||||||
|
until `fusermount3 -uz` clears every mount. Then restart on an account where hydrating the
|
||||||
|
metadata cache takes about a minute: the watchdog aborts the new daemon before it pings.
|
||||||
|
|
||||||
|
**Cause.** Two faults, one after the other.
|
||||||
|
|
||||||
|
1. A dead FUSE mount answers `ENOTCONN`. `create_dir_all` in `mount_once` read that as "absent",
|
||||||
|
tried `mkdir`, and failed with `EEXIST`. The daemon retried that error forever and never
|
||||||
|
reached `check_mountpoint`, which clears a dead mount.
|
||||||
|
2. systemd arms the watchdog when the process starts, but the supervisor sent its first ping one
|
||||||
|
full period (60 s) after it started. The supervisor starts only once the mount is live: 74 s
|
||||||
|
in on this account (70 s of that is hydrating 47 958 nodes). 74 s + 60 s is past the 120 s
|
||||||
|
`WatchdogSec`, so the run was aborted just before its first ping.
|
||||||
|
|
||||||
|
**Fix.** `mount_once` calls `prepare_mountpoint`, which clears a dead mount before it creates the
|
||||||
|
directory. Before startup, the daemon sends `WATCHDOG_USEC=` to allow 10 minutes. When the
|
||||||
|
supervisor first gets an answer from the control socket, it sends the unit's own timeout back.
|
||||||
|
The supervisor also probes and pings on its first tick, not one period later. A startup that
|
||||||
|
hangs for good is still aborted, only later.
|
||||||
|
|
||||||
|
The daemon stopped answering during the copy because of
|
||||||
|
[B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed).
|
||||||
|
|
||||||
|
**Test:** `first_watchdog_ping_is_due_at_once` in `crates/pdfs-fuse/src/supervisor.rs`. The
|
||||||
|
stale-mount path and the watchdog handshake need a live systemd unit to check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### B278 — `sync list` and `devices list` show the last sync as epoch seconds
|
### B278 — `sync list` and `devices list` show the last sync as epoch seconds
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
|
|||||||
@@ -11,6 +11,171 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.8.0] — 2026-11-14
|
||||||
|
|
||||||
|
Schema: **45**. Synced folders now tell device backups apart from folders attached to My files.
|
||||||
|
Existing folders stay device backups. Older builds cannot open the upgraded database.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Sync a local folder with a folder you already have in My files** (#67, #77).
|
||||||
|
`pdfs sync attach ~/Work --to Work` keeps `~/Work` an ordinary local folder and syncs it both
|
||||||
|
ways with My files/Work. The first sync uploads files that are only on this computer, downloads
|
||||||
|
files that are only on Proton Drive, and keeps both versions of a file that differs: the one on
|
||||||
|
Proton Drive keeps its name, and the local one becomes a conflict copy next to it and is
|
||||||
|
uploaded too. Attached folders are always kept on this computer, never online only. They are
|
||||||
|
listed apart from device backups and are not part of the device profile used to restore another
|
||||||
|
computer. Stopping one keeps both copies, and `--delete-remote` is refused for it. If the folder
|
||||||
|
on Proton Drive is trashed or moved out of My files, syncing stops with an error and the local
|
||||||
|
files stay. Attaching a folder is done from a terminal for now.
|
||||||
|
|
||||||
|
- **Tab completion for `pdfs`** in bash, zsh and fish, installed by every package. Besides
|
||||||
|
commands and options it completes paths in My files (`pdfs pin Doc<Tab>`) and what the running
|
||||||
|
service knows, each with a description: synced folders, queued changes, computers, trash, albums,
|
||||||
|
items shared with you, invitations, bookmarks and conflict copies, and once a path is typed, the
|
||||||
|
file's versions or the share's members and link. Without the service it still completes commands
|
||||||
|
and options. Elvish and PowerShell load it from `pdfs completions SHELL`, and so does a source
|
||||||
|
build; the [command-line reference](CLI.md#shell-completion) has the line for each shell.
|
||||||
|
`pdfs share` and `pdfs share-role` now check the role (`viewer`, `editor` or `admin`) before
|
||||||
|
sending the request, and `pdfs public-link` does the same for `viewer` and `editor`.
|
||||||
|
|
||||||
|
- **Video thumbnails in My files, Shared, Trash and the launcher**, as in Photos, with a play
|
||||||
|
badge in the grid. A video's thumbnail is the one its uploader attached when it has one.
|
||||||
|
Otherwise the daemon takes a frame with ffmpeg from the start of the file, plus an MP4's
|
||||||
|
index, and never downloads more than about 16 MiB of a video for it, however large the
|
||||||
|
video is. **Build Thumbnails** covers videos too (B283).
|
||||||
|
|
||||||
|
- **A new layout for Photos.**
|
||||||
|
- The grid shows every photo at its own shape, in rows that fill the width (portraits
|
||||||
|
are no longer cropped to squares). **Square Tiles** in the page menu brings the even
|
||||||
|
grid back, and the Year zoom stays square.
|
||||||
|
- Timeline, Albums and Places switch in the header. The filters take one row, which
|
||||||
|
scrolls sideways in a narrow window.
|
||||||
|
- The section you are in floats over the top of the grid once its heading has scrolled
|
||||||
|
away. Headings are larger, and a check beside each one selects the whole day, month or
|
||||||
|
year.
|
||||||
|
- Selection mode works as in other GNOME apps: the header shows the count and
|
||||||
|
**Cancel**, and a bar along the bottom has these actions:
|
||||||
|
- **Add to Favorites**, or remove them when every selected photo is already one;
|
||||||
|
- **Add to Album**;
|
||||||
|
- **Remove from Album**;
|
||||||
|
- **Save Copies**, into a folder you pick, never overwriting a file;
|
||||||
|
- **Move to Trash**.
|
||||||
|
- Favorites carry a star on their tile, and a video waiting for its thumbnail shows a
|
||||||
|
video glyph.
|
||||||
|
- New icons for Photos, albums and the slideshow.
|
||||||
|
- **Upload** takes several photos and videos at once, and you can drop files onto the
|
||||||
|
timeline to upload them.
|
||||||
|
- The zoom level and the tile shape are remembered between runs.
|
||||||
|
- In the photo viewer, the details panel lies over the photo when the window is
|
||||||
|
narrow instead of squeezing it.
|
||||||
|
|
||||||
|
- **Larger icons in My files.** The icon size slider goes up to 256 px, big enough to
|
||||||
|
tell photos apart by their thumbnail. Ctrl+scroll, Ctrl+plus, Ctrl+minus and Ctrl+0
|
||||||
|
change it too, and the size is remembered between runs.
|
||||||
|
|
||||||
|
- **Back takes you to where you were.** Back, Forward and Up in My files return to a folder
|
||||||
|
scrolled where it was left, with the same items selected, and closing the photo viewer scrolls
|
||||||
|
the grid to the photo it was showing. Alt+Left and Backspace go back a level in Shared with me
|
||||||
|
and Computers and leave an open album or place in Photos, as Escape does too.
|
||||||
|
|
||||||
|
- **Share photos, and more in the photo viewer.**
|
||||||
|
- **Share…** in a photo's menu and in the viewer's ⋮ menu opens the usual Share dialog: invite
|
||||||
|
people or make a public link. It is not offered for a photo in an album someone else shared.
|
||||||
|
- **Copy Image** (Ctrl+C) puts the photo on screen on the clipboard.
|
||||||
|
- A strip of thumbnails along the bottom of the viewer shows the photos around the one on
|
||||||
|
screen; click one to jump to it. It fades with the other controls and stays hidden during a
|
||||||
|
slideshow.
|
||||||
|
- The line under the date names the town the photo was taken in, when it is known.
|
||||||
|
|
||||||
|
- **Drag photos out of Photos.** Select photos, then drag one of them into a file manager, a chat
|
||||||
|
or any app that takes files. The photos download when dropped and arrive under their own
|
||||||
|
names. A drag that starts on a photo that is not selected still selects, as before.
|
||||||
|
|
||||||
|
- **Upload progress in Photos.** Uploading several photos at once shows "Uploading 3 of 12
|
||||||
|
items…" with a progress bar above the timeline, instead of a single toast at the start.
|
||||||
|
|
||||||
|
- **Videos and music play in the app.** Opening a video or a song in My files, by a double-click
|
||||||
|
or **Open**, plays it in a player window, streamed from the mount like before. **Open With…**
|
||||||
|
still hands it to another player, and the separate **Play** item is gone. When the file can't
|
||||||
|
be played (a codec GStreamer lacks), the window says so and offers **Open With…**. Both this
|
||||||
|
player and the one in Photos gained a fullscreen button, fullscreen on a double-click,
|
||||||
|
**Repeat** in the ⋮ menu, `K` to play and pause, `J` and `L` (or Shift+←/→) to jump
|
||||||
|
10 seconds, and `M` to mute. The volume carries over from one video to the next, and the
|
||||||
|
thumbnail strip in Photos stays off a video, where it covered the seek bar.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
|
||||||
|
from the SDK, and a photo keeps its own modification time now that it no longer follows the
|
||||||
|
capture time. [The SDK review](SDK-0.11.0.md) lists the new features and breaking changes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Photos zoomed all the way out shows the whole timeline.** The first 200 photos fit on one
|
||||||
|
screen there, and with nothing to scroll no more were loaded. More now load whenever the photos
|
||||||
|
don't fill the window, also after a zoom or a resize.
|
||||||
|
|
||||||
|
- **My files comes back after the service was killed for hanging** (B279). A killed service left
|
||||||
|
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
|
||||||
|
forever without opening anything, until the mounts were cleared by hand. The service now clears
|
||||||
|
a dead mount before it opens the folder. On a large account a restart that did open My files was
|
||||||
|
killed again, because loading the account took longer than the 2 minutes the service has to
|
||||||
|
report in. Startup now gets 10 minutes, and the 2 minutes apply again once the service answers.
|
||||||
|
|
||||||
|
- **Uploading many large photos no longer runs the computer out of memory** (B280). Every image
|
||||||
|
upload decodes the whole picture to make its thumbnail, and up to 16 did so at once. A batch of
|
||||||
|
100-megapixel photos took about 9 GiB, filled the swap, and the service was killed on every
|
||||||
|
restart. Thumbnails now share 512 MiB between them: phone photos are still made side by side,
|
||||||
|
and a very large photo waits and is made on its own.
|
||||||
|
|
||||||
|
- **Keep a file offline in an on-demand synced folder** (B281). `pdfs pin` and `pdfs unpin` now
|
||||||
|
accept a path in a synced folder such as `~/Pictures`. Before, they only took paths under the
|
||||||
|
Proton Drive mount, so the copy in the synced folder was downloaded again by every program that
|
||||||
|
read it.
|
||||||
|
|
||||||
|
- **Files kept offline stay on this computer when they change elsewhere** (B282). A change from
|
||||||
|
another device, even a rename, deleted the copy of a file kept offline, and nothing downloaded
|
||||||
|
it again, so the next read downloaded it or failed without a network. Files added to a folder
|
||||||
|
kept offline from another device were never downloaded. The copy now stays until the new
|
||||||
|
version is downloaded, which happens on its own. Files kept offline are also checked at
|
||||||
|
startup and when the connection returns, and failed downloads are tried again.
|
||||||
|
|
||||||
|
- **Large `.ts` recordings show and open as videos** (B283). An MPEG transport stream got the
|
||||||
|
text icon, no thumbnail, and the `@text` open rule. It now gets the video icon, a thumbnail
|
||||||
|
and the `@media` rule. A `.ts` of 1 MiB or more counts as a video, a smaller one stays
|
||||||
|
TypeScript source, and the daemon checks the stream's sync byte before it takes a frame.
|
||||||
|
|
||||||
|
- **Videos over 256 MiB get a thumbnail** (B283). They never got one, and a smaller video was
|
||||||
|
downloaded whole into memory for one frame.
|
||||||
|
|
||||||
|
- **A folder opens at the top.** A folder opened in My files, Shared with me or Computers could
|
||||||
|
open scrolled to where its parent was left, often at the bottom. Another folder, a new search
|
||||||
|
and a new Photos filter, album or place now start at the top, and a refresh keeps its place.
|
||||||
|
|
||||||
|
- **Closing a photo opened from the Places map goes back to the map**, not to the town's
|
||||||
|
timeline.
|
||||||
|
|
||||||
|
- **Albums and Places stay on screen.** They could switch to the timeline by themselves when a
|
||||||
|
timeline page arrived late, after an upload or after a trash Undo. Coming back to Photos no
|
||||||
|
longer reloads the timeline and loses its scroll position, unless photos changed while it was
|
||||||
|
out of sight. After an album or a place, the timeline came back unfiltered while the kind tab
|
||||||
|
and the date still showed the filter.
|
||||||
|
|
||||||
|
- **A search in My files stays.** It was replaced by the folder's listing whenever files
|
||||||
|
changed, on refresh and on coming back to the page, while the query stayed in the box.
|
||||||
|
|
||||||
|
- **The photo viewer's buttons are readable on any photo.** They were white on a see-through
|
||||||
|
white, and vanished over a bright photo. The bar now keeps only the favorite star, the details
|
||||||
|
and a menu, which names Slideshow, Save a Copy, Open With, Add to Album, Show on Map and Move to
|
||||||
|
Trash in words where eight icons stood. A shot stored as more than one file shows a "RAW" or
|
||||||
|
"Live" switch, or "2 of 3" for a burst, where an unlabelled icon was. The title reads
|
||||||
|
"Wednesday, October 7, 2026, 13:42" rather than a timestamp. Delete asks first, as it does in
|
||||||
|
the grid, and Alt+Left closes the viewer.
|
||||||
|
|
||||||
|
- **A date jump in Photos stops when you look elsewhere.** A jump still loading the timeline
|
||||||
|
kept going after another filter, album or place was opened.
|
||||||
|
|
||||||
## [3.7.0] — 2026-11-02
|
## [3.7.0] — 2026-11-02
|
||||||
|
|
||||||
Schema: **44**, unchanged.
|
Schema: **44**, unchanged.
|
||||||
|
|||||||
+34
-1
@@ -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).
|
what the commands do in the app, see the [user guide](USER_GUIDE.md).
|
||||||
|
|
||||||
- [Conventions](#conventions)
|
- [Conventions](#conventions)
|
||||||
|
- [Shell completion](#shell-completion)
|
||||||
- [Account and service](#account-and-service)
|
- [Account and service](#account-and-service)
|
||||||
- [Files](#files)
|
- [Files](#files)
|
||||||
- [Available offline](#available-offline)
|
- [Available offline](#available-offline)
|
||||||
@@ -32,6 +33,36 @@ what the commands do in the app, see the [user guide](USER_GUIDE.md).
|
|||||||
service.
|
service.
|
||||||
- **Output** is English and stable enough for `grep`; use `--json` where it is available.
|
- **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
|
## Account and service
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -71,7 +102,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). PATH can be under the Proton Drive mount or in an on-demand synced folder. A file in a mirror folder is already on this computer. |
|
||||||
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
|
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
|
||||||
| `pdfs pins` | List files and folders kept available offline. |
|
| `pdfs pins` | List files and folders kept available offline. |
|
||||||
|
|
||||||
@@ -80,6 +111,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pdfs sync add PATH` | Back up a local folder under this computer and keep it in sync. |
|
| `pdfs sync add PATH` | Back up a local folder under this computer and keep it in sync. |
|
||||||
|
| `pdfs sync attach PATH --to REMOTE` | Merge a real local folder with an existing My files folder, keeping both versions on initial conflicts. |
|
||||||
| `pdfs sync list` | List synced folders with their IDs and state. |
|
| `pdfs sync list` | List synced folders with their IDs and state. |
|
||||||
| `pdfs sync rm ID [--delete-remote]` | Stop syncing. Local files stay; `--delete-remote` also removes the copy on Proton Drive. |
|
| `pdfs sync rm ID [--delete-remote]` | Stop syncing. Local files stay; `--delete-remote` also removes the copy on Proton Drive. |
|
||||||
| `pdfs sync mode ID mirror\|ondemand` | Switch between a full local copy (`mirror`) and online only (`ondemand`). |
|
| `pdfs sync mode ID mirror\|ondemand` | Switch between a full local copy (`mirror`) and online only (`ondemand`). |
|
||||||
@@ -184,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 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 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 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.
|
[Troubleshooting](TROUBLESHOOTING.md) explains when to use each of these.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ That URL follows `main`. To stay on releases, pin the input to a release tag ins
|
|||||||
the tag to update:
|
the tag to update:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.7.0";
|
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.8.0";
|
||||||
```
|
```
|
||||||
|
|
||||||
Each release tag is built with Nix before the release is published.
|
Each release tag is built with Nix before the release is published.
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Issue #78: session loss on Fedora 45
|
||||||
|
|
||||||
|
Investigation on 2026-11-14 of [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78),
|
||||||
|
using its report and comments through `gh`, pdfs 3.7.0/current source, and the oo7 0.7.0 tag.
|
||||||
|
|
||||||
|
## What is established
|
||||||
|
|
||||||
|
The reporter still saw the problem with oo7 0.7.0 on November 7. The original
|
||||||
|
[SetSecret persistence fix](https://github.com/linux-credentials/oo7/commit/7fb11dd111c4be8fec01996e3caa31c44fa962b3)
|
||||||
|
is present in that release. In an isolated test, three successive binary `SetSecret` updates
|
||||||
|
survived reopening the keyring file. Repeating the original persistence explanation is therefore
|
||||||
|
insufficient.
|
||||||
|
|
||||||
|
A different, reproducible failure exists at the boundary between oo7 and pdfs:
|
||||||
|
|
||||||
|
1. oo7 0.7.0's `Collection::search_inner_items` returns an empty list for a locked collection.
|
||||||
|
`SearchItems` consequently returns **both** the unlocked and locked lists empty even when the
|
||||||
|
matching item is on disk. The isolated test reproduces this with pdfs's exact lookup attributes.
|
||||||
|
This is upstream [oo7 #614](https://github.com/linux-credentials/oo7/issues/614).
|
||||||
|
2. `dbus-secret-service-keyring-store` 1.0.1 maps an empty search to `keyring_core::Error::NoEntry`;
|
||||||
|
pdfs maps that to `Error::NotLoggedIn`. The client cannot offer an unlock when no locked item
|
||||||
|
is reported.
|
||||||
|
3. In pdfs 3.7.0 and the code before this change, `TokenSink::flush` treated `NotLoggedIn` as a
|
||||||
|
logout, cleared the pending rotation and returned success. It skipped the error path that
|
||||||
|
retains the new tokens in the kernel keyring and retries their persistence. The code was
|
||||||
|
unchanged between v3.7.0 and this investigation.
|
||||||
|
4. If a token refresh encounters that reply, the saved blob retains a spent refresh token. The
|
||||||
|
running process can keep working with the new tokens in memory; a restart then exposes the
|
||||||
|
stale saved credentials. A regression test failed before the fix and passes afterward.
|
||||||
|
|
||||||
|
The same unsafe assumption also applies to transient empty lookups such as the race reported in
|
||||||
|
[oo7 #652](https://github.com/linux-credentials/oo7/issues/652), fixed in 0.7.1. That issue's
|
||||||
|
replacement race was not independently reproduced here; pdfs normally updates existing items
|
||||||
|
with `SetSecret`, so it must not be assumed to encounter the same-item `CreateItem` race.
|
||||||
|
|
||||||
|
## Change and limits
|
||||||
|
|
||||||
|
`TokenSink::flush` now propagates a missing-session error instead of discarding the rotation.
|
||||||
|
The existing retry, kernel-keyring recovery and unsaved-token status paths can then run. Once
|
||||||
|
the collection is unlocked and readable, the pending rotation is saved. A genuinely deleted
|
||||||
|
session is never recreated: flushing still requires a readable blob with the matching session
|
||||||
|
ID and expected refresh token. A new login causes the old rotation to be discarded as before.
|
||||||
|
|
||||||
|
This is a confirmed pdfs defect (B284), **not a confirmed complete explanation of the reporter's
|
||||||
|
cold-boot failure**. The issue has no oo7 logs or record showing that its collection was locked
|
||||||
|
when a token rotated. A locked keyring only at startup can hide a login, but does not by itself
|
||||||
|
explain `InvalidRefreshToken`. The token-loss path requires a successful refresh while the saved
|
||||||
|
entry is unreadable.
|
||||||
|
|
||||||
|
The fix does not make a locked keyring writable. If it stays unreadable until power-off, tokens
|
||||||
|
retained in process/kernel memory still cannot survive a cold boot. No new disk credential store
|
||||||
|
or credential format is introduced.
|
||||||
|
|
||||||
|
Other upstream Fedora-upgrade issues were checked: [oo7 #651](https://github.com/linux-credentials/oo7/issues/651)
|
||||||
|
creates duplicate placeholder/migrated collections and refuses writes; [oo7 #662](https://github.com/linux-credentials/oo7/issues/662)
|
||||||
|
can resolve the default alias to a locked collection. Both have fixes in
|
||||||
|
[0.7.1](https://github.com/linux-credentials/oo7/releases/tag/0.7.1), released November 10,
|
||||||
|
after the reporter's November 7 retest. Neither can be assigned as this report's cause without
|
||||||
|
matching runtime evidence.
|
||||||
|
|
||||||
|
## Reproduce without a Proton account or desktop keyring
|
||||||
|
|
||||||
|
The oo7 fixture uses fake tokens, a temporary disk keyring and a private peer-to-peer D-Bus
|
||||||
|
connection. It asserts the observed 0.7.0 behavior, including the incorrect empty locked lookup;
|
||||||
|
passing means that behavior was reproduced. It does not simulate Proton refresh or a full boot.
|
||||||
|
|
||||||
|
From the pdfs repository, choose an unused temporary checkout path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone --depth 1 --branch 0.7.0 https://github.com/linux-credentials/oo7.git /tmp/pdfs-oo7-repro
|
||||||
|
git -C /tmp/pdfs-oo7-repro apply "$PWD/docs/repro/oo7-0.7.0-issue78.patch"
|
||||||
|
cargo test --manifest-path /tmp/pdfs-oo7-repro/Cargo.toml \
|
||||||
|
-p oo7-daemon --lib --features test-util issue78 -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)
|
||||||
|
oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring
|
||||||
|
test item::tests::issue78_locked_lookup_hides_saved_tokens ... ok
|
||||||
|
```
|
||||||
|
|
||||||
|
The pdfs side is covered by `cargo test -p pdfs-core auth::tests`, specifically
|
||||||
|
`a_temporarily_missing_session_does_not_discard_a_rotation` and
|
||||||
|
`retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`.
|
||||||
|
|
||||||
|
Remaining live validation: reproduce on a Fedora 45 GNOME session across logout/shutdown,
|
||||||
|
correlating oo7 lock/write errors with pdfs refresh and retry logs, and checking the running
|
||||||
|
daemon's package version rather than just the CLI version. The investigation did not use a real
|
||||||
|
Proton account, alter a desktop keyring, or post to either GitHub issue.
|
||||||
@@ -0,0 +1,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.
|
||||||
+28
-1
@@ -5,6 +5,7 @@ up to Proton Drive and kept in step with it. This page covers adding one, what t
|
|||||||
that should not be backed up, changes that are still waiting to upload, conflicts, and pausing.
|
that should not be backed up, changes that are still waiting to upload, conflicts, and pausing.
|
||||||
|
|
||||||
- [Add a folder](#add-a-folder)
|
- [Add a folder](#add-a-folder)
|
||||||
|
- [Attach an existing My files folder](#attach-an-existing-my-files-folder)
|
||||||
- [Synced or online only](#synced-or-online-only)
|
- [Synced or online only](#synced-or-online-only)
|
||||||
- [Move files between synced folders and My files](#move-files-between-synced-folders-and-my-files)
|
- [Move files between synced folders and My files](#move-files-between-synced-folders-and-my-files)
|
||||||
- [Stop syncing a folder](#stop-syncing-a-folder)
|
- [Stop syncing a folder](#stop-syncing-a-folder)
|
||||||
@@ -74,7 +75,8 @@ again when they move into a synced one.
|
|||||||
|
|
||||||
**Stop Syncing** in the app, or `pdfs sync rm ID`, keeps both the local files and the copy on
|
**Stop Syncing** in the app, or `pdfs sync rm ID`, keeps both the local files and the copy on
|
||||||
Proton Drive. To delete the copy on Proton Drive as well, tick **Also delete from Proton Drive**,
|
Proton Drive. To delete the copy on Proton Drive as well, tick **Also delete from Proton Drive**,
|
||||||
or add `--delete-remote`.
|
or add `--delete-remote`. Attached My files folders always keep the cloud copy;
|
||||||
|
`--delete-remote` is refused for them.
|
||||||
|
|
||||||
## Sync a folder from scratch
|
## Sync a folder from scratch
|
||||||
|
|
||||||
@@ -169,3 +171,28 @@ The version you do not keep goes to the Trash, so a wrong choice can be undone.
|
|||||||
`pdfs sync resume` continues at once.
|
`pdfs sync resume` continues at once.
|
||||||
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
||||||
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
||||||
|
|
||||||
|
## Attach an existing My files folder
|
||||||
|
|
||||||
|
Run `pdfs sync attach /path/to/local --to Work` to merge a real local directory
|
||||||
|
with the existing `Work` folder in My files. `--to` also accepts an absolute path
|
||||||
|
inside the My files mount. Both folders must already exist.
|
||||||
|
|
||||||
|
The local directory stays a normal mirrored directory. The first pass copies files
|
||||||
|
found on only one side, adopts identical files, and keeps a conflict copy when
|
||||||
|
versions differ; it does not propagate deletions. Later passes use ordinary
|
||||||
|
bidirectional sync, including deletions and the existing wipe guards.
|
||||||
|
|
||||||
|
The My files root, shared folders, overlapping sync roots, and local paths inside
|
||||||
|
or containing the cloud mount are refused. Use `pdfs sync list` to find the new
|
||||||
|
folder's ID and monitor its state. `pdfs sync rm ID` stops syncing and keeps both
|
||||||
|
copies. `--delete-remote` is refused for attached folders.
|
||||||
|
|
||||||
|
Attached folders support **mirror** mode only. Use the main My files mount for online-only
|
||||||
|
access. They are listed separately from device backups and are not included in the device
|
||||||
|
profile used to restore another computer.
|
||||||
|
|
||||||
|
On the first sync, the remote version keeps the original name when both versions differ.
|
||||||
|
The local version is preserved as a conflict copy next to it and is uploaded too. If the
|
||||||
|
remote root is trashed or moved outside My files, syncing stops with an error and local
|
||||||
|
files are kept.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
diff --git a/server/src/item/tests.rs b/server/src/item/tests.rs
|
||||||
|
index 99c25dc..2ba705d 100644
|
||||||
|
--- a/server/src/item/tests.rs
|
||||||
|
+++ b/server/src/item/tests.rs
|
||||||
|
@@ -5,6 +5,48 @@ use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
use crate::{service::PrompterType, tests::TestServiceSetup};
|
||||||
|
|
||||||
|
+// Investigation for proton-drive-linux #78. Fake tokens and a private p2p bus;
|
||||||
|
+// no desktop keyring or Proton account is used.
|
||||||
|
+#[tokio::test]
|
||||||
|
+async fn issue78_locked_lookup_hides_saved_tokens() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
+ let setup =
|
||||||
|
+ TestServiceSetup::with_login_keyring(oo7::Secret::from("test-password-long-enough"))
|
||||||
|
+ .await?;
|
||||||
|
+ let collection = setup.default_collection().await?;
|
||||||
|
+ let attributes = [("service", "proton-drive-linux"), ("username", "session")];
|
||||||
|
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(b"fake-refresh-r1"))?;
|
||||||
|
+ collection
|
||||||
|
+ .create_item("pdfs fake session", &attributes, &secret, false, None)
|
||||||
|
+ .await?;
|
||||||
|
+
|
||||||
|
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
||||||
|
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
|
||||||
|
+ setup.lock_collection(collection).await?;
|
||||||
|
+ assert!(collection.is_locked().await?);
|
||||||
|
+
|
||||||
|
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
||||||
|
+ // This is the problematic reply, not the Secret Service behavior expected
|
||||||
|
+ // by keyring-store: the saved item is neither unlocked nor reported locked.
|
||||||
|
+ assert_eq!((unlocked.len(), locked.len()), (0, 0));
|
||||||
|
+ assert_eq!(setup.login_keyring().await?.items().await?.len(), 1);
|
||||||
|
+ eprintln!("oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)");
|
||||||
|
+
|
||||||
|
+ setup.unlock_collection(collection).await?;
|
||||||
|
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
||||||
|
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
|
||||||
|
+
|
||||||
|
+ // Exercise the binary SetSecret path used by pdfs's keyring-store. Reopen
|
||||||
|
+ // the disk keyring after each update, rather than trusting a memory read.
|
||||||
|
+ for refresh in [b"fake-refresh-r2", b"fake-refresh-r3", b"fake-refresh-r4"] {
|
||||||
|
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(refresh))?;
|
||||||
|
+ unlocked[0].set_secret(&secret).await?;
|
||||||
|
+ let items = setup.login_keyring().await?.items().await?;
|
||||||
|
+ assert_eq!(items[0].secret().as_bytes(), refresh);
|
||||||
|
+ }
|
||||||
|
+ eprintln!("oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring");
|
||||||
|
+ Ok(())
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
#[tokio::test]
|
||||||
|
async fn label_property() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let setup = TestServiceSetup::plain_session(true).await?;
|
||||||
+4
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=3.7.0
|
pkgver=3.8.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -80,5 +80,8 @@ package() {
|
|||||||
# Translations: compiled from po/ into /usr/share/locale.
|
# Translations: compiled from po/ into /usr/share/locale.
|
||||||
po/build.sh "$pkgdir/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"
|
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-bin
|
pkgbase = proton-drive-for-linux-bin
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||||
pkgver = 3.6.0
|
pkgver = 3.7.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
install = proton-drive.install
|
install = proton-drive.install
|
||||||
@@ -22,21 +22,21 @@ pkgbase = proton-drive-for-linux-bin
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=3.6.0
|
provides = proton-drive-for-linux=3.7.0
|
||||||
provides = proton-drive-linux=3.6.0
|
provides = proton-drive-linux=3.7.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !strip
|
options = !strip
|
||||||
options = !debug
|
options = !debug
|
||||||
source = proton-drive-linux-3.6.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.6.0/proton-drive-linux-3.6.0-x86_64.tar.gz
|
source = proton-drive-linux-3.7.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.7.0/proton-drive-linux-3.7.0-x86_64.tar.gz
|
||||||
source = io.narl.proton-drive-linux-3.6.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.desktop
|
source = io.narl.proton-drive-linux-3.7.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux.desktop
|
||||||
source = io.narl.proton-drive-linux-tray-3.6.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
source = io.narl.proton-drive-linux-tray-3.7.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||||
source = io.narl.proton-drive-linux-3.6.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.svg
|
source = io.narl.proton-drive-linux-3.7.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux.svg
|
||||||
source = proton-drive-3.6.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/proton-drive.service
|
source = proton-drive-3.7.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/proton-drive.service
|
||||||
source = LICENSE-3.6.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/LICENSE
|
source = LICENSE-3.7.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/LICENSE
|
||||||
sha256sums = 8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7
|
sha256sums = 3f0d16c19b10b30e3f181e3c7fb439f0a8c38980e8ab7a4a96ae6596b349787c
|
||||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-bin
|
pkgname=proton-drive-for-linux-bin
|
||||||
pkgver=3.6.0
|
pkgver=3.7.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -36,7 +36,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
|
|||||||
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||||
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
||||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||||
sha256sums=('8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7'
|
sha256sums=('3f0d16c19b10b30e3f181e3c7fb439f0a8c38980e8ab7a4a96ae6596b349787c'
|
||||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||||
@@ -64,6 +64,14 @@ package() {
|
|||||||
install -dm755 "$pkgdir/usr/share"
|
install -dm755 "$pkgdir/usr/share"
|
||||||
cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale"
|
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"
|
install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
|
||||||
# pacman brings new releases, so the app never checks for them itself.
|
# pacman brings new releases, so the app never checks for them itself.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-git
|
pkgbase = proton-drive-for-linux-git
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||||
pkgver = 3.6.0.r0.g2720191
|
pkgver = 3.7.0.r0.g7ab14bb
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
install = proton-drive.install
|
install = proton-drive.install
|
||||||
@@ -25,8 +25,8 @@ pkgbase = proton-drive-for-linux-git
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=3.6.0.r0.g2720191
|
provides = proton-drive-for-linux=3.7.0.r0.g7ab14bb
|
||||||
provides = proton-drive-linux=3.6.0.r0.g2720191
|
provides = proton-drive-linux=3.7.0.r0.g7ab14bb
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-git
|
pkgname=proton-drive-for-linux-git
|
||||||
pkgver=3.6.0.r0.g2720191
|
pkgver=3.7.0.r0.g7ab14bb
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -81,6 +81,9 @@ package() {
|
|||||||
# Translations: compiled from po/ into /usr/share/locale.
|
# Translations: compiled from po/ into /usr/share/locale.
|
||||||
po/build.sh "$pkgdir/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 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux
|
pkgbase = proton-drive-for-linux
|
||||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||||
pkgver = 3.6.0
|
pkgver = 3.7.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
install = proton-drive.install
|
install = proton-drive.install
|
||||||
@@ -24,13 +24,13 @@ pkgbase = proton-drive-for-linux
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-linux=3.6.0
|
provides = proton-drive-linux=3.7.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux-bin
|
conflicts = proton-drive-for-linux-bin
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !lto
|
options = !lto
|
||||||
source = proton-drive-for-linux-3.6.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.6.0.tar.gz
|
source = proton-drive-for-linux-3.7.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.7.0.tar.gz
|
||||||
sha256sums = d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d
|
sha256sums = 542c534976e335cde6658de127e1eefffade9057d5332fd33457bc371c7a2737
|
||||||
|
|
||||||
pkgname = proton-drive-for-linux
|
pkgname = proton-drive-for-linux
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux
|
pkgname=proton-drive-for-linux
|
||||||
pkgver=3.6.0
|
pkgver=3.7.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -26,7 +26,7 @@ options=('!lto')
|
|||||||
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
||||||
install=proton-drive.install
|
install=proton-drive.install
|
||||||
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
sha256sums=('d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d')
|
sha256sums=('542c534976e335cde6658de127e1eefffade9057d5332fd33457bc371c7a2737')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
@@ -76,6 +76,9 @@ package() {
|
|||||||
# Translations: compiled from po/ into /usr/share/locale.
|
# Translations: compiled from po/ into /usr/share/locale.
|
||||||
po/build.sh "$pkgdir/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 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
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.desktop
|
||||||
../io.narl.proton-drive-linux-tray.desktop
|
../io.narl.proton-drive-linux-tray.desktop
|
||||||
../io.narl.proton-drive-linux.svg
|
../io.narl.proton-drive-linux.svg
|
||||||
|
../completions.sh
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,6 +108,9 @@ rustPlatform.buildRustPackage {
|
|||||||
patchShebangs po/build.sh
|
patchShebangs po/build.sh
|
||||||
po/build.sh $out/share/locale
|
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.
|
# Nix brings new releases, so the app never checks for them itself.
|
||||||
install -Dm644 /dev/null $out/share/proton-drive-linux/managed-updates
|
install -Dm644 /dev/null $out/share/proton-drive-linux/managed-updates
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
||||||
%global _lto_cflags %{nil}
|
%global _lto_cflags %{nil}
|
||||||
|
|
||||||
%{!?version: %global version 3.7.0}
|
%{!?version: %global version 3.8.0}
|
||||||
%global forge https://github.com/narrrl/proton-drive-linux
|
%global forge https://github.com/narrrl/proton-drive-linux
|
||||||
# Files come from the checkout, or from the unpacked tag archive.
|
# Files come from the checkout, or from the unpacked tag archive.
|
||||||
%global src %{?git_dir}%{!?git_dir:.}
|
%global src %{?git_dir}%{!?git_dir:.}
|
||||||
@@ -114,6 +114,7 @@ install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux.svg \
|
|||||||
install -D -m0644 %{src}/packaging/proton-drive.service \
|
install -D -m0644 %{src}/packaging/proton-drive.service \
|
||||||
%{buildroot}/usr/lib/systemd/user/proton-drive.service
|
%{buildroot}/usr/lib/systemd/user/proton-drive.service
|
||||||
%{src}/po/build.sh %{buildroot}%{_datadir}/locale
|
%{src}/po/build.sh %{buildroot}%{_datadir}/locale
|
||||||
|
%{src}/packaging/completions.sh "$rel/pdfs" %{buildroot}%{_datadir}
|
||||||
%if 0%{!?git_dir:1}
|
%if 0%{!?git_dir:1}
|
||||||
# dnf brings new releases from COPR, so the app does not offer its own check.
|
# 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
|
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
|
%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
|
||||||
/usr/lib/systemd/user/proton-drive.service
|
/usr/lib/systemd/user/proton-drive.service
|
||||||
%{_datadir}/locale/*/LC_MESSAGES/pdfs.mo
|
%{_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}
|
%if 0%{!?git_dir:1}
|
||||||
%{_datadir}/proton-drive-linux/managed-updates
|
%{_datadir}/proton-drive-linux/managed-updates
|
||||||
%endif
|
%endif
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ crates/pdfs-gui/src/app/widgets/compat.rs
|
|||||||
crates/pdfs-gui/src/app/widgets/details.rs
|
crates/pdfs-gui/src/app/widgets/details.rs
|
||||||
crates/pdfs-gui/src/app/widgets/file_list.rs
|
crates/pdfs-gui/src/app/widgets/file_list.rs
|
||||||
crates/pdfs-gui/src/app/widgets/menu.rs
|
crates/pdfs-gui/src/app/widgets/menu.rs
|
||||||
|
crates/pdfs-gui/src/app/widgets/player.rs
|
||||||
crates/pdfs-gui/src/app/widgets/share_dialog.rs
|
crates/pdfs-gui/src/app/widgets/share_dialog.rs
|
||||||
crates/pdfs-gui/src/app/widgets/thumbnails.rs
|
crates/pdfs-gui/src/app/widgets/thumbnails.rs
|
||||||
crates/pdfs-gui/src/app/widgets/versions_dialog.rs
|
crates/pdfs-gui/src/app/widgets/versions_dialog.rs
|
||||||
|
|||||||
+1248
-1063
File diff suppressed because it is too large.
Load diff
+1223
-1065
File diff suppressed because it is too large.
Load diff
+1251
-1066
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.
|
# Too long or too internal for the wiki; links to them go to GitHub.
|
||||||
EXCLUDED = {"BUGS.md", "CHANGELOG.md"}
|
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "ISSUE-78.md", "SDK-0.11.0.md"}
|
||||||
|
|
||||||
# Files outside docs/ whose links are checked but not published.
|
# Files outside docs/ whose links are checked but not published.
|
||||||
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]
|
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]
|
||||||
|
|||||||
Reference in new issue
Block a user