mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-15 17:41:59 +01:00
Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05772aa13 | ||
|
|
2f2fe96cdf | ||
|
|
2ec3b7ff62 | ||
|
|
38bd282d3a | ||
|
|
d773ba15d3 | ||
|
|
d93982baa9 | ||
|
|
3f5001b2d2 | ||
|
|
2308021aa2 | ||
|
|
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 | ||
|
|
7ab14bb8f7 | ||
|
|
9c8b6680a7 | ||
|
|
bbd913b87d | ||
|
|
04f4610e4d | ||
|
|
0a5afd0b6a | ||
|
|
163c54a1e9 | ||
|
|
0d7c6e7bae | ||
|
|
10252fa84b | ||
|
|
bf9ec0862a | ||
|
|
d0d0c73a51 | ||
|
|
129ad1a50d | ||
|
|
506a610273 | ||
|
|
5048492a83 | ||
|
|
321547fb77 | ||
|
|
a06a8150fb | ||
|
|
c4bce50d35 | ||
|
|
d2b5916b99 | ||
|
|
4626ce17a7 | ||
|
|
630f316fe6 | ||
|
|
801681f747 | ||
|
|
e7382f2c7e | ||
|
|
7046f0f3ef | ||
|
|
37e8dae61b | ||
|
|
c52f2098f2 | ||
|
|
f4a2674b9c | ||
|
|
347861bbc1 | ||
|
|
c57763b07f | ||
|
|
846dfd8d14 | ||
|
|
2ae99de844 | ||
|
|
69ff216e7d | ||
|
|
639ca359fa |
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
+462
-406
File diff suppressed because it is too large.
Load diff
+14
-6
@@ -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.6.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.12.0" }
|
||||||
proton-drive-rs = { version = "0.10.1" }
|
proton-drive-rs = { version = "0.12.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" }
|
||||||
@@ -33,7 +34,7 @@ anyhow = "1"
|
|||||||
# wedge every later acquisition of it: `Workers` catches the unwind to keep the
|
# wedge every later acquisition of it: `Workers` catches the unwind to keep the
|
||||||
# pool alive, and `std::sync::Mutex` would defeat that by poisoning the state
|
# pool alive, and `std::sync::Mutex` would defeat that by poisoning the state
|
||||||
# the recovered worker needs.
|
# the recovered worker needs.
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.13"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "6"
|
directories = "6"
|
||||||
@@ -41,7 +42,7 @@ ignore = "0.4"
|
|||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
# Google Takeout exports are zip sets; the importer reads their central
|
# Google Takeout exports are zip sets; the importer reads their central
|
||||||
# directories and streams entries out without extracting to disk first.
|
# directories and streams entries out without extracting to disk first.
|
||||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||||
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||||
# import has to speak SHA-1 whatever its age.
|
# import has to speak SHA-1 whatever its age.
|
||||||
sha1 = "0.11"
|
sha1 = "0.11"
|
||||||
@@ -49,13 +50,20 @@ sha1 = "0.11"
|
|||||||
# formats a camera or phone actually produces — the rest of the crate's codecs
|
# formats a camera or phone actually produces — the rest of the crate's codecs
|
||||||
# would be dead weight in the daemon.
|
# would be dead weight in the daemon.
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
# Cover pictures of songs, read from their tags (ID3, FLAC, Ogg, MP4, APE) for
|
||||||
|
# the song's thumbnail. Its default feature is required: 0.25 does not build
|
||||||
|
# without it.
|
||||||
|
lofty = "0.25"
|
||||||
|
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||||
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
||||||
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
||||||
# with no `target` it matches items v3 wrote, so existing logins survive.
|
# with no `target` it matches items v3 wrote, so existing logins survive.
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+370
-77
@@ -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.
|
||||||
@@ -221,14 +228,19 @@ enum Command {
|
|||||||
/// numbered parts, and a photo's metadata often sits in a different part
|
/// numbered parts, and a photo's metadata often sits in a different part
|
||||||
/// than the photo. Albums in the export are recreated as Proton albums, and
|
/// than the photo. Albums in the export are recreated as Proton albums, and
|
||||||
/// photos already on the account (same name and same content) are skipped,
|
/// photos already on the account (same name and same content) are skipped,
|
||||||
/// so an interrupted import can simply be run again.
|
/// so an interrupted import can simply be run again, or resumed with
|
||||||
|
/// `--resume` without naming the archives 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_unless_present = "resume", 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, conflicts_with = "resume")]
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
|
/// Start the last import again with the same archives, after it was
|
||||||
|
/// interrupted, stopped or failed.
|
||||||
|
#[arg(long, conflicts_with = "archives")]
|
||||||
|
resume: bool,
|
||||||
/// Stay attached and print progress until the import finishes.
|
/// Stay attached and print progress until the import finishes.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
wait: bool,
|
wait: bool,
|
||||||
@@ -277,6 +289,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 +306,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 +314,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 +324,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 +333,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 +352,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 +373,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 +385,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 +394,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 +418,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 +436,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 +454,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 +481,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 +496,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 +519,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 +543,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 +589,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 +647,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 +656,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 +691,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 +716,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 +726,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 +739,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 +756,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 +783,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 +791,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 +801,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 +826,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 +837,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 +846,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 +866,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 +876,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 +892,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 +916,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 +929,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 +953,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 +976,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
|
||||||
@@ -937,8 +1035,9 @@ fn main() -> Result<()> {
|
|||||||
Command::ImportGooglePhotos {
|
Command::ImportGooglePhotos {
|
||||||
archives,
|
archives,
|
||||||
dry_run,
|
dry_run,
|
||||||
|
resume,
|
||||||
wait,
|
wait,
|
||||||
} => cmd_import_google_photos(archives, dry_run, wait),
|
} => cmd_import_google_photos(archives, dry_run, resume, wait),
|
||||||
Command::RedatePhotos {
|
Command::RedatePhotos {
|
||||||
dry_run,
|
dry_run,
|
||||||
limit,
|
limit,
|
||||||
@@ -1021,6 +1120,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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1036,10 +1136,7 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
|
|||||||
CtlResponse::Devices { items } if items.is_empty() => println!("No devices."),
|
CtlResponse::Devices { items } if items.is_empty() => println!("No devices."),
|
||||||
CtlResponse::Devices { items } => {
|
CtlResponse::Devices { items } => {
|
||||||
for d in items {
|
for d in items {
|
||||||
let sync = d
|
let sync = last_synced(d.last_sync);
|
||||||
.last_sync
|
|
||||||
.map(|t| t.to_string())
|
|
||||||
.unwrap_or_else(|| "never".to_string());
|
|
||||||
let tag = match (d.adopted, d.this_device) {
|
let tag = match (d.adopted, d.this_device) {
|
||||||
(true, _) => " *adopted*",
|
(true, _) => " *adopted*",
|
||||||
(false, true) => " *this machine*",
|
(false, true) => " *this machine*",
|
||||||
@@ -1119,6 +1216,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)? {
|
||||||
@@ -1130,11 +1243,7 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
|||||||
}
|
}
|
||||||
CtlResponse::SyncFolders { items } => {
|
CtlResponse::SyncFolders { items } => {
|
||||||
for f in items {
|
for f in items {
|
||||||
let sync = if f.last_sync == 0 {
|
let sync = last_synced(Some(f.last_sync));
|
||||||
"never".to_string()
|
|
||||||
} else {
|
|
||||||
f.last_sync.to_string()
|
|
||||||
};
|
|
||||||
// A queued switch is reported as the mode the folder is moving
|
// A queued switch is reported as the mode the folder is moving
|
||||||
// to, so `sync list` never reads as if the request was dropped.
|
// to, so `sync list` never reads as if the request was dropped.
|
||||||
let mode = match &f.pending_mode {
|
let mode = match &f.pending_mode {
|
||||||
@@ -1915,6 +2024,15 @@ fn format_epoch(secs: i64) -> String {
|
|||||||
format!("{m:02}-{d:02} {h:02}:{mi:02}")
|
format!("{m:02}-{d:02} {h:02}:{mi:02}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `synced:` column of `sync list` and `devices list`: when the last pass
|
||||||
|
/// finished, or `never`. The daemon reports 0 for a folder that never synced.
|
||||||
|
fn last_synced(secs: Option<i64>) -> String {
|
||||||
|
match secs {
|
||||||
|
Some(secs) if secs > 0 => format_epoch(secs),
|
||||||
|
_ => "never".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_leave(uid: String) -> Result<()> {
|
fn cmd_leave(uid: String) -> Result<()> {
|
||||||
ok_or_bail(control_request(CtlRequest::LeaveShared { uid })?)
|
ok_or_bail(control_request(CtlRequest::LeaveShared { uid })?)
|
||||||
}
|
}
|
||||||
@@ -2292,6 +2410,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 {
|
||||||
@@ -2432,6 +2553,7 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
since: failure.as_ref().map_or(now, |f| f.since),
|
since: failure.as_ref().map_or(now, |f| f.since),
|
||||||
attempts,
|
attempts,
|
||||||
next_attempt: now + delay.as_secs() as i64,
|
next_attempt: now + delay.as_secs() as i64,
|
||||||
|
not_empty: not_empty_cause(&e),
|
||||||
};
|
};
|
||||||
if let Err(e) = pdfs_core::mountfail::record(&state_dir, &record) {
|
if let Err(e) = pdfs_core::mountfail::record(&state_dir, &record) {
|
||||||
tracing::warn!(error = %e, "could not record the mount failure");
|
tracing::warn!(error = %e, "could not record the mount failure");
|
||||||
@@ -2444,6 +2566,23 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The folder in the way, when `error` is a mount refused over a folder that
|
||||||
|
/// already has files in it. The refusal travels inside an `io::Error`, whose
|
||||||
|
/// `source` skips the error it wraps, so each link is unwrapped by hand.
|
||||||
|
fn not_empty_cause(error: &anyhow::Error) -> Option<pdfs_core::mountpoint::NotEmpty> {
|
||||||
|
error.chain().find_map(|cause| {
|
||||||
|
cause
|
||||||
|
.downcast_ref::<pdfs_core::mountpoint::NotEmpty>()
|
||||||
|
.or_else(|| {
|
||||||
|
cause
|
||||||
|
.downcast_ref::<std::io::Error>()?
|
||||||
|
.get_ref()?
|
||||||
|
.downcast_ref::<pdfs_core::mountpoint::NotEmpty>()
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// How long to wait before mount attempt `attempts + 1`, after `attempts`
|
/// How long to wait before mount attempt `attempts + 1`, after `attempts`
|
||||||
/// failures in a row.
|
/// failures in a row.
|
||||||
///
|
///
|
||||||
@@ -2472,7 +2611,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();
|
||||||
@@ -2780,9 +2922,17 @@ fn photo_count(n: usize) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -> Result<()> {
|
fn cmd_import_google_photos(
|
||||||
// The daemon opens the archives itself off the shared filesystem, so it gets
|
archives: Vec<PathBuf>,
|
||||||
// absolute paths — the same arrangement `pdfs upload` uses.
|
dry_run: bool,
|
||||||
|
resume: bool,
|
||||||
|
wait: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let request = if resume {
|
||||||
|
CtlRequest::ResumeImport
|
||||||
|
} else {
|
||||||
|
// The daemon opens the archives itself off the shared filesystem, so it
|
||||||
|
// gets absolute paths — the same arrangement `pdfs upload` uses.
|
||||||
let mut absolute = Vec::with_capacity(archives.len());
|
let mut absolute = Vec::with_capacity(archives.len());
|
||||||
for archive in &archives {
|
for archive in &archives {
|
||||||
let path = std::fs::canonicalize(archive)
|
let path = std::fs::canonicalize(archive)
|
||||||
@@ -2793,11 +2943,13 @@ fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -
|
|||||||
.to_owned(),
|
.to_owned(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
CtlRequest::ImportTakeout {
|
||||||
match control_request(CtlRequest::ImportTakeout {
|
|
||||||
archives: absolute,
|
archives: absolute,
|
||||||
dry_run,
|
dry_run,
|
||||||
})? {
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match control_request(request)? {
|
||||||
CtlResponse::Ok { message } => println!("{message}"),
|
CtlResponse::Ok { message } => println!("{message}"),
|
||||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
other => bail!("unexpected response: {other:?}"),
|
other => bail!("unexpected response: {other:?}"),
|
||||||
@@ -2809,20 +2961,34 @@ fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -
|
|||||||
|
|
||||||
// Poll rather than stream: the control protocol is request/response, and an
|
// Poll rather than stream: the control protocol is request/response, and an
|
||||||
// import runs for hours — a held-open socket would be the only long-lived
|
// import runs for hours — a held-open socket would be the only long-lived
|
||||||
// connection the daemon has.
|
// connection the daemon has. A line is printed when the step changes and
|
||||||
|
// every few percent of it, not on every poll.
|
||||||
|
let mut last_line = String::new();
|
||||||
|
let mut last_printed = std::time::Instant::now();
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
match control_request(CtlRequest::ImportStatus)? {
|
match control_request(CtlRequest::ImportStatus)? {
|
||||||
CtlResponse::ImportStatus {
|
CtlResponse::ImportStatus {
|
||||||
running: true,
|
running: true,
|
||||||
summary: _,
|
progress,
|
||||||
} => continue,
|
..
|
||||||
CtlResponse::ImportStatus { summary, .. } => {
|
} => {
|
||||||
print_import_summary(summary.as_ref());
|
let line = progress.as_ref().map(progress_line).unwrap_or_default();
|
||||||
return finished_run(
|
let step_changed = line.split(':').next() != last_line.split(':').next();
|
||||||
"the import",
|
if !line.is_empty()
|
||||||
summary.map(|s| (s.failed, s.cancelled, s.error)),
|
&& line != last_line
|
||||||
);
|
&& (step_changed || last_printed.elapsed().as_secs() >= 30)
|
||||||
|
{
|
||||||
|
println!("{line}");
|
||||||
|
last_line = line;
|
||||||
|
last_printed = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CtlResponse::ImportStatus {
|
||||||
|
summary, resumable, ..
|
||||||
|
} => {
|
||||||
|
print_import_summary(summary.as_ref(), &resumable);
|
||||||
|
return import_outcome(summary.as_ref());
|
||||||
}
|
}
|
||||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
other => bail!("unexpected response: {other:?}"),
|
other => bail!("unexpected response: {other:?}"),
|
||||||
@@ -2830,6 +2996,47 @@ fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One line saying where a running import is.
|
||||||
|
fn progress_line(progress: &pdfs_core::control::ImportProgress) -> String {
|
||||||
|
use pdfs_core::control::ImportPhase;
|
||||||
|
let step = match progress.phase {
|
||||||
|
ImportPhase::Scanning => "Reading archives",
|
||||||
|
ImportPhase::Checking => "Checking for photos already on Proton",
|
||||||
|
ImportPhase::Uploading => "Uploading",
|
||||||
|
ImportPhase::Albums => "Filing photos into albums",
|
||||||
|
};
|
||||||
|
match (progress.total, &progress.current) {
|
||||||
|
(0, _) => format!("{step}…"),
|
||||||
|
(total, Some(name)) => format!("{step}: {} of {total} ({name})", progress.done),
|
||||||
|
(total, None) => format!("{step}: {} of {total}", progress.done),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an import `--wait` followed ended, as the exit status: an error unless
|
||||||
|
/// it did everything it set out to do, so a script can tell.
|
||||||
|
fn import_outcome(summary: Option<&pdfs_core::control::ImportSummary>) -> Result<()> {
|
||||||
|
let Some(summary) = summary else {
|
||||||
|
bail!("the import ended without a result; the service may have restarted");
|
||||||
|
};
|
||||||
|
if let Some(error) = &summary.error {
|
||||||
|
bail!("the import failed: {error}");
|
||||||
|
}
|
||||||
|
if summary.interrupted {
|
||||||
|
bail!("the import was interrupted; run `pdfs import-google-photos --resume` to continue");
|
||||||
|
}
|
||||||
|
if summary.cancelled {
|
||||||
|
bail!("the import was cancelled");
|
||||||
|
}
|
||||||
|
if summary.failed > 0 || !summary.unreadable_archives.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"the import finished, but {} photo(s) and {} archive(s) failed",
|
||||||
|
summary.failed,
|
||||||
|
summary.unreadable_archives.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_redate_photos(
|
fn cmd_redate_photos(
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
@@ -2964,11 +3171,19 @@ fn print_redate_summary(summary: Option<&pdfs_core::control::RedateSummary>) {
|
|||||||
|
|
||||||
fn cmd_import_status() -> Result<()> {
|
fn cmd_import_status() -> Result<()> {
|
||||||
match control_request(CtlRequest::ImportStatus)? {
|
match control_request(CtlRequest::ImportStatus)? {
|
||||||
CtlResponse::ImportStatus { running, summary } => {
|
CtlResponse::ImportStatus {
|
||||||
|
running,
|
||||||
|
summary,
|
||||||
|
progress,
|
||||||
|
resumable,
|
||||||
|
} => {
|
||||||
if running {
|
if running {
|
||||||
println!("Import running. `pdfs transfers` shows its progress.");
|
match progress.as_ref() {
|
||||||
|
Some(progress) => println!("Import running. {}", progress_line(progress)),
|
||||||
|
None => println!("Import running."),
|
||||||
}
|
}
|
||||||
print_import_summary(summary.as_ref());
|
}
|
||||||
|
print_import_summary(summary.as_ref(), &resumable);
|
||||||
}
|
}
|
||||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
other => bail!("unexpected response: {other:?}"),
|
other => bail!("unexpected response: {other:?}"),
|
||||||
@@ -2985,18 +3200,28 @@ fn cmd_cancel_import() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>) {
|
fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>, resumable: &[String]) {
|
||||||
let Some(summary) = summary else {
|
let Some(summary) = summary else {
|
||||||
println!("No import has run yet.");
|
println!("No import has run yet.");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if summary.dry_run {
|
||||||
|
println!("Scan only; nothing was uploaded.");
|
||||||
|
}
|
||||||
if let Some(error) = &summary.error {
|
if let Some(error) = &summary.error {
|
||||||
println!("Failed : {error}");
|
println!("Stopped by : {error}");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
println!("Photos in export : {}", summary.found);
|
println!("Photos in export : {}", summary.found);
|
||||||
|
if summary.dry_run {
|
||||||
|
println!("Would upload : {}", summary.to_upload);
|
||||||
|
println!("Bytes to upload : {}", summary.to_upload_bytes);
|
||||||
|
} else {
|
||||||
println!("Uploaded : {}", summary.uploaded);
|
println!("Uploaded : {}", summary.uploaded);
|
||||||
|
}
|
||||||
println!("Already on Proton: {}", summary.duplicates);
|
println!("Already on Proton: {}", summary.duplicates);
|
||||||
|
if summary.live_photos > 0 {
|
||||||
|
println!("Live photos : {}", summary.live_photos);
|
||||||
|
}
|
||||||
if summary.skipped_trashed > 0 {
|
if summary.skipped_trashed > 0 {
|
||||||
println!("Skipped (trash) : {}", summary.skipped_trashed);
|
println!("Skipped (trash) : {}", summary.skipped_trashed);
|
||||||
}
|
}
|
||||||
@@ -3009,12 +3234,20 @@ fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>) {
|
|||||||
if summary.failed > 0 {
|
if summary.failed > 0 {
|
||||||
println!("Failed : {}", summary.failed);
|
println!("Failed : {}", summary.failed);
|
||||||
}
|
}
|
||||||
|
for archive in &summary.unreadable_archives {
|
||||||
|
println!("Unreadable : {archive}");
|
||||||
|
}
|
||||||
if summary.bytes > 0 {
|
if summary.bytes > 0 {
|
||||||
println!("Uploaded bytes : {}", summary.bytes);
|
println!("Uploaded bytes : {}", summary.bytes);
|
||||||
}
|
}
|
||||||
if summary.cancelled {
|
if summary.interrupted {
|
||||||
|
println!("The service stopped before the import finished.");
|
||||||
|
} else if summary.cancelled {
|
||||||
println!("Import was cancelled before it finished.");
|
println!("Import was cancelled before it finished.");
|
||||||
}
|
}
|
||||||
|
if !resumable.is_empty() {
|
||||||
|
println!("Run `pdfs import-google-photos --resume` to continue it.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> {
|
fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> {
|
||||||
@@ -3738,26 +3971,6 @@ fn executable_on_path(name: &str) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mounted_fs_type(path: &Path) -> Option<String> {
|
|
||||||
let wanted = path.canonicalize().ok()?;
|
|
||||||
let mounts = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
|
|
||||||
mounts
|
|
||||||
.lines()
|
|
||||||
.filter_map(|line| {
|
|
||||||
let fields: Vec<_> = line.split_whitespace().collect();
|
|
||||||
let separator = fields.iter().position(|field| *field == "-")?;
|
|
||||||
let encoded = *fields.get(4)?;
|
|
||||||
let decoded = encoded.replace("\\040", " ").replace("\\011", "\t");
|
|
||||||
let mountpoint = PathBuf::from(decoded);
|
|
||||||
if mountpoint.canonicalize().ok()? == wanted {
|
|
||||||
Some(fields.get(separator + 1)?.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.next()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create, sync, and remove a new file without ever replacing an existing one.
|
/// Create, sync, and remove a new file without ever replacing an existing one.
|
||||||
fn writable_probe(dir: &Path) -> std::io::Result<()> {
|
fn writable_probe(dir: &Path) -> std::io::Result<()> {
|
||||||
let name = format!(".pdfs-diagnose-probe-{}", std::process::id());
|
let name = format!(".pdfs-diagnose-probe-{}", std::process::id());
|
||||||
@@ -3775,6 +3988,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.
|
||||||
///
|
///
|
||||||
@@ -3920,7 +4142,7 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
outdated_daemon_note(&version),
|
outdated_daemon_note(&version),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let live_type = mounted_fs_type(Path::new(&mountpoint));
|
let live_type = pdfs_core::mountpoint::mounted_fs_type(Path::new(&mountpoint));
|
||||||
let is_fuse = live_type
|
let is_fuse = live_type
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|kind| kind.starts_with("fuse"));
|
.is_some_and(|kind| kind.starts_with("fuse"));
|
||||||
@@ -4268,6 +4490,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(),
|
||||||
@@ -4329,6 +4552,23 @@ mod login_wait_tests {
|
|||||||
assert_eq!(secs, [5, 10, 20, 40, 80, 120, 120, 120]);
|
assert_eq!(secs, [5, 10, 20, 40, 80, 120, 120, 120]);
|
||||||
assert_eq!(mount_retry_delay(u32::MAX).as_secs(), 120);
|
assert_eq!(mount_retry_delay(u32::MAX).as_secs(), 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A refusal over a folder with files in it reaches the failure record
|
||||||
|
/// through the `io::Error` and the context around it (`docs/BUGS.md` B277).
|
||||||
|
#[test]
|
||||||
|
fn a_mount_refused_over_files_is_recorded_as_such() {
|
||||||
|
let found = pdfs_core::mountpoint::NotEmpty {
|
||||||
|
path: PathBuf::from("/home/you/ProtonDrive"),
|
||||||
|
entries: vec!["Work".into()],
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
let error =
|
||||||
|
anyhow::Error::new(std::io::Error::other(found.clone())).context("mount failed");
|
||||||
|
assert_eq!(not_empty_cause(&error), Some(found));
|
||||||
|
let other =
|
||||||
|
anyhow::Error::new(std::io::Error::other("missing key")).context("mount failed");
|
||||||
|
assert_eq!(not_empty_cause(&other), None);
|
||||||
|
}
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4366,6 +4606,51 @@ mod login_wait_tests {
|
|||||||
assert!(finished_run("the import", None).is_err());
|
assert!(finished_run("the import", None).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_waited_for_import_that_did_not_finish_exits_with_an_error() {
|
||||||
|
use pdfs_core::control::ImportSummary;
|
||||||
|
let done = ImportSummary {
|
||||||
|
uploaded: 4,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(import_outcome(Some(&done)).is_ok());
|
||||||
|
assert!(import_outcome(None).is_err());
|
||||||
|
for broken in [
|
||||||
|
ImportSummary {
|
||||||
|
interrupted: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ImportSummary {
|
||||||
|
unreadable_archives: vec!["/exports/takeout-002.zip".into()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ImportSummary {
|
||||||
|
error: Some("check for existing photos: offline".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(import_outcome(Some(&broken)).is_err(), "{broken:?}");
|
||||||
|
}
|
||||||
|
let interrupted = import_outcome(Some(&ImportSummary {
|
||||||
|
interrupted: true,
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
assert!(interrupted.unwrap_err().to_string().contains("--resume"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resume_needs_no_archives_and_excludes_them() {
|
||||||
|
let cli = Cli::try_parse_from(["pdfs", "import-google-photos", "--resume"]).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
cli.command,
|
||||||
|
Command::ImportGooglePhotos { resume: true, .. }
|
||||||
|
));
|
||||||
|
assert!(Cli::try_parse_from(["pdfs", "import-google-photos"]).is_err());
|
||||||
|
assert!(
|
||||||
|
Cli::try_parse_from(["pdfs", "import-google-photos", "--resume", "a.zip"]).is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn waiting_for_a_session_stops_hammering_the_keyring() {
|
fn waiting_for_a_session_stops_hammering_the_keyring() {
|
||||||
let mut checks_left = 20;
|
let mut checks_left = 20;
|
||||||
@@ -4437,6 +4722,14 @@ mod login_wait_tests {
|
|||||||
assert_eq!(format_epoch_utc(-86_400), "1969-12-31");
|
assert_eq!(format_epoch_utc(-86_400), "1969-12-31");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_synced_shows_a_time_not_epoch_seconds() {
|
||||||
|
// B278: issue #46 showed `synced: 1793570850`.
|
||||||
|
assert_eq!(last_synced(Some(1_793_570_850)), "11-01 22:07");
|
||||||
|
assert_eq!(last_synced(Some(0)), "never");
|
||||||
|
assert_eq!(last_synced(None), "never");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_forged_looking_invitation_is_flagged_not_stated() {
|
fn a_forged_looking_invitation_is_flagged_not_stated() {
|
||||||
let mut entry = pdfs_core::control::DirEntry {
|
let mut entry = pdfs_core::control::DirEntry {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+253
-33
@@ -123,13 +123,84 @@ fn client_config() -> ProtonClientConfiguration {
|
|||||||
ProtonClientConfiguration::new(app_version).with_user_agent(user_agent)
|
ProtonClientConfiguration::new(app_version).with_user_agent(user_agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn keyring_entry() -> Result<Entry> {
|
/// Run `op` on the session's keyring entry.
|
||||||
// Installed lazily rather than once at startup so a session bus that was not
|
fn with_keyring<T>(op: impl Fn(&Entry) -> keyring_core::Result<T>) -> Result<T> {
|
||||||
// up yet on the first attempt is retried on the next one.
|
with_keyring_entry(KEYRING_SERVICE, KEYRING_USER, op)
|
||||||
if keyring_core::get_default_store().is_none() {
|
}
|
||||||
keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?);
|
|
||||||
|
/// Run `op` on the entry `service`/`user`, reconnecting once if the Secret
|
||||||
|
/// Service fails the call.
|
||||||
|
///
|
||||||
|
/// The store holds one D-Bus connection and one Secret Service session for the
|
||||||
|
/// whole process. The session lives in the provider's memory: once the provider
|
||||||
|
/// restarts (oo7's unit restarts it on failure, and both oo7 and gnome-keyring
|
||||||
|
/// can be `--replace`d), every read and write on the old session fails with
|
||||||
|
/// `NoSession`, for as long as this process runs. A daemon then keeps refreshed
|
||||||
|
/// tokens only in memory, and a cold boot loses them (`docs/BUGS.md` B285). A
|
||||||
|
/// platform failure therefore drops the store, and the call is made once more
|
||||||
|
/// on a new one. A locked keyring, a dismissed prompt or a missing entry is
|
||||||
|
/// not the connection's fault and comes back as it is.
|
||||||
|
fn with_keyring_entry<T>(
|
||||||
|
service: &str,
|
||||||
|
user: &str,
|
||||||
|
op: impl Fn(&Entry) -> keyring_core::Result<T>,
|
||||||
|
) -> Result<T> {
|
||||||
|
let store = keyring_store()?;
|
||||||
|
match op(&Entry::new(service, user)?) {
|
||||||
|
Err(keyring_core::Error::PlatformFailure(e)) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
"the Secret Service failed this process's connection; reconnecting"
|
||||||
|
);
|
||||||
|
drop_keyring_store(&store);
|
||||||
|
keyring_store()?;
|
||||||
|
Ok(op(&Entry::new(service, user)?)?)
|
||||||
|
}
|
||||||
|
result => Ok(result?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Held while the default store is looked at and replaced, so that threads
|
||||||
|
/// failing at the same time drop the broken store once and share its
|
||||||
|
/// replacement.
|
||||||
|
static KEYRING_STORE: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
||||||
|
|
||||||
|
/// The process's Secret Service store, connected now if there is none.
|
||||||
|
///
|
||||||
|
/// Connected lazily rather than once at startup so a session bus that was not
|
||||||
|
/// up yet on the first attempt is tried again on the next one.
|
||||||
|
fn keyring_store() -> Result<Arc<keyring_core::CredentialStore>> {
|
||||||
|
let _swap = KEYRING_STORE.lock();
|
||||||
|
if let Some(store) = keyring_core::get_default_store() {
|
||||||
|
return Ok(store);
|
||||||
|
}
|
||||||
|
let store = connect_keyring_store()?;
|
||||||
|
keyring_core::set_default_store(Arc::clone(&store));
|
||||||
|
Ok(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget `failed`, unless another thread has already replaced it.
|
||||||
|
fn drop_keyring_store(failed: &Arc<keyring_core::CredentialStore>) {
|
||||||
|
let _swap = KEYRING_STORE.lock();
|
||||||
|
if keyring_core::get_default_store().is_some_and(|store| Arc::ptr_eq(&store, failed)) {
|
||||||
|
keyring_core::unset_default_store();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn connect_keyring_store() -> Result<Arc<keyring_core::CredentialStore>> {
|
||||||
|
Ok(dbus_secret_service_keyring_store::Store::new()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tests' in-memory store when they set one, so a reconnect finds the
|
||||||
|
/// same entries; the real Secret Service otherwise.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn connect_keyring_store() -> Result<Arc<keyring_core::CredentialStore>> {
|
||||||
|
tests::KEYRING_CONNECTS.fetch_add(1, Ordering::SeqCst);
|
||||||
|
match tests::MOCK_STORE.lock().clone() {
|
||||||
|
Some(store) => Ok(store),
|
||||||
|
None => Ok(dbus_secret_service_keyring_store::Store::new()?),
|
||||||
}
|
}
|
||||||
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the user answered when asked for the mailbox password.
|
/// What the user answered when asked for the mailbox password.
|
||||||
@@ -421,17 +492,15 @@ fn classify_verification_gate(e: ProtonError) -> Error {
|
|||||||
|
|
||||||
fn write_stored(stored: &StoredSession) -> Result<()> {
|
fn write_stored(stored: &StoredSession) -> Result<()> {
|
||||||
let json = Zeroizing::new(serde_json::to_string(stored)?);
|
let json = Zeroizing::new(serde_json::to_string(stored)?);
|
||||||
keyring_entry()?.set_password(&json)?;
|
with_keyring(|entry| entry.set_password(&json))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the persisted session blob, or `Error::NotLoggedIn` if absent.
|
/// Load the persisted session blob, or `Error::NotLoggedIn` if absent.
|
||||||
pub fn load() -> Result<StoredSession> {
|
pub fn load() -> Result<StoredSession> {
|
||||||
let entry = keyring_entry()?;
|
match with_keyring(|entry| entry.get_password()) {
|
||||||
match entry.get_password() {
|
|
||||||
Ok(json) => Ok(serde_json::from_str(&json)?),
|
Ok(json) => Ok(serde_json::from_str(&json)?),
|
||||||
Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
|
Err(Error::Keyring(keyring_core::Error::NoEntry)) => Err(Error::NotLoggedIn),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,9 +658,9 @@ pub fn logout() -> Result<bool> {
|
|||||||
tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed");
|
tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed");
|
||||||
}
|
}
|
||||||
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
||||||
match keyring_entry()?.delete_credential() {
|
match with_keyring(|entry| entry.delete_credential()) {
|
||||||
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(ended),
|
Ok(()) | Err(Error::Keyring(keyring_core::Error::NoEntry)) => Ok(ended),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,21 +1065,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,
|
||||||
@@ -1213,12 +1276,138 @@ mod tests {
|
|||||||
/// The keyring is one store per process, so the tests that use it,
|
/// The keyring is one store per process, so the tests that use it,
|
||||||
/// through `keyring-core`'s in-memory store, take turns.
|
/// through `keyring-core`'s in-memory store, take turns.
|
||||||
fn mock_keyring() -> parking_lot::MutexGuard<'static, ()> {
|
fn mock_keyring() -> parking_lot::MutexGuard<'static, ()> {
|
||||||
static KEYRING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
|
||||||
let turn = KEYRING.lock();
|
let turn = KEYRING.lock();
|
||||||
keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap());
|
let store: Arc<keyring_core::CredentialStore> = keyring_core::mock::Store::new().unwrap();
|
||||||
|
*MOCK_STORE.lock() = Some(Arc::clone(&store));
|
||||||
|
keyring_core::set_default_store(store);
|
||||||
turn
|
turn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static KEYRING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
||||||
|
|
||||||
|
/// The in-memory store a reconnect hands out, so it finds the same
|
||||||
|
/// entries; `None` connects to the real Secret Service.
|
||||||
|
pub(super) static MOCK_STORE: parking_lot::Mutex<Option<Arc<keyring_core::CredentialStore>>> =
|
||||||
|
parking_lot::Mutex::new(None);
|
||||||
|
|
||||||
|
/// How many times a store was connected.
|
||||||
|
pub(super) static KEYRING_CONNECTS: std::sync::atomic::AtomicUsize =
|
||||||
|
std::sync::atomic::AtomicUsize::new(0);
|
||||||
|
|
||||||
|
/// The session's entry, to make its next call fail.
|
||||||
|
fn keyring_entry() -> Entry {
|
||||||
|
Entry::new(KEYRING_SERVICE, KEYRING_USER).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the session entry's next call fail with `error`.
|
||||||
|
fn fail_next(error: keyring_core::Error) {
|
||||||
|
keyring_entry()
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<keyring_core::mock::Cred>()
|
||||||
|
.unwrap()
|
||||||
|
.set_error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_session() -> keyring_core::Error {
|
||||||
|
keyring_core::Error::PlatformFailure(
|
||||||
|
std::io::Error::other("The session does not exist (NoSession)").into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B285: a restarted Secret Service no longer knows the process's
|
||||||
|
/// session, and every call on it failed until the process restarted.
|
||||||
|
#[test]
|
||||||
|
fn a_dropped_secret_service_session_is_reconnected_and_retried() {
|
||||||
|
let _turn = mock_keyring();
|
||||||
|
write_stored(&stored_with("r")).unwrap();
|
||||||
|
let connects = KEYRING_CONNECTS.load(Ordering::SeqCst);
|
||||||
|
|
||||||
|
fail_next(no_session());
|
||||||
|
assert_eq!(load().unwrap().refresh_token, "r");
|
||||||
|
assert_eq!(KEYRING_CONNECTS.load(Ordering::SeqCst), connects + 1);
|
||||||
|
|
||||||
|
fail_next(no_session());
|
||||||
|
write_stored(&stored_with("r2")).unwrap();
|
||||||
|
assert_eq!(load().unwrap().refresh_token, "r2");
|
||||||
|
assert_eq!(KEYRING_CONNECTS.load(Ordering::SeqCst), connects + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rotation_lands_at_once_after_the_keyring_restarts() {
|
||||||
|
let _turn = mock_keyring();
|
||||||
|
write_stored(&stored_with("r")).unwrap();
|
||||||
|
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||||
|
|
||||||
|
fail_next(no_session());
|
||||||
|
sink.rotated(tokens("a2", "r2")).unwrap();
|
||||||
|
assert_eq!(load().unwrap().refresh_token, "r2");
|
||||||
|
assert!(sink.state.lock().unsaved.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A locked keyring or a dismissed prompt is no fault of the connection.
|
||||||
|
#[test]
|
||||||
|
fn a_locked_keyring_is_not_reconnected() {
|
||||||
|
let _turn = mock_keyring();
|
||||||
|
write_stored(&stored_with("r")).unwrap();
|
||||||
|
let connects = KEYRING_CONNECTS.load(Ordering::SeqCst);
|
||||||
|
|
||||||
|
fail_next(keyring_core::Error::NoStorageAccess(
|
||||||
|
std::io::Error::other("locked").into(),
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
load(),
|
||||||
|
Err(Error::Keyring(keyring_core::Error::NoStorageAccess(_)))
|
||||||
|
));
|
||||||
|
assert_eq!(KEYRING_CONNECTS.load(Ordering::SeqCst), connects);
|
||||||
|
assert_eq!(load().unwrap().refresh_token, "r");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The real Secret Service, restarted between two calls on one store.
|
||||||
|
///
|
||||||
|
/// Run inside a throwaway session bus, never against the desktop's
|
||||||
|
/// keyring. With gnome-keyring:
|
||||||
|
///
|
||||||
|
/// ```sh
|
||||||
|
/// echo -n pw | gnome-keyring-daemon --unlock --components=secrets
|
||||||
|
/// PDFS_SECRET_SERVICE_RESTART_TEST=1 \
|
||||||
|
/// PDFS_RESTART_PROVIDER='echo -n pw | gnome-keyring-daemon --replace --unlock --components=secrets' \
|
||||||
|
/// cargo test -p pdfs-core real_secret_service -- --ignored
|
||||||
|
/// ```
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs a throwaway Secret Service; see the doc comment"]
|
||||||
|
fn real_secret_service_survives_a_provider_restart() {
|
||||||
|
if std::env::var_os("PDFS_SECRET_SERVICE_RESTART_TEST").is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let restart = std::env::var("PDFS_RESTART_PROVIDER").expect("PDFS_RESTART_PROVIDER");
|
||||||
|
let _turn = KEYRING.lock();
|
||||||
|
*MOCK_STORE.lock() = None;
|
||||||
|
keyring_core::unset_default_store();
|
||||||
|
let (service, user) = ("pdfs-test", "restart");
|
||||||
|
|
||||||
|
with_keyring_entry(service, user, |e| e.set_password("before")).unwrap();
|
||||||
|
let status = std::process::Command::new("sh")
|
||||||
|
.args(["-c", &restart])
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success(), "restarting the provider failed");
|
||||||
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
|
||||||
|
let connects = KEYRING_CONNECTS.load(Ordering::SeqCst);
|
||||||
|
let read = with_keyring_entry(service, user, |e| e.get_password()).unwrap();
|
||||||
|
assert_eq!(read, "before");
|
||||||
|
assert_eq!(
|
||||||
|
KEYRING_CONNECTS.load(Ordering::SeqCst),
|
||||||
|
connects + 1,
|
||||||
|
"the provider kept the old session, so nothing was tested"
|
||||||
|
);
|
||||||
|
with_keyring_entry(service, user, |e| e.set_password("after")).unwrap();
|
||||||
|
let read = with_keyring_entry(service, user, |e| e.get_password()).unwrap();
|
||||||
|
assert_eq!(read, "after");
|
||||||
|
with_keyring_entry(service, user, |e| e.delete_credential()).unwrap();
|
||||||
|
keyring_core::unset_default_store();
|
||||||
|
}
|
||||||
|
|
||||||
fn stored_with(refresh: &str) -> StoredSession {
|
fn stored_with(refresh: &str) -> StoredSession {
|
||||||
serde_json::from_str(&blob("").replace(r#""r""#, &format!("{refresh:?}"))).unwrap()
|
serde_json::from_str(&blob("").replace(r#""r""#, &format!("{refresh:?}"))).unwrap()
|
||||||
}
|
}
|
||||||
@@ -1273,12 +1462,7 @@ mod tests {
|
|||||||
write_stored(&stored_with("r")).unwrap();
|
write_stored(&stored_with("r")).unwrap();
|
||||||
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||||
|
|
||||||
let entry = keyring_entry().unwrap();
|
fail_next(keyring_core::Error::NoStorageAccess(
|
||||||
let cred = entry
|
|
||||||
.as_any()
|
|
||||||
.downcast_ref::<keyring_core::mock::Cred>()
|
|
||||||
.unwrap();
|
|
||||||
cred.set_error(keyring_core::Error::NoStorageAccess(
|
|
||||||
std::io::Error::other("locked").into(),
|
std::io::Error::other("locked").into(),
|
||||||
));
|
));
|
||||||
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
||||||
@@ -1306,6 +1490,42 @@ 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()));
|
||||||
|
fail_next(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 {
|
||||||
|
|||||||
@@ -621,10 +621,20 @@ impl ContentCache {
|
|||||||
for entry in rd.flatten() {
|
for entry in rd.flatten() {
|
||||||
let name = entry.file_name();
|
let name = entry.file_name();
|
||||||
let Some(name) = name.to_str() else { continue };
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
// A `.meta` tags the blob named like it without the suffix. One
|
||||||
|
// whose blob is gone is what an eviction leaves between its two
|
||||||
|
// removals, by a crash or by a store of the same uid landing in
|
||||||
|
// between, and nothing else would ever remove it.
|
||||||
|
if let Some(blob) = name.strip_suffix(".meta") {
|
||||||
|
if !dir.join(blob).exists() {
|
||||||
|
let _ = std::fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// `.geom` is a block-geometry sidecar (see `GeometryMeta`), tens
|
// `.geom` is a block-geometry sidecar (see `GeometryMeta`), tens
|
||||||
// of bytes and not a cached block; like `.meta` it is neither
|
// of bytes and not a cached block; like `.meta` it is neither
|
||||||
// budgeted nor evictable on its own.
|
// budgeted nor evictable on its own.
|
||||||
if name.ends_with(".meta") || name.ends_with(".geom") {
|
if name.ends_with(".geom") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// A `.tmp` is a staging file from a store that never completed
|
// A `.tmp` is a staging file from a store that never completed
|
||||||
@@ -2250,6 +2260,28 @@ mod tests {
|
|||||||
assert!(total <= 8, "budget enforced after reconcile, total={total}");
|
assert!(total <= 8, "budget enforced after reconcile, total={total}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An eviction removes the blob, then its tag. A crash in between, or a
|
||||||
|
/// store of the same uid landing in between, left the tag on its own, and
|
||||||
|
/// nothing removed it.
|
||||||
|
#[test]
|
||||||
|
fn a_tag_without_its_blob_is_swept_at_open() {
|
||||||
|
let dir = TempDir::new();
|
||||||
|
let content = dir.path().join("content");
|
||||||
|
let pins = dir.path().join("pins.json");
|
||||||
|
let db = Arc::new(Db::open_in_memory().unwrap());
|
||||||
|
{
|
||||||
|
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
|
||||||
|
c.store(&uid("kept"), 1, 4, b"kept").unwrap();
|
||||||
|
c.store(&uid("gone"), 1, 4, b"gone").unwrap();
|
||||||
|
std::fs::remove_file(c.blob_path(&uid("gone"))).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let c = ContentCache::open(content, pins, 0, db).unwrap();
|
||||||
|
assert!(!c.meta_path(&uid("gone")).exists());
|
||||||
|
assert!(c.meta_path(&uid("kept")).exists());
|
||||||
|
assert_eq!(c.read_range(&uid("kept"), 1, 4, 0, 4).unwrap(), b"kept");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stale_metadata_is_a_miss() {
|
fn stale_metadata_is_a_miss() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -541,6 +562,12 @@ impl AppDirs {
|
|||||||
self.state_dir().join("control.sock")
|
self.state_dir().join("control.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Google Photos import's last run: its archives and how it ended, so
|
||||||
|
/// an import cut short by a restart can be resumed.
|
||||||
|
pub fn takeout_import_path(&self) -> PathBuf {
|
||||||
|
self.state_dir().join("takeout-import.json")
|
||||||
|
}
|
||||||
|
|
||||||
/// Unix domain socket the tray uses to ensure single instance.
|
/// Unix domain socket the tray uses to ensure single instance.
|
||||||
pub fn tray_socket(&self) -> PathBuf {
|
pub fn tray_socket(&self) -> PathBuf {
|
||||||
self.state_dir().join("tray.sock")
|
self.state_dir().join("tray.sock")
|
||||||
@@ -761,6 +788,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 +824,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 +868,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]
|
||||||
|
|||||||
@@ -279,6 +279,11 @@ pub enum Request {
|
|||||||
/// and files what it has uploaded into its albums, then reports
|
/// and files what it has uploaded into its albums, then reports
|
||||||
/// `cancelled`. Replies with [`Response::Ok`].
|
/// `cancelled`. Replies with [`Response::Ok`].
|
||||||
CancelImport,
|
CancelImport,
|
||||||
|
/// Start the last import again with the same archives, after it was
|
||||||
|
/// interrupted, cancelled or failed. Photos it already uploaded are found
|
||||||
|
/// on the account and skipped. Replies like [`Request::ImportTakeout`];
|
||||||
|
/// [`ErrorKind::NotFound`] when there is nothing to resume.
|
||||||
|
ResumeImport,
|
||||||
/// Upload the photo at `source_path` under the given name and media type.
|
/// Upload the photo at `source_path` under the given name and media type.
|
||||||
///
|
///
|
||||||
/// A path, not the bytes: this protocol is line-delimited JSON, and
|
/// A path, not the bytes: this protocol is line-delimited JSON, and
|
||||||
@@ -469,6 +474,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.
|
||||||
@@ -761,6 +773,9 @@ pub enum Topic {
|
|||||||
Conflicts,
|
Conflicts,
|
||||||
/// The activity log gained entries.
|
/// The activity log gained entries.
|
||||||
Activity,
|
Activity,
|
||||||
|
/// File thumbnails were made or fetched. A front-end asks again for the
|
||||||
|
/// tiles it still shows blank; the ones it already painted stay as they are.
|
||||||
|
Thumbnails,
|
||||||
/// A topic from a newer daemon, which this front-end cannot act on.
|
/// A topic from a newer daemon, which this front-end cannot act on.
|
||||||
#[serde(other)]
|
#[serde(other)]
|
||||||
Unknown,
|
Unknown,
|
||||||
@@ -770,7 +785,7 @@ impl Topic {
|
|||||||
/// Every topic a front-end can act on. A new subscription is told all of
|
/// Every topic a front-end can act on. A new subscription is told all of
|
||||||
/// them have changed, so it catches up on whatever happened while it was
|
/// them have changed, so it catches up on whatever happened while it was
|
||||||
/// not connected.
|
/// not connected.
|
||||||
pub const ALL: [Topic; 9] = [
|
pub const ALL: [Topic; 10] = [
|
||||||
Topic::Files,
|
Topic::Files,
|
||||||
Topic::Photos,
|
Topic::Photos,
|
||||||
Topic::Trash,
|
Topic::Trash,
|
||||||
@@ -780,6 +795,7 @@ impl Topic {
|
|||||||
Topic::Queue,
|
Topic::Queue,
|
||||||
Topic::Conflicts,
|
Topic::Conflicts,
|
||||||
Topic::Activity,
|
Topic::Activity,
|
||||||
|
Topic::Thumbnails,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -985,14 +1001,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 +1034,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)]
|
||||||
@@ -1671,10 +1694,75 @@ pub struct ImportSummary {
|
|||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
/// True when the run stopped early because it was cancelled.
|
/// True when the run stopped early because it was cancelled.
|
||||||
pub cancelled: bool,
|
pub cancelled: bool,
|
||||||
/// Why the run as a whole failed, when it did, such as an archive that
|
/// Why the run as a whole stopped early, when it did, such as the
|
||||||
/// is not a zip. The counts are then empty.
|
/// account being unreachable. The counts say how far it got.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
/// What kind of failure [`error`](Self::error) is, so a front-end can word
|
||||||
|
/// it in the user's language. The text itself is the daemon's English.
|
||||||
|
#[serde(default)]
|
||||||
|
pub error_kind: Option<ErrorKind>,
|
||||||
|
/// True for a scan that uploads nothing ([`Request::ImportTakeout`] with
|
||||||
|
/// `dry_run`). [`to_upload`](Self::to_upload) then says what an import
|
||||||
|
/// would upload.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dry_run: bool,
|
||||||
|
/// Photos not yet on the account: what a dry run would upload, and what a
|
||||||
|
/// real run set out to upload once the duplicates were known.
|
||||||
|
#[serde(default)]
|
||||||
|
pub to_upload: usize,
|
||||||
|
/// Bytes of [`to_upload`](Self::to_upload).
|
||||||
|
#[serde(default)]
|
||||||
|
pub to_upload_bytes: u64,
|
||||||
|
/// Live and motion photos whose video was filed with its still.
|
||||||
|
#[serde(default)]
|
||||||
|
pub live_photos: usize,
|
||||||
|
/// True when the service stopped while the import ran (a shutdown or a
|
||||||
|
/// restart). [`Request::ResumeImport`] picks it up again.
|
||||||
|
#[serde(default)]
|
||||||
|
pub interrupted: bool,
|
||||||
|
/// Archives that could not be read and were left out, by path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub unreadable_archives: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImportSummary {
|
||||||
|
/// Whether the run did all it set out to do.
|
||||||
|
pub fn complete(&self) -> bool {
|
||||||
|
self.error.is_none()
|
||||||
|
&& !self.cancelled
|
||||||
|
&& !self.interrupted
|
||||||
|
&& self.failed == 0
|
||||||
|
&& self.unreadable_archives.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which step a running Takeout import is on.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ImportPhase {
|
||||||
|
/// Reading the archives' directories and metadata.
|
||||||
|
#[default]
|
||||||
|
Scanning,
|
||||||
|
/// Asking the account which photos it already has.
|
||||||
|
Checking,
|
||||||
|
/// Uploading the photos that are new.
|
||||||
|
Uploading,
|
||||||
|
/// Creating albums and filing photos into them.
|
||||||
|
Albums,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a running import is ([`Response::ImportStatus`]).
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ImportProgress {
|
||||||
|
pub phase: ImportPhase,
|
||||||
|
/// Steps done in this phase.
|
||||||
|
pub done: u64,
|
||||||
|
/// Steps in this phase; `0` while not yet known.
|
||||||
|
pub total: u64,
|
||||||
|
/// The photo being uploaded, during [`ImportPhase::Uploading`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub current: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One album in a [`Response::Albums`] listing.
|
/// One album in a [`Response::Albums`] listing.
|
||||||
@@ -1778,6 +1866,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 +1930,49 @@ 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 `name` is a song or other recording in a format that can carry
|
||||||
|
/// its own cover picture: ID3 (MP3, AAC, WAV, AIFF), FLAC and Ogg pictures,
|
||||||
|
/// MP4 `covr` (M4A, M4B), or APE tags (WavPack, Monkey's Audio).
|
||||||
|
pub fn is_audio_file_name(name: &str) -> bool {
|
||||||
|
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
AUDIO_COVER_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUDIO_COVER_EXTENSIONS: &[&str] = &[
|
||||||
|
"aac", "aif", "aiff", "ape", "flac", "m4a", "m4b", "mp3", "oga", "ogg", "opus", "wav", "wv",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Whether file listings show a thumbnail for a file named `name`, of `size`
|
||||||
|
/// bytes when known: an image [`is_thumbnail_image_name`] accepts, a video,
|
||||||
|
/// whose thumbnail is a frame the daemon takes with ffmpeg, or a song, whose
|
||||||
|
/// thumbnail is its cover: the picture in its tags, or a cover image beside it.
|
||||||
|
pub fn is_thumbnail_media_name(name: &str, size: Option<u64>) -> bool {
|
||||||
|
is_thumbnail_image_name(name) || is_video_file_name(name, size) || is_audio_file_name(name)
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -2142,8 +2278,17 @@ pub enum Response {
|
|||||||
/// report; `summary` is `None` when no import has run this session.
|
/// report; `summary` is `None` when no import has run this session.
|
||||||
ImportStatus {
|
ImportStatus {
|
||||||
running: bool,
|
running: bool,
|
||||||
|
/// The counts so far while running, the final report after.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
summary: Option<ImportSummary>,
|
summary: Option<ImportSummary>,
|
||||||
|
/// The current step, while running.
|
||||||
|
#[serde(default)]
|
||||||
|
progress: Option<ImportProgress>,
|
||||||
|
/// The archives of the last run, when it can be resumed with
|
||||||
|
/// [`Request::ResumeImport`]: it was a real import that did not
|
||||||
|
/// finish. Survives a restart of the service.
|
||||||
|
#[serde(default)]
|
||||||
|
resumable: Vec<String>,
|
||||||
},
|
},
|
||||||
/// How the photo re-date is doing (reply to [`Request::RedateStatus`]).
|
/// How the photo re-date is doing (reply to [`Request::RedateStatus`]).
|
||||||
/// `running` is false once it has finished, when `summary` is the final
|
/// `running` is false once it has finished, when `summary` is the final
|
||||||
@@ -2391,9 +2536,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)?;
|
||||||
@@ -2608,6 +2761,16 @@ mod tests {
|
|||||||
assert!(end.is_none(), "{end:?}");
|
assert!(end.is_none(), "{end:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_thumbnail_change_travels_as_its_own_topic() {
|
||||||
|
let line = serde_json::to_string(&Event::Changed {
|
||||||
|
topics: vec![Topic::Thumbnails],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(line, r#"{"Changed":{"topics":["thumbnails"]}}"#);
|
||||||
|
assert!(Topic::ALL.contains(&Topic::Thumbnails));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() {
|
fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() {
|
||||||
let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n");
|
let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n");
|
||||||
@@ -2653,6 +2816,52 @@ 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_videos_and_songs() {
|
||||||
|
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("Track 01.MP3", None));
|
||||||
|
assert!(!is_thumbnail_media_name("notes.txt", None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn songs_are_told_by_their_extension() {
|
||||||
|
for name in [
|
||||||
|
"a.mp3", "b.FLAC", "c.ogg", "d.oga", "e.opus", "f.m4a", "g.m4b", "h.aac", "i.wav",
|
||||||
|
"j.aiff", "k.aif", "l.wv", "m.ape",
|
||||||
|
] {
|
||||||
|
assert!(is_audio_file_name(name), "{name}");
|
||||||
|
}
|
||||||
|
for name in ["mp3", "song.mp3.zip", "clip.mp4", "notes.txt", "cover.jpg"] {
|
||||||
|
assert!(!is_audio_file_name(name), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ pub mod keycheck;
|
|||||||
pub mod localindex;
|
pub mod localindex;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
pub mod mountfail;
|
pub mod mountfail;
|
||||||
|
pub mod mountpoint;
|
||||||
pub mod mounts;
|
pub mod mounts;
|
||||||
pub mod opener;
|
pub mod opener;
|
||||||
pub mod places;
|
pub mod places;
|
||||||
@@ -28,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;
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ pub struct MountFailure {
|
|||||||
pub attempts: u32,
|
pub attempts: u32,
|
||||||
/// Unix seconds of the next attempt.
|
/// Unix seconds of the next attempt.
|
||||||
pub next_attempt: i64,
|
pub next_attempt: i64,
|
||||||
|
/// Set when the mount was refused because the folder already has files in
|
||||||
|
/// it, so the app can explain that in the user's language. Absent in a
|
||||||
|
/// file an older daemon wrote.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub not_empty: Option<crate::mountpoint::NotEmpty>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the failure is kept for the state directory `state_dir`.
|
/// Where the failure is kept for the state directory `state_dir`.
|
||||||
@@ -65,6 +70,7 @@ mod tests {
|
|||||||
since: 1_700_000_000,
|
since: 1_700_000_000,
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
next_attempt: 1_700_000_040,
|
next_attempt: 1_700_000_040,
|
||||||
|
not_empty: None,
|
||||||
};
|
};
|
||||||
record(&dir, &failure).unwrap();
|
record(&dir, &failure).unwrap();
|
||||||
assert_eq!(read(&dir), Some(failure));
|
assert_eq!(read(&dir), Some(failure));
|
||||||
@@ -72,4 +78,12 @@ mod tests {
|
|||||||
assert_eq!(read(&dir), None);
|
assert_eq!(read(&dir), None);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failure_an_older_daemon_wrote_still_reads() {
|
||||||
|
let old = r#"{"error":"missing key","since":1,"attempts":2,"next_attempt":3}"#;
|
||||||
|
let failure: MountFailure = serde_json::from_str(old).unwrap();
|
||||||
|
assert_eq!(failure.not_empty, None);
|
||||||
|
assert_eq!(failure.attempts, 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
//! Whether a folder can take the My files mount without hiding anything.
|
||||||
|
//!
|
||||||
|
//! FUSE mounts over a folder that already has files in it without a word. The
|
||||||
|
//! files then sit under the mount, out of sight: not uploaded, and back only
|
||||||
|
//! once the service stops, so they look deleted (`docs/BUGS.md` B277, #67).
|
||||||
|
//! The daemon refuses such a folder, and the app refuses it when it is picked.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The file system type the kernel reports for this client's own mounts.
|
||||||
|
pub const OWN_FS_TYPE: &str = "fuse.protondrive";
|
||||||
|
|
||||||
|
/// How many of a folder's entries an error names.
|
||||||
|
const NAMED_ENTRIES: usize = 3;
|
||||||
|
|
||||||
|
/// A folder that already has something in it.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NotEmpty {
|
||||||
|
/// The folder.
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// The first few names in it, sorted.
|
||||||
|
pub entries: Vec<String>,
|
||||||
|
/// How many entries it has in all.
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for NotEmpty {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{} is not empty (holds ", self.path.display())?;
|
||||||
|
write!(f, "{}", self.entries.join(", "))?;
|
||||||
|
let rest = self.count.saturating_sub(self.entries.len());
|
||||||
|
if rest > 0 {
|
||||||
|
write!(f, " and {rest} more")?;
|
||||||
|
}
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"); My files would hide those files. Move them out, or choose another folder for My files"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for NotEmpty {}
|
||||||
|
|
||||||
|
/// What is in `dir`, if anything. A folder that does not exist yet is empty.
|
||||||
|
/// Hidden entries count: a `.directory` or a `.Trash-1000` hidden under the
|
||||||
|
/// mount is as lost as any other file.
|
||||||
|
pub fn not_empty(dir: &Path) -> std::io::Result<Option<NotEmpty>> {
|
||||||
|
let read = match std::fs::read_dir(dir) {
|
||||||
|
Ok(read) => read,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
let mut names = read
|
||||||
|
.map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
|
||||||
|
.collect::<std::io::Result<Vec<_>>>()?;
|
||||||
|
if names.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
names.sort();
|
||||||
|
let count = names.len();
|
||||||
|
names.truncate(NAMED_ENTRIES);
|
||||||
|
Ok(Some(NotEmpty {
|
||||||
|
path: dir.to_path_buf(),
|
||||||
|
entries: names,
|
||||||
|
count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file system type mounted at `path`, as `/proc/self/mountinfo` names it
|
||||||
|
/// (`ext4`, `fuse.protondrive`, …), or `None` when nothing is mounted there.
|
||||||
|
pub fn mounted_fs_type(path: &Path) -> Option<String> {
|
||||||
|
let wanted = path.canonicalize().ok()?;
|
||||||
|
let mounts = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
|
||||||
|
mounts
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let fields: Vec<_> = line.split_whitespace().collect();
|
||||||
|
let separator = fields.iter().position(|field| *field == "-")?;
|
||||||
|
let encoded = *fields.get(4)?;
|
||||||
|
let decoded = encoded.replace("\\040", " ").replace("\\011", "\t");
|
||||||
|
let mountpoint = PathBuf::from(decoded);
|
||||||
|
if mountpoint.canonicalize().ok()? == wanted {
|
||||||
|
Some(fields.get(separator + 1)?.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("pdfs-mountpoint-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_or_missing_folder_takes_the_mount() {
|
||||||
|
let dir = temp_dir("empty");
|
||||||
|
assert_eq!(not_empty(&dir).unwrap(), None);
|
||||||
|
assert_eq!(not_empty(&dir.join("missing")).unwrap(), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_with_files_is_refused_and_named() {
|
||||||
|
let dir = temp_dir("files");
|
||||||
|
for name in ["report.pdf", "Notes", ".directory", "zeta.txt"] {
|
||||||
|
std::fs::write(dir.join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
let found = not_empty(&dir).unwrap().expect("must refuse");
|
||||||
|
assert_eq!(found.count, 4);
|
||||||
|
assert_eq!(found.entries, [".directory", "Notes", "report.pdf"]);
|
||||||
|
let message = found.to_string();
|
||||||
|
assert!(message.contains("is not empty"), "{message}");
|
||||||
|
assert!(
|
||||||
|
message.contains(".directory, Notes, report.pdf and 1 more"),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hidden_file_alone_is_enough_to_refuse() {
|
||||||
|
let dir = temp_dir("hidden");
|
||||||
|
std::fs::write(dir.join(".Trash-1000"), b"").unwrap();
|
||||||
|
let found = not_empty(&dir).unwrap().expect("must refuse");
|
||||||
|
assert_eq!(found.count, 1);
|
||||||
|
assert!(!found.to_string().contains("more"));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_folder_is_not_a_mount() {
|
||||||
|
let dir = temp_dir("plain");
|
||||||
|
assert_eq!(mounted_fs_type(&dir), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
+485
-67
@@ -25,7 +25,9 @@
|
|||||||
//! The service folder (`Google Photos`) is localized, as are the year folders
|
//! The service folder (`Google Photos`) is localized, as are the year folders
|
||||||
//! (`Photos from 2019`) and the trash folder, so nothing here matches on the
|
//! (`Photos from 2019`) and the trash folder, so nothing here matches on the
|
||||||
//! service name: the first component is dropped when it is the export root, the
|
//! service name: the first component is dropped when it is the export root, the
|
||||||
//! second is the service, and the rest is `<folder>/<file>`. A folder is an
|
//! second is the service, and the rest is `<folder>/<file>`. The Photos service
|
||||||
|
//! is the one whose sidecars carry a `photoTakenTime`; other services in the same
|
||||||
|
//! export (`Google Drive/`, `YouTube/`) are left out. A folder is an
|
||||||
//! **album** when it carries an album metadata sidecar naming it; anything else
|
//! **album** when it carries an album metadata sidecar naming it; anything else
|
||||||
//! is a plain bucket that imports into the timeline only. Trash is matched by
|
//! is a plain bucket that imports into the timeline only. Trash is matched by
|
||||||
//! name against [`TRASH_FOLDERS`] — the one place a localized name is
|
//! name against [`TRASH_FOLDERS`] — the one place a localized name is
|
||||||
@@ -121,8 +123,14 @@ const MEDIA_EXTENSIONS: &[(&str, &str)] = &[
|
|||||||
("m2ts", "video/mp2t"),
|
("m2ts", "video/mp2t"),
|
||||||
("mpg", "video/mpeg"),
|
("mpg", "video/mpeg"),
|
||||||
("mpeg", "video/mpeg"),
|
("mpeg", "video/mpeg"),
|
||||||
|
// The video half of a Pixel motion photo, `PXL_….MP` beside `PXL_….MP.jpg`.
|
||||||
|
("mp", "video/mp4"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Sidecars bigger than this are not read. A real one is a few kilobytes; a
|
||||||
|
/// `.json` this large is some other service's data and not worth the memory.
|
||||||
|
const SIDECAR_MAX_BYTES: u64 = 1024 * 1024;
|
||||||
|
|
||||||
/// The media type for `name`'s extension, or `None` when it is not media this
|
/// The media type for `name`'s extension, or `None` when it is not media this
|
||||||
/// import handles.
|
/// import handles.
|
||||||
pub fn media_type_of(name: &str) -> Option<&'static str> {
|
pub fn media_type_of(name: &str) -> Option<&'static str> {
|
||||||
@@ -147,6 +155,13 @@ pub enum Bucket {
|
|||||||
Archived,
|
Archived,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A position in WGS 84 decimal degrees, from a sidecar's `geoData`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct GeoPoint {
|
||||||
|
pub latitude: f64,
|
||||||
|
pub longitude: f64,
|
||||||
|
}
|
||||||
|
|
||||||
/// One media file found in the export.
|
/// One media file found in the export.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct TakeoutPhoto {
|
pub struct TakeoutPhoto {
|
||||||
@@ -162,14 +177,49 @@ pub struct TakeoutPhoto {
|
|||||||
pub size: u64,
|
pub size: u64,
|
||||||
/// Media type from the extension.
|
/// Media type from the extension.
|
||||||
pub media_type: String,
|
pub media_type: String,
|
||||||
/// Capture time in epoch seconds, from `photoTakenTime` (falling back to
|
/// Capture time in epoch seconds, from the sidecar's `photoTakenTime`,
|
||||||
/// `creationTime`). `None` when the photo has no sidecar — the upload then
|
/// else from a date in the file name. `None` when neither has one; the
|
||||||
/// defaults it to now, as the SDK does.
|
/// importer then tries the file's EXIF, then [`creation_time`](Self::creation_time).
|
||||||
pub capture_time: Option<i64>,
|
pub capture_time: Option<i64>,
|
||||||
|
/// The sidecar's `creationTime`: when the photo reached Google Photos, not
|
||||||
|
/// when it was taken. Only a last resort for the capture time.
|
||||||
|
#[serde(default)]
|
||||||
|
pub creation_time: Option<i64>,
|
||||||
|
/// Where the photo was taken, from the sidecar's `geoData` (or
|
||||||
|
/// `geoDataExif`). Google writes `0, 0` for "unknown", which is dropped.
|
||||||
|
#[serde(default)]
|
||||||
|
pub location: Option<GeoPoint>,
|
||||||
/// The sidecar's `favorited` flag.
|
/// The sidecar's `favorited` flag.
|
||||||
pub favorite: bool,
|
pub favorite: bool,
|
||||||
/// Which bucket the file was found in.
|
/// Which bucket the file was found in.
|
||||||
pub bucket: Bucket,
|
pub bucket: Bucket,
|
||||||
|
/// Pairs the halves of a live or motion photo: a still and a video in the
|
||||||
|
/// same folder carry the same key (`IMG_1.HEIC` + `IMG_1.MOV`,
|
||||||
|
/// `PXL_1.MP.jpg` + `PXL_1.MP`). `None` for media that cannot be either half.
|
||||||
|
#[serde(default)]
|
||||||
|
pub live_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TakeoutPhoto {
|
||||||
|
/// Whether this is the video half of a pair, as opposed to the still.
|
||||||
|
pub fn is_video(&self) -> bool {
|
||||||
|
self.media_type.starts_with("video/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the pair this belongs to is a Pixel motion photo (`.MP`) rather
|
||||||
|
/// than an Apple live photo.
|
||||||
|
pub fn is_motion_photo(&self) -> bool {
|
||||||
|
self.live_key
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|key| key.ends_with(".mp"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An archive the scan had to leave out, and why.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct UnreadableArchive {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub reason: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything one scan found, ready to be turned into an import plan.
|
/// Everything one scan found, ready to be turned into an import plan.
|
||||||
@@ -183,8 +233,16 @@ pub struct TakeoutScan {
|
|||||||
/// Media files skipped because they sit in the trash bucket.
|
/// Media files skipped because they sit in the trash bucket.
|
||||||
pub skipped_trashed: usize,
|
pub skipped_trashed: usize,
|
||||||
/// Entries that were neither media nor a sidecar (`archive_browser.html`,
|
/// Entries that were neither media nor a sidecar (`archive_browser.html`,
|
||||||
/// activity `.csv`s, `.mp` motion-photo parts).
|
/// activity `.csv`s), and media from other Google services in the same
|
||||||
|
/// export (`Google Drive/`, `YouTube/`).
|
||||||
pub skipped_other: usize,
|
pub skipped_other: usize,
|
||||||
|
/// Archives that could not be opened or are not a zip. The rest of the
|
||||||
|
/// export is still imported.
|
||||||
|
#[serde(default)]
|
||||||
|
pub unreadable_archives: Vec<UnreadableArchive>,
|
||||||
|
/// Entries inside an otherwise readable archive that could not be read.
|
||||||
|
#[serde(default)]
|
||||||
|
pub unreadable_entries: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TakeoutScan {
|
impl TakeoutScan {
|
||||||
@@ -195,7 +253,7 @@ impl TakeoutScan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Google's per-photo sidecar. Only the fields the import uses are read; the
|
/// Google's per-photo sidecar. Only the fields the import uses are read; the
|
||||||
/// file also carries geo data, people tags and view counts.
|
/// file also carries people tags, a description and view counts.
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
struct PhotoSidecar {
|
struct PhotoSidecar {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -205,19 +263,69 @@ struct PhotoSidecar {
|
|||||||
#[serde(rename = "creationTime", default)]
|
#[serde(rename = "creationTime", default)]
|
||||||
creation_time: Option<SidecarTime>,
|
creation_time: Option<SidecarTime>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
favorited: bool,
|
favorited: Option<bool>,
|
||||||
|
/// The position as edited in Google Photos, else as the camera wrote it.
|
||||||
|
#[serde(rename = "geoData", default)]
|
||||||
|
geo_data: Option<SidecarGeo>,
|
||||||
|
#[serde(rename = "geoDataExif", default)]
|
||||||
|
geo_data_exif: Option<SidecarGeo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A sidecar timestamp: epoch seconds, as a *string*.
|
impl PhotoSidecar {
|
||||||
|
fn location(&self) -> Option<GeoPoint> {
|
||||||
|
self.geo_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(SidecarGeo::point)
|
||||||
|
.or_else(|| self.geo_data_exif.as_ref().and_then(SidecarGeo::point))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sidecar timestamp: epoch seconds, written as a string, sometimes a number.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct SidecarTime {
|
struct SidecarTime {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
timestamp: Option<String>,
|
timestamp: Option<SidecarNumber>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum SidecarNumber {
|
||||||
|
Number(i64),
|
||||||
|
Text(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SidecarTime {
|
impl SidecarTime {
|
||||||
|
/// The timestamp, when it is a real moment. `0` and negative values are
|
||||||
|
/// Google's "unknown" and would file the photo under 1970.
|
||||||
fn epoch_seconds(&self) -> Option<i64> {
|
fn epoch_seconds(&self) -> Option<i64> {
|
||||||
self.timestamp.as_ref()?.parse().ok()
|
let seconds = match self.timestamp.as_ref()? {
|
||||||
|
SidecarNumber::Number(seconds) => *seconds,
|
||||||
|
SidecarNumber::Text(text) => text.trim().parse().ok()?,
|
||||||
|
};
|
||||||
|
(seconds > 0).then_some(seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `geoData` in a sidecar. All-zero means Google has no position.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SidecarGeo {
|
||||||
|
#[serde(default)]
|
||||||
|
latitude: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
longitude: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SidecarGeo {
|
||||||
|
fn point(&self) -> Option<GeoPoint> {
|
||||||
|
let (latitude, longitude) = (self.latitude?, self.longitude?);
|
||||||
|
let on_globe = latitude.is_finite()
|
||||||
|
&& longitude.is_finite()
|
||||||
|
&& (-90.0..=90.0).contains(&latitude)
|
||||||
|
&& (-180.0..=180.0).contains(&longitude);
|
||||||
|
(on_globe && (latitude != 0.0 || longitude != 0.0)).then_some(GeoPoint {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,21 +370,58 @@ impl RawEntry {
|
|||||||
/// is one album, and a sidecar in part 3 is matched to its photo in part 1.
|
/// is one album, and a sidecar in part 3 is matched to its photo in part 1.
|
||||||
/// Reads only the central directory and the JSON sidecars — no media bytes, so
|
/// Reads only the central directory and the JSON sidecars — no media bytes, so
|
||||||
/// this stays fast on a 200 GB export.
|
/// this stays fast on a 200 GB export.
|
||||||
|
///
|
||||||
|
/// An archive that cannot be opened is left out and listed in
|
||||||
|
/// [`TakeoutScan::unreadable_archives`]; the rest still imports.
|
||||||
pub fn scan_archives(archives: &[PathBuf]) -> CoreResult<TakeoutScan> {
|
pub fn scan_archives(archives: &[PathBuf]) -> CoreResult<TakeoutScan> {
|
||||||
let mut entries: Vec<RawEntry> = Vec::new();
|
scan_archives_until(archives, || false)
|
||||||
let mut sidecars: HashMap<String, Vec<u8>> = HashMap::new();
|
}
|
||||||
|
|
||||||
|
/// [`scan_archives`] that gives up when `stop` returns true, checked between
|
||||||
|
/// archives and every few hundred entries.
|
||||||
|
pub fn scan_archives_until(
|
||||||
|
archives: &[PathBuf],
|
||||||
|
stop: impl Fn() -> bool,
|
||||||
|
) -> CoreResult<TakeoutScan> {
|
||||||
|
let mut entries: Vec<(String, RawEntry)> = Vec::new();
|
||||||
|
let mut sidecars: Vec<(String, String, Vec<u8>)> = Vec::new();
|
||||||
|
let mut unreadable_archives = Vec::new();
|
||||||
|
let mut unreadable_entries = 0;
|
||||||
|
|
||||||
for (index, path) in archives.iter().enumerate() {
|
for (index, path) in archives.iter().enumerate() {
|
||||||
let file = std::fs::File::open(path)
|
if stop() {
|
||||||
.map_err(|e| CoreError::invalid(format!("cannot open {}: {e}", path.display())))?;
|
return Err(CoreError::conflict("the import was cancelled"));
|
||||||
let mut zip = zip::ZipArchive::new(std::io::BufReader::new(file)).map_err(|e| {
|
}
|
||||||
CoreError::invalid(format!("{} is not a readable zip: {e}", path.display()))
|
let opened = std::fs::File::open(path)
|
||||||
})?;
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(|file| {
|
||||||
|
zip::ZipArchive::new(std::io::BufReader::new(file))
|
||||||
|
.map_err(|e| format!("not a readable zip: {e}"))
|
||||||
|
});
|
||||||
|
let mut zip = match opened {
|
||||||
|
Ok(zip) => zip,
|
||||||
|
Err(reason) => {
|
||||||
|
tracing::warn!(archive = %path.display(), %reason, "skipping an unreadable takeout archive");
|
||||||
|
unreadable_archives.push(UnreadableArchive {
|
||||||
|
path: path.clone(),
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for position in 0..zip.len() {
|
for position in 0..zip.len() {
|
||||||
let mut entry = zip.by_index(position).map_err(|e| {
|
if position % 512 == 0 && stop() {
|
||||||
CoreError::invalid(format!("{}: unreadable entry: {e}", path.display()))
|
return Err(CoreError::conflict("the import was cancelled"));
|
||||||
})?;
|
}
|
||||||
|
let mut entry = match zip.by_index(position) {
|
||||||
|
Ok(entry) => entry,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(archive = %path.display(), position, error = %e, "unreadable takeout entry");
|
||||||
|
unreadable_entries += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
if entry.is_dir() {
|
if entry.is_dir() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -286,32 +431,92 @@ pub fn scan_archives(archives: &[PathBuf]) -> CoreResult<TakeoutScan> {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let name = name.to_string_lossy().replace('\\', "/");
|
let name = name.to_string_lossy().replace('\\', "/");
|
||||||
let Some(relative) = strip_export_prefix(&name) else {
|
let Some((service, relative)) = strip_export_prefix(&name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
let (service, relative) = (service.to_string(), relative.to_string());
|
||||||
let size = entry.size();
|
let size = entry.size();
|
||||||
|
|
||||||
// Sidecars are read now, while the entry is open: they are a few
|
// Sidecars are read now, while the entry is open: they are a few
|
||||||
// hundred bytes each and reopening the archive per photo later would
|
// hundred bytes each and reopening the archive per photo later would
|
||||||
// be a second full pass.
|
// be a second full pass.
|
||||||
if relative.to_ascii_lowercase().ends_with(".json") {
|
if relative.to_ascii_lowercase().ends_with(".json") {
|
||||||
let mut body = Vec::with_capacity(size.min(64 * 1024) as usize);
|
if size <= SIDECAR_MAX_BYTES {
|
||||||
|
let mut body = Vec::with_capacity(size as usize);
|
||||||
if entry.read_to_end(&mut body).is_ok() {
|
if entry.read_to_end(&mut body).is_ok() {
|
||||||
sidecars.insert(relative.to_string(), body);
|
sidecars.push((service, relative, body));
|
||||||
|
} else {
|
||||||
|
unreadable_entries += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
entries.push((
|
||||||
entries.push(RawEntry {
|
service,
|
||||||
|
RawEntry {
|
||||||
archive: index,
|
archive: index,
|
||||||
entry: name.clone(),
|
entry: name,
|
||||||
relative: relative.to_string(),
|
relative,
|
||||||
size,
|
size,
|
||||||
});
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(assemble(entries, &sidecars))
|
let (entries, sidecars, other_services) = keep_photos_services(entries, sidecars);
|
||||||
|
let mut scan = assemble(entries, &sidecars);
|
||||||
|
scan.skipped_other += other_services;
|
||||||
|
scan.unreadable_archives = unreadable_archives;
|
||||||
|
scan.unreadable_entries = unreadable_entries;
|
||||||
|
Ok(scan)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop what other Google services put into the same export.
|
||||||
|
///
|
||||||
|
/// A Takeout can hold `Google Drive/`, `YouTube/` and others next to
|
||||||
|
/// `Google Photos/`, and their media must not land in the photo timeline. The
|
||||||
|
/// service folder's name is localized (`Google Fotos`), so the Photos service
|
||||||
|
/// is recognized by its content instead: it is the folder whose sidecars carry
|
||||||
|
/// a `photoTakenTime`. When no folder does — an export without sidecars, or
|
||||||
|
/// one re-zipped without the service level — everything is kept, as before.
|
||||||
|
///
|
||||||
|
/// Returns the entries and sidecars to import, and how many media files were
|
||||||
|
/// left out.
|
||||||
|
fn keep_photos_services(
|
||||||
|
entries: Vec<(String, RawEntry)>,
|
||||||
|
sidecars: Vec<(String, String, Vec<u8>)>,
|
||||||
|
) -> (Vec<RawEntry>, HashMap<String, Vec<u8>>, usize) {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Marker {
|
||||||
|
#[serde(rename = "photoTakenTime")]
|
||||||
|
photo_taken_time: Option<serde::de::IgnoredAny>,
|
||||||
|
}
|
||||||
|
let mut photos_services: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||||
|
for (service, _, body) in &sidecars {
|
||||||
|
if photos_services.contains(service.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if serde_json::from_slice::<Marker>(body).is_ok_and(|m| m.photo_taken_time.is_some()) {
|
||||||
|
photos_services.insert(service.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let keep = |service: &str| photos_services.is_empty() || photos_services.contains(service);
|
||||||
|
|
||||||
|
let mut skipped = 0;
|
||||||
|
let mut kept_entries = Vec::with_capacity(entries.len());
|
||||||
|
for (service, raw) in entries {
|
||||||
|
if keep(&service) {
|
||||||
|
kept_entries.push(raw);
|
||||||
|
} else if media_type_of(raw.file_name()).is_some() {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let kept_sidecars = sidecars
|
||||||
|
.iter()
|
||||||
|
.filter(|(service, _, _)| keep(service))
|
||||||
|
.map(|(_, relative, body)| (relative.clone(), body.clone()))
|
||||||
|
.collect();
|
||||||
|
(kept_entries, kept_sidecars, skipped)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the scan from raw entries plus the sidecar bodies keyed by their path
|
/// Build the scan from raw entries plus the sidecar bodies keyed by their path
|
||||||
@@ -403,19 +608,19 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
.unwrap_or(&file_name)
|
.unwrap_or(&file_name)
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Sidecar first, then the date stamped into the name.
|
// Sidecar first, then the date stamped into the name. `creationTime`
|
||||||
|
// is when the photo reached Google, which for a scan or an old photo
|
||||||
|
// uploaded late is years off; the importer uses it only after EXIF.
|
||||||
let capture_time = sidecar
|
let capture_time = sidecar
|
||||||
.photo_taken_time
|
.photo_taken_time
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(SidecarTime::epoch_seconds)
|
.and_then(SidecarTime::epoch_seconds)
|
||||||
.or_else(|| {
|
|
||||||
sidecar
|
|
||||||
.creation_time
|
|
||||||
.as_ref()
|
|
||||||
.and_then(SidecarTime::epoch_seconds)
|
|
||||||
})
|
|
||||||
.or_else(|| capture_time_from_name(&name))
|
.or_else(|| capture_time_from_name(&name))
|
||||||
.or_else(|| capture_time_from_name(&file_name));
|
.or_else(|| capture_time_from_name(&file_name));
|
||||||
|
let creation_time = sidecar
|
||||||
|
.creation_time
|
||||||
|
.as_ref()
|
||||||
|
.and_then(SidecarTime::epoch_seconds);
|
||||||
|
|
||||||
scan.photos.push(TakeoutPhoto {
|
scan.photos.push(TakeoutPhoto {
|
||||||
archive: raw.archive,
|
archive: raw.archive,
|
||||||
@@ -424,31 +629,57 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
size: raw.size,
|
size: raw.size,
|
||||||
media_type: media_type.to_string(),
|
media_type: media_type.to_string(),
|
||||||
capture_time,
|
capture_time,
|
||||||
favorite: sidecar.favorited,
|
creation_time,
|
||||||
|
location: sidecar.location(),
|
||||||
|
favorite: sidecar.favorited.unwrap_or(false),
|
||||||
bucket,
|
bucket,
|
||||||
|
live_key: live_key(&folder, &file_name),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
scan
|
scan
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the export root (`Takeout/`) and the localized service folder from a
|
/// Split a path inside the archive into the localized service folder and the
|
||||||
/// path inside the archive, leaving `<folder>/<file>` or `<file>`.
|
/// rest, dropping the export root (`Takeout/`): `("Google Fotos",
|
||||||
|
/// "<folder>/<file>")`.
|
||||||
///
|
///
|
||||||
/// `None` for a path with no service folder at all (the export's own
|
/// `None` for a path with no service folder at all (the export's own
|
||||||
/// `archive_browser.html`), which carries nothing to import.
|
/// `archive_browser.html`), which carries nothing to import.
|
||||||
fn strip_export_prefix(path: &str) -> Option<&str> {
|
fn strip_export_prefix(path: &str) -> Option<(&str, &str)> {
|
||||||
let mut rest = path;
|
let mut rest = path;
|
||||||
// The root is usually `Takeout/`, but an export unzipped and re-zipped by
|
// The root is `Takeout/`, but an export unzipped and re-zipped by the user
|
||||||
// the user may not have it — so the root is dropped only when a service
|
// may not have it. `Google Photos/<album>/<file>` then has three parts like
|
||||||
// folder follows it, which the component count decides.
|
// `Takeout/Google Photos/<file>`, so the count alone cannot tell them
|
||||||
|
// apart: a root of another name is only taken for one with four parts.
|
||||||
|
let (first, after) = rest.split_once('/')?;
|
||||||
let components = rest.split('/').count();
|
let components = rest.split('/').count();
|
||||||
if components >= 3 {
|
if (first.eq_ignore_ascii_case("takeout") && components >= 3) || components >= 4 {
|
||||||
rest = rest.split_once('/')?.1;
|
rest = after;
|
||||||
}
|
}
|
||||||
// Drop the service folder (`Google Photos`, `Google Fotos`, …).
|
// The service folder (`Google Photos`, `Google Fotos`, …).
|
||||||
let (_service, tail) = rest.split_once('/')?;
|
rest.split_once('/')
|
||||||
Some(tail)
|
}
|
||||||
|
|
||||||
|
/// The key that pairs the still and the video of one live or motion photo, or
|
||||||
|
/// `None` when `file_name` cannot be either half.
|
||||||
|
///
|
||||||
|
/// Apple writes `IMG_1.HEIC` (or `.JPG`) and `IMG_1.MOV`, which share the stem.
|
||||||
|
/// A Pixel writes `PXL_1.MP.jpg` and `PXL_1.MP`; there the still's stem is the
|
||||||
|
/// video's whole name. The folder is part of the key so two cameras' `IMG_1`
|
||||||
|
/// in different albums never pair.
|
||||||
|
fn live_key(folder: &str, file_name: &str) -> Option<String> {
|
||||||
|
let lower = file_name.to_lowercase();
|
||||||
|
let (stem, ext) = lower.rsplit_once('.')?;
|
||||||
|
let key = match ext {
|
||||||
|
"heic" | "heif" | "jpg" | "jpeg" => stem,
|
||||||
|
// Apple's video half. A plain `.mp4` beside a photo of the same name is
|
||||||
|
// far more often an unrelated clip than half of a live photo.
|
||||||
|
"mov" => stem,
|
||||||
|
"mp" => lower.as_str(),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
(!key.is_empty()).then(|| format!("{folder}/{key}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split `<folder>/<file>` into its parts; the folder is `""` at the root.
|
/// Split `<folder>/<file>` into its parts; the folder is `""` at the root.
|
||||||
@@ -858,13 +1089,13 @@ mod tests {
|
|||||||
RawEntry {
|
RawEntry {
|
||||||
archive,
|
archive,
|
||||||
entry: path.to_string(),
|
entry: path.to_string(),
|
||||||
relative: strip_export_prefix(path).unwrap().to_string(),
|
relative: strip_export_prefix(path).unwrap().1.to_string(),
|
||||||
size,
|
size,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sidecar(map: &mut HashMap<String, Vec<u8>>, path: &str, body: &str) {
|
fn sidecar(map: &mut HashMap<String, Vec<u8>>, path: &str, body: &str) {
|
||||||
let relative = strip_export_prefix(path).unwrap().to_string();
|
let relative = strip_export_prefix(path).unwrap().1.to_string();
|
||||||
map.insert(relative, body.as_bytes().to_vec());
|
map.insert(relative, body.as_bytes().to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,12 +1103,17 @@ mod tests {
|
|||||||
fn strips_export_root_and_localized_service_folder() {
|
fn strips_export_root_and_localized_service_folder() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_export_prefix("Takeout/Google Fotos/Island 2019/IMG_1.jpg"),
|
strip_export_prefix("Takeout/Google Fotos/Island 2019/IMG_1.jpg"),
|
||||||
Some("Island 2019/IMG_1.jpg")
|
Some(("Google Fotos", "Island 2019/IMG_1.jpg"))
|
||||||
);
|
);
|
||||||
// Already unwrapped by the user: no `Takeout/` root left.
|
// Already unwrapped by the user: no `Takeout/` root left.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_export_prefix("Google Photos/IMG_1.jpg"),
|
strip_export_prefix("Google Photos/IMG_1.jpg"),
|
||||||
Some("IMG_1.jpg")
|
Some(("Google Photos", "IMG_1.jpg"))
|
||||||
|
);
|
||||||
|
// ... and an album keeps its folder.
|
||||||
|
assert_eq!(
|
||||||
|
strip_export_prefix("Google Photos/Iceland/IMG_1.jpg"),
|
||||||
|
Some(("Google Photos", "Iceland/IMG_1.jpg"))
|
||||||
);
|
);
|
||||||
// Nothing below a service folder is nothing to import.
|
// Nothing below a service folder is nothing to import.
|
||||||
assert_eq!(strip_export_prefix("archive_browser.html"), None);
|
assert_eq!(strip_export_prefix("archive_browser.html"), None);
|
||||||
@@ -957,23 +1193,144 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn creation_time_is_the_fallback_capture_time() {
|
fn creation_time_is_kept_apart_as_the_last_resort() {
|
||||||
|
// `creationTime` is when the photo reached Google. It must not beat the
|
||||||
|
// date in the name, and the importer tries EXIF before it.
|
||||||
let mut sidecars = HashMap::new();
|
let mut sidecars = HashMap::new();
|
||||||
sidecar(
|
sidecar(
|
||||||
&mut sidecars,
|
&mut sidecars,
|
||||||
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg.json",
|
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg.json",
|
||||||
r#"{"creationTime":{"timestamp":"1500000000"}}"#,
|
r#"{"creationTime":{"timestamp":"1500000000"}}"#,
|
||||||
);
|
);
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_20190612_101500.jpg.json",
|
||||||
|
r#"{"creationTime":{"timestamp":"1700000000"}}"#,
|
||||||
|
);
|
||||||
let scan = assemble(
|
let scan = assemble(
|
||||||
vec![entry(
|
vec![
|
||||||
|
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_1.jpg", 10),
|
||||||
|
entry(
|
||||||
0,
|
0,
|
||||||
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg",
|
"Takeout/Google Photos/Photos from 2019/IMG_20190612_101500.jpg",
|
||||||
10,
|
10,
|
||||||
)],
|
),
|
||||||
|
],
|
||||||
&sidecars,
|
&sidecars,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(scan.photos[0].capture_time, Some(1_500_000_000));
|
assert_eq!(scan.photos[0].capture_time, None);
|
||||||
|
assert_eq!(scan.photos[0].creation_time, Some(1_500_000_000));
|
||||||
|
assert_eq!(
|
||||||
|
scan.photos[1].capture_time,
|
||||||
|
capture_time_from_name("IMG_20190612_101500.jpg")
|
||||||
|
);
|
||||||
|
assert_eq!(scan.photos[1].creation_time, Some(1_700_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidecar_timestamps_tolerate_numbers_zero_and_null() {
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
for (name, body) in [
|
||||||
|
("A.jpg", r#"{"photoTakenTime":{"timestamp":1560000000}}"#),
|
||||||
|
(
|
||||||
|
"B.jpg",
|
||||||
|
r#"{"photoTakenTime":{"timestamp":"0"},"favorited":null}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"C.jpg",
|
||||||
|
r#"{"photoTakenTime":{"timestamp":null},"geoData":null}"#,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
&format!("Takeout/Google Photos/Photos from 2019/{name}.json"),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let scan = assemble(
|
||||||
|
["A.jpg", "B.jpg", "C.jpg"]
|
||||||
|
.iter()
|
||||||
|
.map(|name| {
|
||||||
|
entry(
|
||||||
|
0,
|
||||||
|
&format!("Takeout/Google Photos/Photos from 2019/{name}"),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos[0].capture_time, Some(1_560_000_000));
|
||||||
|
assert_eq!(scan.photos[1].capture_time, None, "0 is not 1970");
|
||||||
|
assert!(!scan.photos[1].favorite);
|
||||||
|
assert_eq!(scan.photos[2].capture_time, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn location_comes_from_geo_data_and_zero_means_none() {
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Iceland/IMG_1.jpg.json",
|
||||||
|
r#"{"geoData":{"latitude":64.1466,"longitude":-21.9426,"altitude":0.0}}"#,
|
||||||
|
);
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Iceland/IMG_2.jpg.json",
|
||||||
|
r#"{"geoData":{"latitude":0.0,"longitude":0.0},
|
||||||
|
"geoDataExif":{"latitude":63.4186,"longitude":-19.0060}}"#,
|
||||||
|
);
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Iceland/IMG_3.jpg.json",
|
||||||
|
r#"{"geoData":{"latitude":0.0,"longitude":0.0}}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
(1..=3)
|
||||||
|
.map(|n| entry(0, &format!("Takeout/Google Photos/Iceland/IMG_{n}.jpg"), 1))
|
||||||
|
.collect(),
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
scan.photos[0].location,
|
||||||
|
Some(GeoPoint {
|
||||||
|
latitude: 64.1466,
|
||||||
|
longitude: -21.9426
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scan.photos[1].location.map(|p| p.latitude),
|
||||||
|
Some(63.4186),
|
||||||
|
"geoDataExif fills in for an empty geoData"
|
||||||
|
);
|
||||||
|
assert_eq!(scan.photos[2].location, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_and_motion_photo_halves_share_a_key() {
|
||||||
|
let scan = assemble(
|
||||||
|
vec![
|
||||||
|
entry(0, "Takeout/Google Photos/Trip/IMG_1.HEIC", 1),
|
||||||
|
entry(0, "Takeout/Google Photos/Trip/IMG_1.MOV", 1),
|
||||||
|
entry(0, "Takeout/Google Photos/Trip/PXL_2.MP.jpg", 1),
|
||||||
|
entry(0, "Takeout/Google Photos/Trip/PXL_2.MP", 1),
|
||||||
|
entry(0, "Takeout/Google Photos/Other/IMG_1.MOV", 1),
|
||||||
|
entry(0, "Takeout/Google Photos/Trip/scan.png", 1),
|
||||||
|
],
|
||||||
|
&HashMap::new(),
|
||||||
|
);
|
||||||
|
let key = |i: usize| scan.photos[i].live_key.clone();
|
||||||
|
|
||||||
|
assert_eq!(key(0), key(1));
|
||||||
|
assert!(!scan.photos[0].is_motion_photo());
|
||||||
|
assert_eq!(key(2), key(3));
|
||||||
|
assert!(scan.photos[3].is_motion_photo());
|
||||||
|
assert!(scan.photos[3].is_video(), "the .MP half is a video");
|
||||||
|
assert_ne!(key(1), key(4), "another folder's IMG_1 does not pair");
|
||||||
|
assert_eq!(key(5), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1332,8 +1689,8 @@ mod tests {
|
|||||||
));
|
));
|
||||||
let file = std::fs::File::create(&path).unwrap();
|
let file = std::fs::File::create(&path).unwrap();
|
||||||
let mut zip = zip::ZipWriter::new(file);
|
let mut zip = zip::ZipWriter::new(file);
|
||||||
let options: zip::write::FileOptions<'_, ()> =
|
let options = zip::write::SimpleFileOptions::default()
|
||||||
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
.compression_method(zip::CompressionMethod::Stored);
|
||||||
for (name, body) in files {
|
for (name, body) in files {
|
||||||
zip.start_file(*name, options).unwrap();
|
zip.start_file(*name, options).unwrap();
|
||||||
zip.write_all(body).unwrap();
|
zip.write_all(body).unwrap();
|
||||||
@@ -1387,16 +1744,77 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_file_that_is_not_a_zip_is_a_readable_error() {
|
fn a_file_that_is_not_a_zip_is_left_out_and_the_rest_imports() {
|
||||||
let path =
|
let broken =
|
||||||
std::env::temp_dir().join(format!("pdfs-takeout-not-a-zip-{}", std::process::id()));
|
std::env::temp_dir().join(format!("pdfs-takeout-not-a-zip-{}", std::process::id()));
|
||||||
std::fs::write(&path, b"not a zip at all").unwrap();
|
std::fs::write(&broken, b"not a zip at all").unwrap();
|
||||||
let error = scan_archives(std::slice::from_ref(&path)).unwrap_err();
|
let good = write_zip(
|
||||||
let _ = std::fs::remove_file(&path);
|
"beside-broken",
|
||||||
|
&[("Takeout/Google Photos/Photos from 2019/IMG_1.jpg", b"x")],
|
||||||
|
);
|
||||||
|
|
||||||
|
let scan = scan_archives(&[broken.clone(), good.clone()]).unwrap();
|
||||||
|
let _ = std::fs::remove_file(&broken);
|
||||||
|
let _ = std::fs::remove_file(&good);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos.len(), 1);
|
||||||
|
assert_eq!(scan.photos[0].archive, 1);
|
||||||
|
assert_eq!(scan.unreadable_archives.len(), 1);
|
||||||
|
assert_eq!(scan.unreadable_archives[0].path, broken);
|
||||||
assert!(
|
assert!(
|
||||||
error.message.contains("not a readable zip"),
|
scan.unreadable_archives[0]
|
||||||
"unexpected message: {}",
|
.reason
|
||||||
error.message
|
.contains("not a readable zip"),
|
||||||
|
"unexpected reason: {}",
|
||||||
|
scan.unreadable_archives[0].reason
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_services_in_the_same_export_are_not_imported() {
|
||||||
|
let zip = write_zip(
|
||||||
|
"services",
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"Takeout/Google Fotos/Fotos von 2019/IMG_1.jpg",
|
||||||
|
b"x".as_slice(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Takeout/Google Fotos/Fotos von 2019/IMG_1.jpg.json",
|
||||||
|
br#"{"photoTakenTime":{"timestamp":"1560000000"}}"#.as_slice(),
|
||||||
|
),
|
||||||
|
("Takeout/Drive/My Drive/holiday.jpg", b"y".as_slice()),
|
||||||
|
("Takeout/YouTube/videos/clip.mp4", b"z".as_slice()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let scan = scan_archives(std::slice::from_ref(&zip)).unwrap();
|
||||||
|
let _ = std::fs::remove_file(&zip);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos.len(), 1);
|
||||||
|
assert_eq!(scan.photos[0].name, "IMG_1.jpg");
|
||||||
|
assert_eq!(scan.skipped_other, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_export_without_sidecars_keeps_every_service() {
|
||||||
|
let zip = write_zip(
|
||||||
|
"no-sidecars",
|
||||||
|
&[("Takeout/Google Photos/Photos from 2019/IMG_1.jpg", b"x")],
|
||||||
|
);
|
||||||
|
let scan = scan_archives(std::slice::from_ref(&zip)).unwrap();
|
||||||
|
let _ = std::fs::remove_file(&zip);
|
||||||
|
assert_eq!(scan.photos.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stop_request_ends_the_scan() {
|
||||||
|
let zip = write_zip(
|
||||||
|
"stop",
|
||||||
|
&[("Takeout/Google Photos/Photos from 2019/IMG_1.jpg", b"x")],
|
||||||
|
);
|
||||||
|
let result = scan_archives_until(std::slice::from_ref(&zip), || true);
|
||||||
|
let _ = std::fs::remove_file(&zip);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,11 @@ tokio.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
fuser.workspace = true
|
fuser.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
|
lofty.workspace = true
|
||||||
notify = "8"
|
notify = "8"
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+101
-35
@@ -110,7 +110,7 @@ struct ControlHandlerPermit;
|
|||||||
impl ControlHandlerPermit {
|
impl ControlHandlerPermit {
|
||||||
fn acquire() -> Option<Self> {
|
fn acquire() -> Option<Self> {
|
||||||
ACTIVE_CONTROL_HANDLERS
|
ACTIVE_CONTROL_HANDLERS
|
||||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| {
|
.try_update(Ordering::AcqRel, Ordering::Acquire, |active| {
|
||||||
(active < MAX_CONTROL_HANDLERS).then_some(active + 1)
|
(active < MAX_CONTROL_HANDLERS).then_some(active + 1)
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
@@ -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 {
|
||||||
@@ -840,35 +877,23 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
if archives.is_empty() {
|
if archives.is_empty() {
|
||||||
CtlResponse::error(CoreError::invalid("no Takeout archives given"))
|
CtlResponse::error(CoreError::invalid("no Takeout archives given"))
|
||||||
} else {
|
} else {
|
||||||
let paths: Vec<PathBuf> = archives.into_iter().map(PathBuf::from).collect();
|
let paths = archives.into_iter().map(PathBuf::from).collect();
|
||||||
let missing: Vec<String> = paths
|
start_takeout_import(core, paths, dry_run)
|
||||||
.iter()
|
|
||||||
.filter(|path| !path.is_file())
|
|
||||||
.map(|path| path.display().to_string())
|
|
||||||
.collect();
|
|
||||||
if !missing.is_empty() {
|
|
||||||
CtlResponse::error(CoreError::not_found(format!(
|
|
||||||
"cannot read: {}",
|
|
||||||
missing.join(", ")
|
|
||||||
)))
|
|
||||||
} else {
|
|
||||||
let count = paths.len();
|
|
||||||
match core.spawn_takeout_import(paths, dry_run) {
|
|
||||||
Ok(()) => CtlResponse::Ok {
|
|
||||||
message: format!(
|
|
||||||
"{} {count} archive(s)",
|
|
||||||
if dry_run { "scanning" } else { "importing" }
|
|
||||||
),
|
|
||||||
},
|
|
||||||
Err(e) => CtlResponse::error(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::ImportStatus) => {
|
Ok(CtlRequest::ImportStatus) => {
|
||||||
let (running, summary) = crate::takeout::import_status();
|
let report = crate::takeout::import_status();
|
||||||
CtlResponse::ImportStatus { running, summary }
|
CtlResponse::ImportStatus {
|
||||||
|
running: report.running,
|
||||||
|
summary: report.summary,
|
||||||
|
progress: report.progress,
|
||||||
|
resumable: report.resumable,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::ResumeImport) => match crate::takeout::resumable_archives() {
|
||||||
|
None => CtlResponse::error(CoreError::not_found("there is no import to resume")),
|
||||||
|
Some(paths) => start_takeout_import(core, paths, false),
|
||||||
|
},
|
||||||
Ok(CtlRequest::CancelImport) => {
|
Ok(CtlRequest::CancelImport) => {
|
||||||
if crate::takeout::cancel_import() {
|
if crate::takeout::cancel_import() {
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -938,6 +963,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 +1424,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),
|
||||||
@@ -1956,6 +1997,31 @@ pub(crate) fn run_control_socket(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check that every archive is there, then start the import in the background.
|
||||||
|
fn start_takeout_import(core: &Core, paths: Vec<PathBuf>, dry_run: bool) -> CtlResponse {
|
||||||
|
let missing: Vec<String> = paths
|
||||||
|
.iter()
|
||||||
|
.filter(|path| !path.is_file())
|
||||||
|
.map(|path| path.display().to_string())
|
||||||
|
.collect();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
return CtlResponse::error(CoreError::not_found(format!(
|
||||||
|
"cannot read: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let count = paths.len();
|
||||||
|
match core.spawn_takeout_import(paths, dry_run) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!(
|
||||||
|
"{} {count} archive(s)",
|
||||||
|
if dry_run { "scanning" } else { "importing" }
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod request_limit_tests {
|
mod request_limit_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -1281,6 +1281,13 @@ impl Core {
|
|||||||
sent: (&sent.0, &sent.1),
|
sent: (&sent.0, &sent.1),
|
||||||
landed: (&home.to_string(), &name),
|
landed: (&home.to_string(), &name),
|
||||||
};
|
};
|
||||||
|
// Counted from the op's retirement until the bytes are cached and the
|
||||||
|
// tree has the real uid, as an upload is. Uncounted, the queue read
|
||||||
|
// empty while that still ran: a delete in between took an open file
|
||||||
|
// for closed and dropped what its handle reads, and a script that
|
||||||
|
// waited for an empty queue read the file before it was cached
|
||||||
|
// (`docs/BUGS.md` B257).
|
||||||
|
let _landing = LandingUpload::begin(&self.landing_uploads);
|
||||||
let newer = match self.retire_create(op, uploaded, &landing, &local, &real)? {
|
let newer = match self.retire_create(op, uploaded, &landing, &local, &real)? {
|
||||||
CreateRetired::Withdrawn { trash } => {
|
CreateRetired::Withdrawn { trash } => {
|
||||||
return self.trash_withdrawn_create(op, trash, &local, &real, &name);
|
return self.trash_withdrawn_create(op, trash, &local, &real, &name);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -2376,7 +2376,7 @@ impl ProtonFs {
|
|||||||
// regardless — the file is gone from their point of view the moment the
|
// regardless — the file is gone from their point of view the moment the
|
||||||
// command returns. The same when the network goes
|
// command returns. The same when the network goes
|
||||||
// away under the remote call below.
|
// away under the remote call below.
|
||||||
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) {
|
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name, open_now) {
|
||||||
Ok(()) => reply.ok(),
|
Ok(()) => reply.ok(),
|
||||||
Err(e) => reply.error(e),
|
Err(e) => reply.error(e),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-23
@@ -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
|
||||||
@@ -3999,7 +4026,12 @@ impl Core {
|
|||||||
/// the cached ones, as when the trash is sent at once: a partial write
|
/// the cached ones, as when the trash is sent at once: a partial write
|
||||||
/// fills its gaps from them, offline too (`docs/BUGS.md` B186). The final
|
/// fills its gaps from them, offline too (`docs/BUGS.md` B186). The final
|
||||||
/// close or the trash landing, whichever comes last, lets go of them.
|
/// close or the trash landing, whichever comes last, lets go of them.
|
||||||
fn queue_trash(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
///
|
||||||
|
/// `open` is whether the caller found the file open. A create that landed
|
||||||
|
/// under a delete is trashed by its real uid before the tree has it, so
|
||||||
|
/// asking the tree alone took an open file for closed (`docs/BUGS.md`
|
||||||
|
/// B257).
|
||||||
|
fn queue_trash(&self, uid: &NodeUid, name: &str, open: bool) -> Result<(), Errno> {
|
||||||
self.require_uid_writable(uid)?;
|
self.require_uid_writable(uid)?;
|
||||||
// The bytes below are about to be discarded, and a drain worker may be
|
// The bytes below are about to be discarded, and a drain worker may be
|
||||||
// reading them onto the wire right now. Stop it first: with several
|
// reading them onto the wire right now. Stop it first: with several
|
||||||
@@ -4014,7 +4046,7 @@ impl Core {
|
|||||||
error!(%uid, error = %e, "queueing trash failed");
|
error!(%uid, error = %e, "queueing trash failed");
|
||||||
Errno::EIO
|
Errno::EIO
|
||||||
})?;
|
})?;
|
||||||
let open_now = self.is_open_anywhere(uid);
|
let open_now = open || self.is_open_anywhere(uid);
|
||||||
if open_now {
|
if open_now {
|
||||||
let read_from = self.pending_blob(uid);
|
let read_from = self.pending_blob(uid);
|
||||||
self.release_dropped_below(
|
self.release_dropped_below(
|
||||||
@@ -4263,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();
|
||||||
@@ -4287,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();
|
||||||
@@ -4297,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)
|
||||||
}
|
}
|
||||||
@@ -4317,15 +4360,21 @@ 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) {
|
||||||
|
job.detail(file.name.clone());
|
||||||
|
if let Err(e) =
|
||||||
|
self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size)
|
||||||
|
{
|
||||||
|
warn!(uid = %file.uid, error = %e, "pin folder: download failed");
|
||||||
failed += 1;
|
failed += 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
job.step();
|
job.step();
|
||||||
}
|
}
|
||||||
info!(folder = %name, failed, "pin folder: done");
|
info!(folder = %name, failed, "pin folder: done");
|
||||||
@@ -4358,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() {
|
||||||
@@ -4375,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),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5383,9 +5427,12 @@ impl Core {
|
|||||||
/// `unlink` under an open handle. Its queued ops go now, or the drain would
|
/// `unlink` under an open handle. Its queued ops go now, or the drain would
|
||||||
/// upload to the node after it was trashed (`docs/BUGS.md` B106).
|
/// upload to the node after it was trashed (`docs/BUGS.md` B106).
|
||||||
fn remove_replaced(&self, ino: u64, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
fn remove_replaced(&self, ino: u64, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
||||||
|
// Asked under the uid the tree has now: a landed create's real uid is
|
||||||
|
// not in the tree yet (`docs/BUGS.md` B257).
|
||||||
|
let open = self.is_open_anywhere(uid);
|
||||||
let landed;
|
let landed;
|
||||||
let uid = if is_local_uid(uid) {
|
let uid = if is_local_uid(uid) {
|
||||||
if self.is_open_anywhere(uid) {
|
if open {
|
||||||
self.withdraw_queued_ops(uid)?;
|
self.withdraw_queued_ops(uid)?;
|
||||||
} else {
|
} else {
|
||||||
self.discard_queued_ops(uid)?;
|
self.discard_queued_ops(uid)?;
|
||||||
@@ -5410,7 +5457,7 @@ impl Core {
|
|||||||
// Local-first or offline, or the network goes away under the call:
|
// Local-first or offline, or the network goes away under the call:
|
||||||
// queue the trash. The rename that follows queues as well, behind it.
|
// queue the trash. The rename that follows queues as well, behind it.
|
||||||
if !self.sends_inline() {
|
if !self.sends_inline() {
|
||||||
return self.queue_trash(uid, name);
|
return self.queue_trash(uid, name, open);
|
||||||
}
|
}
|
||||||
self.require_uid_writable(uid)?;
|
self.require_uid_writable(uid)?;
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
@@ -5418,7 +5465,7 @@ impl Core {
|
|||||||
.and_then(batch::into_unit)
|
.and_then(batch::into_unit)
|
||||||
{
|
{
|
||||||
if self.lost_link(&e, "trash a replaced node") {
|
if self.lost_link(&e, "trash a replaced node") {
|
||||||
return self.queue_trash(uid, name);
|
return self.queue_trash(uid, name, open);
|
||||||
}
|
}
|
||||||
error!(%uid, name, error = %e, "trashing the node a rename replaces failed");
|
error!(%uid, name, error = %e, "trashing the node a rename replaces failed");
|
||||||
self.log_activity(ActivityKind::Trash, name, e.to_string(), false);
|
self.log_activity(ActivityKind::Trash, name, e.to_string(), false);
|
||||||
@@ -6433,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,
|
||||||
@@ -9258,7 +9306,7 @@ mod tests {
|
|||||||
assert!(!replaced.contains("st.forget(uid)"));
|
assert!(!replaced.contains("st.forget(uid)"));
|
||||||
assert_before(
|
assert_before(
|
||||||
replaced,
|
replaced,
|
||||||
"if self.is_open_anywhere(uid)",
|
"let open = self.is_open_anywhere(uid)",
|
||||||
"self.discard_queued_ops(uid)",
|
"self.discard_queued_ops(uid)",
|
||||||
);
|
);
|
||||||
assert_before(replaced, "if open_now", "self.cache.evict(uid)");
|
assert_before(replaced, "if open_now", "self.cache.evict(uid)");
|
||||||
|
|||||||
@@ -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,43 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make sure `mountpoint` is a directory a mount can go on, clearing a dead
|
||||||
|
/// mount a killed daemon left there first.
|
||||||
|
///
|
||||||
|
/// A dead mount answers `ENOTCONN`, which `create_dir_all` takes for "absent";
|
||||||
|
/// it then tries `mkdir` and fails with `EEXIST`. The daemon retried that error
|
||||||
|
/// forever and never got as far as [`check_mountpoint`], which would have
|
||||||
|
/// cleared the mount (`docs/BUGS.md` B279).
|
||||||
|
pub fn prepare_mountpoint(mountpoint: &Path) -> std::io::Result<()> {
|
||||||
|
if is_stale_mount(mountpoint) {
|
||||||
|
clear_stale_mount(mountpoint);
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(mountpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse to mount My files over a folder that already has files in it: FUSE
|
||||||
|
/// would hide them, unsent and out of sight, until the service stops
|
||||||
|
/// (`docs/BUGS.md` B277). Runs before the root fetch and before any worker
|
||||||
|
/// starts, so a refusal costs nothing to retry.
|
||||||
|
///
|
||||||
|
/// A dead mount is cleared first, since it cannot be listed. One of this
|
||||||
|
/// client's own live mounts is left to [`spawn_session`], which clears it as it
|
||||||
|
/// always has; listing it would show Drive's files, not the folder's.
|
||||||
|
pub(super) fn check_mountpoint(mountpoint: &Path) -> std::io::Result<()> {
|
||||||
|
if is_stale_mount(mountpoint) {
|
||||||
|
clear_stale_mount(mountpoint);
|
||||||
|
}
|
||||||
|
if pdfs_core::mountpoint::mounted_fs_type(mountpoint).as_deref()
|
||||||
|
== Some(pdfs_core::mountpoint::OWN_FS_TYPE)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match pdfs_core::mountpoint::not_empty(mountpoint)? {
|
||||||
|
Some(found) => Err(std::io::Error::other(found)),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
||||||
/// recover it from `nodes` without the API.
|
/// recover it from `nodes` without the API.
|
||||||
const ROOT_UID_KEY: &str = "root_uid";
|
const ROOT_UID_KEY: &str = "root_uid";
|
||||||
@@ -427,6 +464,7 @@ pub(crate) fn mount_with(
|
|||||||
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||||
// so the age it reports is the daemon's own.
|
// so the age it reports is the daemon's own.
|
||||||
super::diagnostics::start_clock();
|
super::diagnostics::start_clock();
|
||||||
|
check_mountpoint(mountpoint)?;
|
||||||
let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
||||||
let online = start == Start::Online;
|
let online = start == Start::Online;
|
||||||
let scope = root.tree_event_scope_id();
|
let scope = root.tree_event_scope_id();
|
||||||
@@ -473,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())),
|
||||||
@@ -583,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) {
|
||||||
|
|||||||
+1165
-74
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,22 @@ impl Daemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether nothing is queued and no upload is still landing, as `pdfs
|
||||||
|
/// status` counts it. An empty [`Self::pending`] is not enough before a
|
||||||
|
/// read that needs the cache: the op goes before the uploaded bytes are
|
||||||
|
/// cached, so a test that went offline at once raced the drain.
|
||||||
|
pub(crate) fn drained(&self) -> bool {
|
||||||
|
self.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
&& matches!(
|
||||||
|
self.request(&Request::Status),
|
||||||
|
Ok(Response::Status {
|
||||||
|
pending_uploads: 0,
|
||||||
|
pending_changes: 0,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the mount believes Proton is reachable, as `pdfs status` shows
|
/// Whether the mount believes Proton is reachable, as `pdfs status` shows
|
||||||
/// it.
|
/// it.
|
||||||
pub(crate) fn online(&self) -> io::Result<bool> {
|
pub(crate) fn online(&self) -> io::Result<bool> {
|
||||||
@@ -393,6 +409,43 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A folder with files in it is not mounted over, and its files stay where
|
||||||
|
/// they are; once it is empty, the mount comes up (B277, #67).
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_mount_over_local_files_is_refused_and_leaves_them_alone() {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("not-empty");
|
||||||
|
let mountpoint = dir.join("mnt");
|
||||||
|
std::fs::create_dir_all(mountpoint.join("Work")).unwrap();
|
||||||
|
std::fs::write(mountpoint.join("notes.txt"), b"local only").unwrap();
|
||||||
|
|
||||||
|
let error = Daemon::start(&dir, drive.client(1, Faults::lan()))
|
||||||
|
.err()
|
||||||
|
.expect("the mount must be refused");
|
||||||
|
let found = error
|
||||||
|
.get_ref()
|
||||||
|
.and_then(|inner| inner.downcast_ref::<pdfs_core::mountpoint::NotEmpty>())
|
||||||
|
.unwrap_or_else(|| panic!("refused for another reason: {error}"));
|
||||||
|
assert_eq!(found.entries, ["Work", "notes.txt"]);
|
||||||
|
assert!(!is_mounted(&mountpoint));
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(mountpoint.join("notes.txt")).unwrap(),
|
||||||
|
b"local only"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
drive.tree().is_empty(),
|
||||||
|
"nothing went to Drive: {:?}",
|
||||||
|
drive.tree()
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_file(mountpoint.join("notes.txt")).unwrap();
|
||||||
|
std::fs::remove_dir(mountpoint.join("Work")).unwrap();
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
/// Write `bytes` at `offset` into an existing file, without truncating it.
|
/// Write `bytes` at `offset` into an existing file, without truncating it.
|
||||||
fn write_at(path: &Path, offset: u64, bytes: &[u8]) -> io::Result<()> {
|
fn write_at(path: &Path, offset: u64, bytes: &[u8]) -> io::Result<()> {
|
||||||
use std::os::unix::fs::FileExt;
|
use std::os::unix::fs::FileExt;
|
||||||
@@ -565,7 +618,7 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::write(&path, b"0123456789").unwrap();
|
std::fs::write(&path, b"0123456789").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
daemon.drained()
|
||||||
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
|
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
|
||||||
}));
|
}));
|
||||||
daemon.client.set_online(false);
|
daemon.client.set_online(false);
|
||||||
@@ -692,6 +745,55 @@ 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 a_new_file_deleted_while_open_as_its_create_lands_still_reads() {
|
||||||
|
// The create's op was retired and the tree still had the placeholder.
|
||||||
|
// The queue read empty, and the delete trashed the node by its real
|
||||||
|
// uid, which the tree did not have yet: it took the open file for
|
||||||
|
// closed and dropped the bytes its handle reads (B257).
|
||||||
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("delete-open-as-create-lands");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let path = daemon.mountpoint.join("f.txt");
|
||||||
|
|
||||||
|
let landing = daemon.client.hold_next_landing_of_any();
|
||||||
|
std::fs::write(&path, b"0123456789").unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
|
||||||
|
assert!(!daemon.drained(), "the landing create is counted");
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap();
|
||||||
|
drop(landing);
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
|
||||||
|
|
||||||
|
daemon.client.set_online(false);
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
file.seek(SeekFrom::End(0)).unwrap();
|
||||||
|
file.write_all(b"ab").unwrap();
|
||||||
|
let mut read = Vec::new();
|
||||||
|
file.seek(SeekFrom::Start(0)).unwrap();
|
||||||
|
file.read_to_end(&mut read).unwrap();
|
||||||
|
assert_eq!(read, b"0123456789ab");
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
daemon.client.set_online(true);
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.drained() && drive.tree().is_empty()
|
||||||
|
}),
|
||||||
|
"{:?} {:?}",
|
||||||
|
daemon.pending(),
|
||||||
|
drive.tree()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
fn a_restart_does_not_bring_back_the_write_of_a_file_deleted_while_open() {
|
fn a_restart_does_not_bring_back_the_write_of_a_file_deleted_while_open() {
|
||||||
@@ -706,7 +808,7 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::write(&path, b"0123456789").unwrap();
|
std::fs::write(&path, b"0123456789").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
daemon.drained()
|
||||||
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
|
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
|
||||||
}));
|
}));
|
||||||
daemon.client.set_online(false);
|
daemon.client.set_online(false);
|
||||||
@@ -2810,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 {
|
||||||
@@ -2918,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) {
|
||||||
@@ -3090,4 +3731,192 @@ 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 song with no picture in its tags shows the cover image in its folder,
|
||||||
|
/// and the daemon tells a following front-end that a thumbnail landed, so
|
||||||
|
/// a folder on screen fills in without being opened again.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_song_without_a_cover_shows_its_folders_and_says_so() {
|
||||||
|
let dir = scratch("song-cover");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
// Silent MPEG-1 Layer III frames, without any tag.
|
||||||
|
let mut frame = vec![0u8; 417];
|
||||||
|
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x64]);
|
||||||
|
let song = frame.repeat(64);
|
||||||
|
let mut cover = Vec::new();
|
||||||
|
image::codecs::jpeg::JpegEncoder::new(&mut cover)
|
||||||
|
.encode_image(&image::RgbImage::new(300, 300))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
drive.device().write("track one.mp3", &song);
|
||||||
|
drive.device().write("Cover.jpg", &cover);
|
||||||
|
let client = drive.client(1, Faults::lan());
|
||||||
|
let daemon = Daemon::start(&dir.join("daemon"), client).unwrap();
|
||||||
|
|
||||||
|
let landed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let mut subscription = control::subscribe(&daemon.socket).unwrap();
|
||||||
|
let flag = landed.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// The first change names every topic: a new subscription catches up.
|
||||||
|
let mut caught_up = false;
|
||||||
|
while let Ok(Some(event)) = subscription.next_event() {
|
||||||
|
if let control::Event::Changed { topics } = event {
|
||||||
|
if caught_up && topics.contains(&control::Topic::Thumbnails) {
|
||||||
|
flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
caught_up = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir {
|
||||||
|
path: String::new(),
|
||||||
|
}) else {
|
||||||
|
panic!("listing");
|
||||||
|
};
|
||||||
|
let song = entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.name == "track one.mp3")
|
||||||
|
.expect("the song is listed");
|
||||||
|
let items = vec![control::FileThumbRequest {
|
||||||
|
uid: song.uid.clone(),
|
||||||
|
modified: song.modified,
|
||||||
|
name: song.name.clone(),
|
||||||
|
}];
|
||||||
|
let Ok(Response::FileThumbGeneration { generation }) =
|
||||||
|
daemon.request(&Request::ReserveFileThumbGeneration)
|
||||||
|
else {
|
||||||
|
panic!("generation");
|
||||||
|
};
|
||||||
|
let mut path = None;
|
||||||
|
let settled = wait_until(Duration::from_secs(60), || {
|
||||||
|
let Ok(Response::Thumbs { items: replies }) = daemon.request(&Request::FileThumbs {
|
||||||
|
items: items.clone(),
|
||||||
|
generation,
|
||||||
|
}) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
path = replies.first().and_then(|reply| reply.path.clone());
|
||||||
|
path.is_some()
|
||||||
|
});
|
||||||
|
assert!(settled, "the song got no thumbnail");
|
||||||
|
let thumb = image::load_from_memory(&std::fs::read(path.unwrap()).unwrap()).unwrap();
|
||||||
|
assert_eq!(thumb.width(), thumb.height(), "the square cover");
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(15), || landed
|
||||||
|
.load(std::sync::atomic::Ordering::SeqCst)),
|
||||||
|
"no thumbnails change was published"
|
||||||
|
);
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A video in My files gets a thumbnail from a frame of its own, but a
|
||||||
|
/// recording can be gigabytes: only its start, and an MP4's index, are
|
||||||
|
/// downloaded for it. A video with a thumbnail from its uploader is not
|
||||||
|
/// downloaded at all, and a `.ts` that is TypeScript gets no thumbnail.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_video_thumbnail_downloads_a_small_part_of_the_video() {
|
||||||
|
if std::process::Command::new("ffmpeg")
|
||||||
|
.arg("-version")
|
||||||
|
.output()
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
eprintln!("skipped: needs ffmpeg");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = scratch("video-thumbs");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
// Twenty seconds at 30 Mbit/s: larger than a thumbnail downloads whole.
|
||||||
|
// An MP4 written this way has its index at the end.
|
||||||
|
let encode = |name: &str| {
|
||||||
|
let path = dir.join(name);
|
||||||
|
let status = std::process::Command::new("ffmpeg")
|
||||||
|
.args(["-nostdin", "-loglevel", "error", "-y", "-f", "lavfi"])
|
||||||
|
.args(["-i", "testsrc2=size=1280x720:rate=30", "-t", "20"])
|
||||||
|
.args(["-c:v", "libx264", "-preset", "ultrafast"])
|
||||||
|
.args(["-b:v", "30M", "-maxrate", "30M", "-bufsize", "30M"])
|
||||||
|
.arg(&path)
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success());
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
assert!(bytes.len() > 30 << 20, "{name} is {} bytes", bytes.len());
|
||||||
|
bytes
|
||||||
|
};
|
||||||
|
let (recording, clip) = (encode("recording.ts"), encode("clip.mp4"));
|
||||||
|
let source = b"export const answer = 42;\n".repeat(64 * 1024);
|
||||||
|
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
drive.device().write("recording.ts", &recording);
|
||||||
|
drive.device().write("clip.mp4", &clip);
|
||||||
|
drive.device().write("app.ts", &source);
|
||||||
|
let phone = drive.device().write("phone.mp4", &clip);
|
||||||
|
let mut frame = Vec::new();
|
||||||
|
image::codecs::jpeg::JpegEncoder::new(&mut frame)
|
||||||
|
.encode_image(&image::RgbImage::new(320, 180))
|
||||||
|
.unwrap();
|
||||||
|
let client = drive.client(1, Faults::lan());
|
||||||
|
client.set_thumbnail(&phone, frame);
|
||||||
|
let daemon = Daemon::start(&dir.join("daemon"), client.clone()).unwrap();
|
||||||
|
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir {
|
||||||
|
path: String::new(),
|
||||||
|
}) else {
|
||||||
|
panic!("listing");
|
||||||
|
};
|
||||||
|
let items: Vec<control::FileThumbRequest> = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| !entry.is_dir)
|
||||||
|
.map(|entry| control::FileThumbRequest {
|
||||||
|
uid: entry.uid.clone(),
|
||||||
|
modified: entry.modified,
|
||||||
|
name: entry.name.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let Ok(Response::FileThumbGeneration { generation }) =
|
||||||
|
daemon.request(&Request::ReserveFileThumbGeneration)
|
||||||
|
else {
|
||||||
|
panic!("generation");
|
||||||
|
};
|
||||||
|
let read_before = client.content_read();
|
||||||
|
|
||||||
|
let mut paths = std::collections::HashMap::new();
|
||||||
|
let settled = wait_until(Duration::from_secs(120), || {
|
||||||
|
let Ok(Response::Thumbs { items: replies }) = daemon.request(&Request::FileThumbs {
|
||||||
|
items: items.clone(),
|
||||||
|
generation,
|
||||||
|
}) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
paths = replies
|
||||||
|
.iter()
|
||||||
|
.filter_map(|reply| {
|
||||||
|
let name = &items.iter().find(|item| item.uid == reply.uid)?.name;
|
||||||
|
Some((name.clone(), (reply.path.clone(), reply.pending)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
paths.values().all(|(_, pending)| !pending) && paths.len() == 4
|
||||||
|
});
|
||||||
|
assert!(settled, "{paths:?}");
|
||||||
|
let read = client.content_read() - read_before;
|
||||||
|
|
||||||
|
for name in ["recording.ts", "clip.mp4", "phone.mp4"] {
|
||||||
|
assert!(paths[name].0.is_some(), "{name} has a thumbnail: {paths:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(paths["app.ts"].0, None, "TypeScript is no video");
|
||||||
|
// Two 16 MiB heads, one MP4 index and the TypeScript file, and nothing
|
||||||
|
// of the video that came with a thumbnail.
|
||||||
|
assert!(
|
||||||
|
read < 34 << 20,
|
||||||
|
"downloaded {} MiB for three thumbnails",
|
||||||
|
read >> 20
|
||||||
|
);
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ use proton_drive_rs::proton_sdk::ids::{
|
|||||||
};
|
};
|
||||||
use proton_drive_rs::{
|
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) {
|
||||||
@@ -2867,7 +2884,7 @@ mod tests {
|
|||||||
/// from being read as "the user deleted everything" and trashing the folder
|
/// from being read as "the user deleted everything" and trashing the folder
|
||||||
/// on Drive.
|
/// on Drive.
|
||||||
#[test]
|
#[test]
|
||||||
fn guard_local_wipe_blocks_only_a_total_disappearance() {
|
fn guard_local_wipe_blocks_a_total_disappearance() {
|
||||||
let base: HashMap<String, ()> = ["a.txt", "b.txt", "d/c.txt"]
|
let base: HashMap<String, ()> = ["a.txt", "b.txt", "d/c.txt"]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| ((*r).to_string(), ()))
|
.map(|r| ((*r).to_string(), ()))
|
||||||
@@ -2881,7 +2898,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
guard_local_wipe(&base, &one).is_ok(),
|
guard_local_wipe(&base, &one).is_ok(),
|
||||||
"one survivor means the tree is readable; the rest are real deletions"
|
"in a small folder, one survivor means the tree is readable; the rest are real deletions"
|
||||||
);
|
);
|
||||||
// A one-entry folder can disappear because its mount or parent became
|
// A one-entry folder can disappear because its mount or parent became
|
||||||
// unavailable just as easily as a larger tree can.
|
// unavailable just as easily as a larger tree can.
|
||||||
|
|||||||
@@ -180,18 +180,42 @@ pub(super) fn local_deletions<B, L>(
|
|||||||
missing
|
missing
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refuse to run a pass whose local side has vanished in its entirety.
|
/// How many tracked paths a folder needs before a mass disappearance, short of
|
||||||
|
/// a total one, trips a wipe guard. Below this, a few survivors say little.
|
||||||
|
const MASS_LOSS_MIN_PATHS: usize = 20;
|
||||||
|
|
||||||
|
/// The share of a large folder's tracked paths that may vanish in one pass, in
|
||||||
|
/// tenths, before a wipe guard stops it: 9 of 10 and up is refused.
|
||||||
|
const MASS_LOSS_TENTHS: usize = 9;
|
||||||
|
|
||||||
|
/// Whether `missing` of `total` tracked paths gone at once looks like a lost
|
||||||
|
/// tree rather than deletions. All of them always does. Most of a large
|
||||||
|
/// folder does too: one surviving file used to clear the guard, so a
|
||||||
|
/// half-mounted disk, or a script that emptied the folder and wrote one file
|
||||||
|
/// back, trashed everything else on Drive (B270).
|
||||||
|
fn looks_like_a_wipe(missing: usize, total: usize) -> bool {
|
||||||
|
total > 0
|
||||||
|
&& (missing == total
|
||||||
|
|| (total >= MASS_LOSS_MIN_PATHS && missing * 10 >= total * MASS_LOSS_TENTHS))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse to run a pass whose local side has vanished in its entirety, or
|
||||||
|
/// nearly so ([`looks_like_a_wipe`]).
|
||||||
///
|
///
|
||||||
/// When every baseline path is absent locally, the likely cause is an unavailable
|
/// When the synced paths are all or nearly all absent locally, the likely
|
||||||
/// mount or unreadable folder rather than a deliberate whole-tree deletion.
|
/// cause is an unavailable mount or unreadable folder rather than a deliberate
|
||||||
/// One surviving path is enough to clear the guard.
|
/// deletion of the whole tree.
|
||||||
pub(super) fn guard_local_wipe<B, L>(
|
pub(super) fn guard_local_wipe<B, L>(
|
||||||
baseline: &HashMap<String, B>,
|
baseline: &HashMap<String, B>,
|
||||||
local: &HashMap<String, L>,
|
local: &HashMap<String, L>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
|
let missing = baseline
|
||||||
|
.keys()
|
||||||
|
.filter(|rel| !local.contains_key(*rel))
|
||||||
|
.count();
|
||||||
|
if looks_like_a_wipe(missing, baseline.len()) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"every one of the {} synced paths is missing locally; refusing to trash \
|
"{missing} of the {} synced paths are missing locally; refusing to trash \
|
||||||
them on Drive. Check that the folder is mounted and readable; to download \
|
them on Drive. Check that the folder is mounted and readable; to download \
|
||||||
them again, sync the folder from scratch.",
|
them again, sync the folder from scratch.",
|
||||||
baseline.len()
|
baseline.len()
|
||||||
@@ -200,20 +224,24 @@ pub(super) fn guard_local_wipe<B, L>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refuse to run a pass whose remote side has vanished in its entirety: the
|
/// Refuse to run a pass whose remote side has vanished in its entirety, or
|
||||||
/// remote twin of [`guard_local_wipe`].
|
/// nearly so: the remote twin of [`guard_local_wipe`].
|
||||||
///
|
///
|
||||||
/// An empty or truncated remote walk reads to the planner as "Drive deleted
|
/// An empty or truncated remote walk reads to the planner as "Drive deleted
|
||||||
/// everything", which a pass answers by deleting the local copies. When not
|
/// everything", which a pass answers by deleting the local copies. When all or
|
||||||
/// one synced path is left on Drive, a failed listing is far likelier than a
|
/// nearly all synced paths are gone from Drive at once, a failed listing is far
|
||||||
/// deliberate wipe of the whole folder, so the pass stops instead.
|
/// likelier than a deliberate wipe of the folder, so the pass stops instead.
|
||||||
pub(super) fn guard_remote_wipe<B, R>(
|
pub(super) fn guard_remote_wipe<B, R>(
|
||||||
baseline: &HashMap<String, B>,
|
baseline: &HashMap<String, B>,
|
||||||
remote: &HashMap<String, R>,
|
remote: &HashMap<String, R>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if !baseline.is_empty() && baseline.keys().all(|rel| !remote.contains_key(rel)) {
|
let missing = baseline
|
||||||
|
.keys()
|
||||||
|
.filter(|rel| !remote.contains_key(*rel))
|
||||||
|
.count();
|
||||||
|
if looks_like_a_wipe(missing, baseline.len()) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"every one of the {} synced paths is missing on Proton Drive; refusing to \
|
"{missing} of the {} synced paths are missing on Proton Drive; refusing to \
|
||||||
delete them here. If they were deleted on purpose, remove the folder from sync; \
|
delete them here. If they were deleted on purpose, remove the folder from sync; \
|
||||||
to upload them again, sync the folder from scratch.",
|
to upload them again, sync the folder from scratch.",
|
||||||
baseline.len()
|
baseline.len()
|
||||||
@@ -816,7 +844,7 @@ mod file_plan_tests {
|
|||||||
/// An empty or failed remote listing must not read as "Drive deleted
|
/// An empty or failed remote listing must not read as "Drive deleted
|
||||||
/// everything" and delete the local copies.
|
/// everything" and delete the local copies.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_remote_wipe_guard_blocks_only_a_total_disappearance() {
|
fn the_remote_wipe_guard_blocks_a_total_disappearance() {
|
||||||
let base = HashMap::from([("a".to_string(), ()), ("b".to_string(), ())]);
|
let base = HashMap::from([("a".to_string(), ()), ("b".to_string(), ())]);
|
||||||
let none: HashMap<String, RemoteItem> = HashMap::new();
|
let none: HashMap<String, RemoteItem> = HashMap::new();
|
||||||
assert!(guard_remote_wipe(&base, &none).is_err());
|
assert!(guard_remote_wipe(&base, &none).is_err());
|
||||||
@@ -826,6 +854,37 @@ mod file_plan_tests {
|
|||||||
assert!(guard_remote_wipe(&empty, &none).is_ok());
|
assert!(guard_remote_wipe(&empty, &none).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One path left of a large folder no longer clears either guard: most
|
||||||
|
/// of it gone at once is a lost tree, not a cleanup (B270).
|
||||||
|
#[test]
|
||||||
|
fn one_survivor_of_a_large_folder_does_not_clear_the_wipe_guards() {
|
||||||
|
let base: HashMap<String, ()> = (0..40).map(|n| (format!("f{n}.txt"), ())).collect();
|
||||||
|
let survivors = |n: usize| -> HashMap<String, ()> {
|
||||||
|
(0..n).map(|n| (format!("f{n}.txt"), ())).collect()
|
||||||
|
};
|
||||||
|
assert!(guard_local_wipe(&base, &survivors(1)).is_err());
|
||||||
|
assert!(guard_remote_wipe(&base, &survivors(1)).is_err());
|
||||||
|
// 36 of 40 gone is nine tenths: still refused.
|
||||||
|
assert!(guard_local_wipe(&base, &survivors(4)).is_err());
|
||||||
|
// 35 of 40 gone is under nine tenths: a pass may delete that much.
|
||||||
|
assert!(guard_local_wipe(&base, &survivors(5)).is_ok());
|
||||||
|
assert!(guard_remote_wipe(&base, &survivors(5)).is_ok());
|
||||||
|
let reason = guard_local_wipe(&base, &survivors(1)).unwrap_err();
|
||||||
|
assert!(reason.starts_with("39 of the 40 synced paths"), "{reason}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small folder still loses most of its files to an ordinary cleanup, so
|
||||||
|
/// only a total loss stops it.
|
||||||
|
#[test]
|
||||||
|
fn a_small_folder_trips_the_wipe_guards_only_when_everything_is_gone() {
|
||||||
|
let base: HashMap<String, ()> = (0..19).map(|n| (format!("f{n}.txt"), ())).collect();
|
||||||
|
let one: HashMap<String, ()> = [("f0.txt".to_string(), ())].into_iter().collect();
|
||||||
|
assert!(guard_local_wipe(&base, &one).is_ok());
|
||||||
|
assert!(guard_remote_wipe(&base, &one).is_ok());
|
||||||
|
let none: HashMap<String, ()> = HashMap::new();
|
||||||
|
assert!(guard_local_wipe(&base, &none).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
/// A node that failed to decrypt has no name, so everything a short
|
/// A node that failed to decrypt has no name, so everything a short
|
||||||
/// listing did not return is left out, and nothing else.
|
/// listing did not return is left out, and nothing else.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
+824
-163
File diff suppressed because it is too large.
Load diff
@@ -40,4 +40,4 @@ pangocairo = "0.22"
|
|||||||
# Map tiles for the opt-in online map. The same build proton-sdk already pulls in.
|
# Map tiles for the opt-in online map. The same build proton-sdk already pulls in.
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots"] }
|
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots"] }
|
||||||
# The system libintl (glibc) rather than a bundled gettext build.
|
# The system libintl (glibc) rather than a bundled gettext build.
|
||||||
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
gettext-rs = { version = "0.8", features = ["gettext-system"] }
|
||||||
@@ -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,28 @@ 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); }
|
||||||
|
.media-player-note .file-thumbnail { border-radius: 12px; }
|
||||||
|
.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 +94,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 +113,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 +123,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,22 @@ 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() {
|
// On any page: a grid, a list or a player may show a tile still blank.
|
||||||
Some("browser") if changed(Topic::Files) => load_browser(ui),
|
if changed(Topic::Thumbnails) {
|
||||||
|
refresh_file_thumbnails(ui);
|
||||||
|
}
|
||||||
|
let page = ui.stack.visible_child_name();
|
||||||
|
// 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")
|
||||||
|
|||||||
+185
-26
@@ -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,15 +73,15 @@ 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,
|
||||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, LinkChange, MappingProgress,
|
DirEntry, ErrorKind, ImportPhase, ImportProgress, ImportSummary, InvitationInfo, JobItem,
|
||||||
PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope,
|
LinkChange, MappingProgress, PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo,
|
||||||
Request, Response, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry,
|
PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, RestoreItem, RevisionInfo,
|
||||||
ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress,
|
SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase,
|
||||||
ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
|
SyncProgress, ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
|
||||||
};
|
};
|
||||||
|
|
||||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
@@ -125,6 +127,9 @@ struct Ui {
|
|||||||
/// Whether this run already asked for the mailbox password of a locked
|
/// Whether this run already asked for the mailbox password of a locked
|
||||||
/// account, so a refresh never re-opens a dialog the user cancelled.
|
/// account, so a refresh never re-opens a dialog the user cancelled.
|
||||||
unlock_offered: Cell<bool>,
|
unlock_offered: Cell<bool>,
|
||||||
|
/// The `since` of the run of mount failures whose "folder isn't empty"
|
||||||
|
/// dialog this run already showed, so a page reload never shows it again.
|
||||||
|
not_empty_offered: Cell<i64>,
|
||||||
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
|
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
|
||||||
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
||||||
mounted: RefCell<bool>,
|
mounted: RefCell<bool>,
|
||||||
@@ -403,6 +408,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
opening: RefCell::new(HashSet::new()),
|
opening: RefCell::new(HashSet::new()),
|
||||||
session: RefCell::new(startup_session),
|
session: RefCell::new(startup_session),
|
||||||
unlock_offered: Cell::new(false),
|
unlock_offered: Cell::new(false),
|
||||||
|
not_empty_offered: Cell::new(0),
|
||||||
mounted: RefCell::new(false),
|
mounted: RefCell::new(false),
|
||||||
sidebar: sidebar_list.clone(),
|
sidebar: sidebar_list.clone(),
|
||||||
nav: split.clone(),
|
nav: split.clone(),
|
||||||
@@ -453,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(),
|
||||||
@@ -484,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,
|
||||||
@@ -518,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(),
|
||||||
@@ -583,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),
|
||||||
@@ -655,9 +668,11 @@ fn build_window(app: &adw::Application) {
|
|||||||
progress_bar: takeout_widgets.progress_bar.clone(),
|
progress_bar: takeout_widgets.progress_bar.clone(),
|
||||||
summary_group: takeout_widgets.summary_group.clone(),
|
summary_group: takeout_widgets.summary_group.clone(),
|
||||||
summary_rows: RefCell::new(Vec::new()),
|
summary_rows: RefCell::new(Vec::new()),
|
||||||
|
resume_group: takeout_widgets.resume_group.clone(),
|
||||||
|
resume_row: takeout_widgets.resume_row.clone(),
|
||||||
|
resume_button: takeout_widgets.resume_button.clone(),
|
||||||
inflight: Cell::new(false),
|
inflight: Cell::new(false),
|
||||||
running: Cell::new(false),
|
running: Cell::new(false),
|
||||||
dry_run: Cell::new(false),
|
|
||||||
},
|
},
|
||||||
update,
|
update,
|
||||||
});
|
});
|
||||||
@@ -689,7 +704,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,
|
||||||
@@ -718,17 +733,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),
|
||||||
@@ -791,6 +815,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);
|
||||||
}
|
}
|
||||||
@@ -854,11 +879,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"),
|
||||||
@@ -1061,7 +1082,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.
|
||||||
@@ -1323,7 +1344,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);
|
||||||
@@ -1501,7 +1522,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"),
|
||||||
&[
|
&[
|
||||||
@@ -1519,6 +1540,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")),
|
||||||
@@ -1531,6 +1555,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")),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1540,8 +1565,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")),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -1567,8 +1604,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.
|
||||||
@@ -1584,6 +1630,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 {
|
||||||
@@ -1610,6 +1671,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) {
|
||||||
@@ -1807,6 +1883,23 @@ pub(crate) fn service_unreachable(
|
|||||||
.ok()
|
.ok()
|
||||||
.flatten();
|
.flatten();
|
||||||
match failure {
|
match failure {
|
||||||
|
Some(failure) if failure.not_empty.is_some() => {
|
||||||
|
let found = failure.not_empty.as_ref().expect("checked above");
|
||||||
|
status(
|
||||||
|
&ui,
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
&gettext("Can't open your Drive"),
|
||||||
|
// Translators: {path} is the folder My files is mounted at.
|
||||||
|
&gettext_f(
|
||||||
|
"“{path}” already has files in it, and My files would hide them. Proton Drive tries again on its own once the folder is empty. You can also choose another folder for My files in Preferences.",
|
||||||
|
&[("path", &found.path.display().to_string())],
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if ui.not_empty_offered.replace(failure.since) != failure.since {
|
||||||
|
offer_not_empty(&ui, found);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(failure) => status(
|
Some(failure) => status(
|
||||||
&ui,
|
&ui,
|
||||||
"dialog-warning-symbolic",
|
"dialog-warning-symbolic",
|
||||||
@@ -1834,6 +1927,37 @@ pub(crate) fn service_unreachable(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tell the user the daemon will not mount My files over a folder with files
|
||||||
|
/// in it (B277), and offer the two ways out: open the folder to move them, or
|
||||||
|
/// choose another one.
|
||||||
|
fn offer_not_empty(ui: &Rc<Ui>, found: &pdfs_core::mountpoint::NotEmpty) {
|
||||||
|
let path = found.path.display().to_string();
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Your Proton Drive Folder Isn't Empty"))
|
||||||
|
// Translators: {path} is the folder My files is mounted at; {n} is how
|
||||||
|
// many files and folders are already in it.
|
||||||
|
.body(ngettext_f(
|
||||||
|
"“{path}” already has {n} item in it. My files would hide it, so Proton Drive does not open there. Move the item to another folder, or choose another folder for My files.",
|
||||||
|
"“{path}” already has {n} items in it. My files would hide them, so Proton Drive does not open there. Move the items to another folder, or choose another folder for My files.",
|
||||||
|
found.count as u64,
|
||||||
|
&[("path", &path)],
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("close", &gettext("Not Now"));
|
||||||
|
dialog.add_response("open", &gettext("Open Folder"));
|
||||||
|
dialog.add_response("choose", &gettext("Choose Another Folder…"));
|
||||||
|
dialog.set_response_appearance("choose", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("choose"));
|
||||||
|
dialog.set_close_response("close");
|
||||||
|
let ui_response = ui.clone();
|
||||||
|
dialog.connect_response(None, move |_, response| match response {
|
||||||
|
"open" => open_path(&path),
|
||||||
|
"choose" => prompt_mountpoint(&ui_response),
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
dialog.present(ui_window(ui).as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
/// How long a copy button shows its check after copying.
|
/// How long a copy button shows its check after copying.
|
||||||
const COPIED_FEEDBACK: Duration = Duration::from_millis(1500);
|
const COPIED_FEEDBACK: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
@@ -1903,6 +2027,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1925,6 +2050,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);
|
||||||
|
if !attached {
|
||||||
dialog.set_extra_child(Some(&group));
|
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
|
||||||
|
|||||||
+1346
-231
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
|
||||||
|
|||||||
@@ -806,7 +806,8 @@ pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Prompt for a new mountpoint folder, persist it to config, and offer to restart
|
/// Prompt for a new mountpoint folder, persist it to config, and offer to restart
|
||||||
/// the mount service so the daemon picks it up.
|
/// the mount service so the daemon picks it up. A folder with files in it is
|
||||||
|
/// refused: the mount would hide them (B277).
|
||||||
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
let dialog = gtk4::FileDialog::builder()
|
let dialog = gtk4::FileDialog::builder()
|
||||||
@@ -816,20 +817,64 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
|||||||
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
|
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||||
let Ok(folder) = res else { return };
|
let Ok(folder) = res else { return };
|
||||||
let Some(path) = folder.path() else { return };
|
let Some(path) = folder.path() else { return };
|
||||||
|
// The folder My files is mounted at lists Drive's files, not its own,
|
||||||
|
// so picking it again is not checked.
|
||||||
|
if path == ui.dirs.resolved_mountpoint(&ui.dirs.load_config()) {
|
||||||
|
use_mountpoint(&ui, &path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let checked = path.clone();
|
||||||
|
let found = gio::spawn_blocking(move || pdfs_core::mountpoint::not_empty(&checked))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| Err(std::io::Error::other("the check did not finish")));
|
||||||
|
match found {
|
||||||
|
Ok(None) => use_mountpoint(&ui, &path),
|
||||||
|
Ok(Some(found)) => refuse_mountpoint(&ui, &found),
|
||||||
|
Err(e) => toast_error(&ui, &gettext("Couldn't read the folder"), &e.to_string()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explain why the folder `found` cannot hold My files.
|
||||||
|
fn refuse_mountpoint(ui: &Rc<Ui>, found: &pdfs_core::mountpoint::NotEmpty) {
|
||||||
|
let path = found.path.display().to_string();
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Choose an Empty Folder"))
|
||||||
|
// Translators: {path} is the folder the user picked; {n} is how many
|
||||||
|
// files and folders are already in it.
|
||||||
|
.body(ngettext_f(
|
||||||
|
"“{path}” already has {n} item in it. My files would hide it while Proton Drive runs, and it would not be uploaded.",
|
||||||
|
"“{path}” already has {n} items in it. My files would hide them while Proton Drive runs, and they would not be uploaded.",
|
||||||
|
found.count as u64,
|
||||||
|
&[("path", &path)],
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("close", &gettext("Close"));
|
||||||
|
dialog.set_default_response(Some("close"));
|
||||||
|
dialog.set_close_response("close");
|
||||||
|
dialog.present(ui_window(ui).as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `path` the folder My files is mounted at, and offer the restart that
|
||||||
|
/// moves it there.
|
||||||
|
fn use_mountpoint(ui: &Rc<Ui>, path: &std::path::Path) {
|
||||||
let path_str = path.display().to_string();
|
let path_str = path.display().to_string();
|
||||||
|
|
||||||
// Persist the choice to config so the next mount uses it.
|
// Persist the choice to config so the next mount uses it.
|
||||||
let mut config = ui.dirs.load_config();
|
let mut config = ui.dirs.load_config();
|
||||||
config.mountpoint = Some(path_str.clone());
|
config.mountpoint = Some(path_str.clone());
|
||||||
if let Err(e) = ui.dirs.save_config(&config) {
|
if let Err(e) = ui.dirs.save_config(&config) {
|
||||||
toast_error(&ui, &gettext("Couldn't save the folder"), &e.to_string());
|
toast_error(ui, &gettext("Couldn't save the folder"), &e.to_string());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.status.mountpoint_row.set_subtitle(&path_str);
|
ui.status.mountpoint_row.set_subtitle(&path_str);
|
||||||
// The Locations page shows the same path as a row title. Any other page
|
// The Locations page shows the same path as a row title. Any other page
|
||||||
// reads it again on the next visit.
|
// reads it again on the next visit.
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
||||||
load_locations(&ui);
|
load_locations(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
||||||
@@ -852,8 +897,7 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
|||||||
restart_service_then(&ui_restart, |_| {});
|
restart_service_then(&ui_restart, |_| {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
confirm.present(ui_window(&ui).as_ref());
|
confirm.present(ui_window(ui).as_ref());
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] /
|
/// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] /
|
||||||
@@ -1071,9 +1115,6 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
|
|||||||
// count falling to zero is what "the sync finished" looks like from here.
|
// count falling to zero is what "the sync finished" looks like from here.
|
||||||
// Jobs are deliberately not counted — a bulk upload retires its scan job and
|
// Jobs are deliberately not counted — a bulk upload retires its scan job and
|
||||||
// starts its upload job mid-flight, which is not a thing finishing.
|
// starts its upload job mid-flight, which is not a thing finishing.
|
||||||
// The Import page reads its progress off the same snapshot rather than
|
|
||||||
// polling the daemon a second time for numbers already on the wire.
|
|
||||||
takeout_progress(ui, jobs);
|
|
||||||
|
|
||||||
let previous = ui.status.active_transfers.replace(items.len());
|
let previous = ui.status.active_transfers.replace(items.len());
|
||||||
if !items.is_empty() {
|
if !items.is_empty() {
|
||||||
@@ -1167,13 +1208,18 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
|
|||||||
pub(crate) fn job_line(j: &JobItem) -> ActivityLine {
|
pub(crate) fn job_line(j: &JobItem) -> ActivityLine {
|
||||||
let done = j.done.to_string();
|
let done = j.done.to_string();
|
||||||
let total = j.total.to_string();
|
let total = j.total.to_string();
|
||||||
|
let (title, detail) = if j.title == IMPORT_JOB {
|
||||||
|
import_job_text(&j.detail)
|
||||||
|
} else {
|
||||||
|
(j.title.clone(), j.detail.clone())
|
||||||
|
};
|
||||||
let args: [(&str, &str); 4] = [
|
let args: [(&str, &str); 4] = [
|
||||||
("title", &j.title),
|
("title", &title),
|
||||||
("detail", &j.detail),
|
("detail", &detail),
|
||||||
("done", &done),
|
("done", &done),
|
||||||
("total", &total),
|
("total", &total),
|
||||||
];
|
];
|
||||||
let text = match (j.total > 0, j.detail.is_empty()) {
|
let text = match (j.total > 0, detail.is_empty()) {
|
||||||
// Translators: a background job's progress. {title} is the job, such as
|
// Translators: a background job's progress. {title} is the job, such as
|
||||||
// "Scanning Photos"; {done} and {total} are counts.
|
// "Scanning Photos"; {done} and {total} are counts.
|
||||||
(true, true) => gettext_f("{title} — {done} of {total}", &args),
|
(true, true) => gettext_f("{title} — {done} of {total}", &args),
|
||||||
@@ -1343,12 +1389,39 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
|||||||
/// read-out so the page doesn't flicker on a blip.
|
/// read-out so the page doesn't flicker on a blip.
|
||||||
pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
||||||
set_mounted(ui, false);
|
set_mounted(ui, false);
|
||||||
paint_sync_status(ui, SyncState::Disconnected);
|
|
||||||
for r in ui.status.pin_rows.borrow().iter() {
|
for r in ui.status.pin_rows.borrow().iter() {
|
||||||
if let Some(b) = &r.unpin {
|
if let Some(b) = &r.unpin {
|
||||||
b.set_sensitive(false);
|
b.set_sensitive(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A running daemon whose mount fails on every try never answers either,
|
||||||
|
// and "isn't running" would be wrong for it (B263, B277). The pages read
|
||||||
|
// the same failure record.
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let failure = if service_down().await {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let state_dir = ui.dirs.state_dir();
|
||||||
|
gio::spawn_blocking(move || pdfs_core::mountfail::read(&state_dir))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
};
|
||||||
|
// The daemon may have answered while this looked.
|
||||||
|
if *ui.mounted.borrow() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
paint_sync_status(
|
||||||
|
&ui,
|
||||||
|
match failure {
|
||||||
|
Some(failure) => SyncState::CannotOpen {
|
||||||
|
not_empty: failure.not_empty.is_some(),
|
||||||
|
},
|
||||||
|
None => SyncState::Disconnected,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the sidebar's status strip reports, most urgent first.
|
/// What the sidebar's status strip reports, most urgent first.
|
||||||
@@ -1392,6 +1465,11 @@ pub(crate) enum SyncState {
|
|||||||
},
|
},
|
||||||
/// No mount daemon answered: still starting, or the service is down.
|
/// No mount daemon answered: still starting, or the service is down.
|
||||||
Disconnected,
|
Disconnected,
|
||||||
|
/// The service runs, but its mount fails on every try. `not_empty` when
|
||||||
|
/// it refuses a folder that already has files in it.
|
||||||
|
CannotOpen {
|
||||||
|
not_empty: bool,
|
||||||
|
},
|
||||||
/// The daemon is running but didn't answer in time.
|
/// The daemon is running but didn't answer in time.
|
||||||
NotResponding,
|
NotResponding,
|
||||||
}
|
}
|
||||||
@@ -1400,7 +1478,10 @@ pub(crate) enum SyncState {
|
|||||||
/// one-line state, and a detail line.
|
/// one-line state, and a detail line.
|
||||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||||
let paused = matches!(state, SyncState::Paused { .. });
|
let paused = matches!(state, SyncState::Paused { .. });
|
||||||
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
let connected = !matches!(
|
||||||
|
state,
|
||||||
|
SyncState::Disconnected | SyncState::CannotOpen { .. } | SyncState::NotResponding
|
||||||
|
);
|
||||||
let expired = matches!(state, SyncState::SessionExpired { .. });
|
let expired = matches!(state, SyncState::SessionExpired { .. });
|
||||||
let outdated = matches!(state, SyncState::Outdated { .. });
|
let outdated = matches!(state, SyncState::Outdated { .. });
|
||||||
let (icon, class, title, detail) = match state {
|
let (icon, class, title, detail) = match state {
|
||||||
@@ -1492,6 +1573,16 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
|||||||
gettext("Not connected"),
|
gettext("Not connected"),
|
||||||
Some(gettext("Proton Drive isn't running")),
|
Some(gettext("Proton Drive isn't running")),
|
||||||
),
|
),
|
||||||
|
SyncState::CannotOpen { not_empty } => (
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
Some("warning"),
|
||||||
|
gettext("Can't open your Drive"),
|
||||||
|
Some(if not_empty {
|
||||||
|
gettext("Your Proton Drive folder isn't empty")
|
||||||
|
} else {
|
||||||
|
gettext("Proton Drive keeps trying to start")
|
||||||
|
}),
|
||||||
|
),
|
||||||
SyncState::NotResponding => (
|
SyncState::NotResponding => (
|
||||||
"dialog-warning-symbolic",
|
"dialog-warning-symbolic",
|
||||||
Some("warning"),
|
Some("warning"),
|
||||||
|
|||||||
@@ -15,17 +15,30 @@
|
|||||||
//! Import — staging is local, so a mis-drop costs nothing.
|
//! Import — staging is local, so a mis-drop costs nothing.
|
||||||
//!
|
//!
|
||||||
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
|
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
|
||||||
//! stages paths, launches, asks [`Request::ImportStatus`] for the final report,
|
//! stages paths, launches, and asks [`Request::ImportStatus`] for the step, the
|
||||||
//! and reads live progress off the import's job in the transfer snapshot the
|
//! running counts and the final report. It asks again whenever the import's job
|
||||||
//! daemon already pushes (see [`takeout_progress`]).
|
//! moves in the transfer snapshot the daemon already pushes. A run the service
|
||||||
|
//! did not get to finish can be picked up with [`Request::ResumeImport`].
|
||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
|
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
|
||||||
/// Matching on it is how this page borrows the progress the daemon already
|
/// Seeing it move is the cue to ask for the import's status; the Activity list
|
||||||
/// pushes instead of asking for it separately.
|
/// shows it under its translated name ([`import_job_text`]).
|
||||||
pub(crate) const IMPORT_JOB: &str = "Importing Google Photos";
|
pub(crate) const IMPORT_JOB: &str = "Importing Google Photos";
|
||||||
|
|
||||||
|
/// The import job's title and detail in the user's language. The daemon names
|
||||||
|
/// its steps in English; the file being uploaded passes through as it is.
|
||||||
|
pub(crate) fn import_job_text(detail: &str) -> (String, String) {
|
||||||
|
let detail = match detail {
|
||||||
|
"reading archives" => gettext("Reading the archives"),
|
||||||
|
"checking for photos you already have" => gettext("Checking for photos you already have"),
|
||||||
|
"sorting photos into albums" => gettext("Sorting photos into albums"),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
(gettext("Importing from Google Photos"), detail)
|
||||||
|
}
|
||||||
|
|
||||||
/// What the archive list says before anything is staged. `repaint_archives`
|
/// What the archive list says before anything is staged. `repaint_archives`
|
||||||
/// swaps in the count and total once there is something to report, and swaps
|
/// swaps in the count and total once there is something to report, and swaps
|
||||||
/// this back when the list is emptied again.
|
/// this back when the list is emptied again.
|
||||||
@@ -95,18 +108,19 @@ pub(crate) struct TakeoutState {
|
|||||||
pub(crate) progress_group: adw::PreferencesGroup,
|
pub(crate) progress_group: adw::PreferencesGroup,
|
||||||
pub(crate) progress_label: gtk4::Label,
|
pub(crate) progress_label: gtk4::Label,
|
||||||
pub(crate) progress_bar: gtk4::ProgressBar,
|
pub(crate) progress_bar: gtk4::ProgressBar,
|
||||||
/// Report group for the last finished run, hidden until there is one.
|
/// The running counts, or the report for the last finished run; hidden
|
||||||
|
/// until there is one.
|
||||||
pub(crate) summary_group: adw::PreferencesGroup,
|
pub(crate) summary_group: adw::PreferencesGroup,
|
||||||
pub(crate) summary_rows: RefCell<Vec<adw::ActionRow>>,
|
pub(crate) summary_rows: RefCell<Vec<adw::ActionRow>>,
|
||||||
|
/// Offers to resume an import the service did not get to finish.
|
||||||
|
pub(crate) resume_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) resume_row: adw::ActionRow,
|
||||||
|
pub(crate) resume_button: gtk4::Button,
|
||||||
/// One [`Request::ImportStatus`] in flight at a time, so a wedged daemon
|
/// One [`Request::ImportStatus`] in flight at a time, so a wedged daemon
|
||||||
/// can't stack worker threads on the 2s tick.
|
/// can't stack worker threads on the 2s tick.
|
||||||
pub(crate) inflight: Cell<bool>,
|
pub(crate) inflight: Cell<bool>,
|
||||||
/// Whether the daemon reported an import running on the last poll.
|
/// Whether the daemon reported an import running on the last poll.
|
||||||
pub(crate) running: Cell<bool>,
|
pub(crate) running: Cell<bool>,
|
||||||
/// Whether the run in flight was launched as a dry run, so the report can
|
|
||||||
/// say "would upload" rather than claiming photos were sent. The daemon's
|
|
||||||
/// summary carries no such flag — it is the same shape either way.
|
|
||||||
pub(crate) dry_run: Cell<bool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Widgets the Import page hands back for wiring.
|
/// Widgets the Import page hands back for wiring.
|
||||||
@@ -126,6 +140,9 @@ pub(crate) struct TakeoutWidgets {
|
|||||||
pub(crate) progress_label: gtk4::Label,
|
pub(crate) progress_label: gtk4::Label,
|
||||||
pub(crate) progress_bar: gtk4::ProgressBar,
|
pub(crate) progress_bar: gtk4::ProgressBar,
|
||||||
pub(crate) summary_group: adw::PreferencesGroup,
|
pub(crate) summary_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) resume_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) resume_row: adw::ActionRow,
|
||||||
|
pub(crate) resume_button: gtk4::Button,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||||
@@ -261,7 +278,23 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
|||||||
.visible(false)
|
.visible(false)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// An import cut short by a restart or a lost connection, offered back at the
|
||||||
|
// top of the page: picking the archives again is exactly what a reboot in
|
||||||
|
// the middle of a migration should not cost.
|
||||||
|
let resume_button = gtk4::Button::builder()
|
||||||
|
.label(pgettext("verb", "Resume"))
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
resume_button.add_css_class("suggested-action");
|
||||||
|
let resume_row = adw::ActionRow::builder()
|
||||||
|
.title(gettext("The last import didn't finish"))
|
||||||
|
.build();
|
||||||
|
resume_row.add_suffix(&resume_button);
|
||||||
|
let resume_group = adw::PreferencesGroup::builder().visible(false).build();
|
||||||
|
resume_group.add(&resume_row);
|
||||||
|
|
||||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||||
|
groups.append(&resume_group);
|
||||||
groups.append(&dropzone);
|
groups.append(&dropzone);
|
||||||
groups.append(&list_group);
|
groups.append(&list_group);
|
||||||
groups.append(&actions);
|
groups.append(&actions);
|
||||||
@@ -301,6 +334,9 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
|||||||
progress_label: progress_label.clone(),
|
progress_label: progress_label.clone(),
|
||||||
progress_bar: progress_bar.clone(),
|
progress_bar: progress_bar.clone(),
|
||||||
summary_group: summary_group.clone(),
|
summary_group: summary_group.clone(),
|
||||||
|
resume_group,
|
||||||
|
resume_row,
|
||||||
|
resume_button,
|
||||||
};
|
};
|
||||||
(frame.upcast(), widgets)
|
(frame.upcast(), widgets)
|
||||||
}
|
}
|
||||||
@@ -427,6 +463,28 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let ui_resume = ui.clone();
|
||||||
|
widgets.resume_button.connect_clicked(move |_| {
|
||||||
|
let ui = ui_resume.clone();
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ResumeImport);
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => import_started(&ui, false),
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, &gettext("Couldn't resume the import"), &message, kind);
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Couldn't resume the import"),
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
let ui_back = ui.clone();
|
let ui_back = ui.clone();
|
||||||
widgets
|
widgets
|
||||||
.back_button
|
.back_button
|
||||||
@@ -559,6 +617,7 @@ fn sync_takeout_actions(ui: &Rc<Ui>) {
|
|||||||
.import_button
|
.import_button
|
||||||
.set_sensitive(has_files && !running);
|
.set_sensitive(has_files && !running);
|
||||||
ui.takeout.clear_button.set_sensitive(has_files && !running);
|
ui.takeout.clear_button.set_sensitive(has_files && !running);
|
||||||
|
ui.takeout.resume_button.set_sensitive(!running);
|
||||||
ui.takeout.cancel_button.set_visible(running);
|
ui.takeout.cancel_button.set_visible(running);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,7 +636,6 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.takeout.dry_run.set(dry_run);
|
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
ui.dirs.control_socket(),
|
ui.dirs.control_socket(),
|
||||||
@@ -588,27 +646,7 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
|||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::Ok { .. })) => {
|
Ok(Ok(Response::Ok { .. })) => import_started(&ui, dry_run),
|
||||||
ui.takeout.running.set(true);
|
|
||||||
ui.gallery.import_banner.set_revealed(true);
|
|
||||||
ui.takeout.summary_group.set_visible(false);
|
|
||||||
ui.takeout.progress_group.set_visible(true);
|
|
||||||
ui.takeout.progress_bar.set_fraction(0.0);
|
|
||||||
ui.takeout.progress_label.set_text(&if dry_run {
|
|
||||||
gettext("Reading archives…")
|
|
||||||
} else {
|
|
||||||
gettext("Starting import…")
|
|
||||||
});
|
|
||||||
sync_takeout_actions(&ui);
|
|
||||||
toast(
|
|
||||||
&ui,
|
|
||||||
&if dry_run {
|
|
||||||
gettext("Scanning the export…")
|
|
||||||
} else {
|
|
||||||
gettext("Import started — you can leave this page, it keeps running.")
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, &gettext("Couldn't start the import"), &message, kind);
|
toast_failure(&ui, &gettext("Couldn't start the import"), &message, kind);
|
||||||
}
|
}
|
||||||
@@ -621,9 +659,39 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask the daemon for the import's state, and paint the report once a run has
|
/// Switch the page over to reporting progress once the daemon took the run.
|
||||||
/// finished. Asked on arrival and while import progress keeps arriving. Cheap
|
fn import_started(ui: &Rc<Ui>, dry_run: bool) {
|
||||||
/// and inflight-guarded.
|
ui.takeout.running.set(true);
|
||||||
|
if !dry_run {
|
||||||
|
ui.gallery.import_banner.set_revealed(true);
|
||||||
|
}
|
||||||
|
ui.takeout.resume_group.set_visible(false);
|
||||||
|
ui.takeout.summary_group.set_visible(false);
|
||||||
|
ui.takeout.progress_group.set_visible(true);
|
||||||
|
ui.takeout.progress_group.set_title(&if dry_run {
|
||||||
|
gettext("Scan in progress")
|
||||||
|
} else {
|
||||||
|
gettext("Import in progress")
|
||||||
|
});
|
||||||
|
ui.takeout.progress_bar.set_fraction(0.0);
|
||||||
|
ui.takeout
|
||||||
|
.progress_label
|
||||||
|
.set_text(&gettext("Reading the archives…"));
|
||||||
|
sync_takeout_actions(ui);
|
||||||
|
toast(
|
||||||
|
ui,
|
||||||
|
&if dry_run {
|
||||||
|
gettext("Scanning the export…")
|
||||||
|
} else {
|
||||||
|
gettext("Import started — you can leave this page, it keeps running.")
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the daemon for the import's state and paint it: the step and counts
|
||||||
|
/// while it runs, the report once it has ended, and the offer to resume a run
|
||||||
|
/// that did not finish. Asked on arrival and while import progress keeps
|
||||||
|
/// arriving. Cheap and inflight-guarded.
|
||||||
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
||||||
if ui.takeout.inflight.get() {
|
if ui.takeout.inflight.get() {
|
||||||
return;
|
return;
|
||||||
@@ -634,124 +702,250 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.takeout.inflight.set(false);
|
ui.takeout.inflight.set(false);
|
||||||
let Ok(Ok(Response::ImportStatus { running, summary })) = result else {
|
let Ok(Ok(Response::ImportStatus {
|
||||||
|
running,
|
||||||
|
summary,
|
||||||
|
progress,
|
||||||
|
resumable,
|
||||||
|
})) = result
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let was_running = ui.takeout.running.replace(running);
|
let was_running = ui.takeout.running.replace(running);
|
||||||
ui.gallery.import_banner.set_revealed(running);
|
let dry = summary.as_ref().is_some_and(|s| s.dry_run);
|
||||||
|
ui.gallery.import_banner.set_revealed(running && !dry);
|
||||||
|
ui.takeout.progress_group.set_visible(running);
|
||||||
if running {
|
if running {
|
||||||
ui.takeout.progress_group.set_visible(true);
|
ui.takeout.progress_group.set_title(&if dry {
|
||||||
|
gettext("Scan in progress")
|
||||||
} else {
|
} else {
|
||||||
ui.takeout.progress_group.set_visible(false);
|
gettext("Import in progress")
|
||||||
// An import runs for hours, so its ending is announced rather than
|
});
|
||||||
// left to be discovered: a toast for whoever is looking at the
|
paint_progress(&ui, progress.as_ref());
|
||||||
// window, and a desktop notification for whoever is not.
|
} else if was_running && let Some(summary) = &summary {
|
||||||
let failed = summary.as_ref().and_then(|s| s.error.clone());
|
announce_end(&ui, summary);
|
||||||
if was_running && let Some(error) = &failed {
|
|
||||||
let headline = gettext("Couldn't import from Google Photos");
|
|
||||||
toast_error(&ui, &headline, error);
|
|
||||||
notify("takeout-import", &headline, error);
|
|
||||||
} else if was_running {
|
|
||||||
let dry = ui.takeout.dry_run.get();
|
|
||||||
let headline = if dry {
|
|
||||||
gettext("Scan finished")
|
|
||||||
} else {
|
|
||||||
gettext("Import finished")
|
|
||||||
};
|
|
||||||
toast(&ui, &headline);
|
|
||||||
if !dry {
|
|
||||||
let body = match &summary {
|
|
||||||
// Translators: {n} is the number of photos uploaded, {failed} the number that failed.
|
|
||||||
Some(s) if s.failed > 0 => ngettext_f(
|
|
||||||
"{n} photo added, {failed} failed.",
|
|
||||||
"{n} photos added, {failed} failed.",
|
|
||||||
s.uploaded as u64,
|
|
||||||
&[("failed", &s.failed.to_string())],
|
|
||||||
),
|
|
||||||
Some(s) if s.cancelled => ngettext_f(
|
|
||||||
"Stopped after {n} photo.",
|
|
||||||
"Stopped after {n} photos.",
|
|
||||||
s.uploaded as u64,
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
Some(s) => ngettext_f(
|
|
||||||
"{n} photo added to Proton Photos.",
|
|
||||||
"{n} photos added to Proton Photos.",
|
|
||||||
s.uploaded as u64,
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
None => gettext("The Google Photos import has finished."),
|
|
||||||
};
|
|
||||||
notify("takeout-import", &headline, &body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if was_running != running {
|
if was_running != running {
|
||||||
sync_takeout_actions(&ui);
|
sync_takeout_actions(&ui);
|
||||||
}
|
}
|
||||||
match summary {
|
match &summary {
|
||||||
Some(summary) => repaint_summary(&ui, &summary),
|
Some(summary) => repaint_summary(&ui, summary, running),
|
||||||
None => ui.takeout.summary_group.set_visible(false),
|
None => ui.takeout.summary_group.set_visible(false),
|
||||||
}
|
}
|
||||||
|
paint_resume(&ui, running, summary.as_ref(), &resumable);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paint the last run's report.
|
/// Tell the user how a run ended. An import runs for hours, so its ending is
|
||||||
fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
/// announced rather than left to be discovered: a toast for whoever is looking
|
||||||
|
/// at the window, and a desktop notification for whoever is not.
|
||||||
|
fn announce_end(ui: &Rc<Ui>, summary: &ImportSummary) {
|
||||||
|
if summary.dry_run {
|
||||||
|
toast(ui, &gettext("Scan finished"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(error) = &summary.error {
|
||||||
|
let kind = summary.error_kind.unwrap_or_default();
|
||||||
|
let headline = gettext("Couldn't import from Google Photos");
|
||||||
|
toast_failure(ui, &headline, error, kind);
|
||||||
|
notify(
|
||||||
|
"takeout-import",
|
||||||
|
&headline,
|
||||||
|
&gettext("You can resume it once the problem is solved."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (headline, body) = if summary.interrupted {
|
||||||
|
(
|
||||||
|
gettext("Import interrupted"),
|
||||||
|
gettext("You can resume it where it stopped."),
|
||||||
|
)
|
||||||
|
} else if summary.cancelled {
|
||||||
|
(
|
||||||
|
gettext("Import stopped"),
|
||||||
|
ngettext_f(
|
||||||
|
"Stopped after {n} photo.",
|
||||||
|
"Stopped after {n} photos.",
|
||||||
|
summary.uploaded as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if summary.failed > 0 {
|
||||||
|
(
|
||||||
|
gettext("Import finished"),
|
||||||
|
// Translators: {n} is the number of photos uploaded, {failed} the number that failed.
|
||||||
|
ngettext_f(
|
||||||
|
"{n} photo added, {failed} failed.",
|
||||||
|
"{n} photos added, {failed} failed.",
|
||||||
|
summary.uploaded as u64,
|
||||||
|
&[("failed", &summary.failed.to_string())],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
gettext("Import finished"),
|
||||||
|
ngettext_f(
|
||||||
|
"{n} photo added to Proton Photos.",
|
||||||
|
"{n} photos added to Proton Photos.",
|
||||||
|
summary.uploaded as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
toast(ui, &headline);
|
||||||
|
notify("takeout-import", &headline, &body);
|
||||||
|
|
||||||
|
// A complete import is done with its archives; keeping them staged would
|
||||||
|
// only invite running the same export again.
|
||||||
|
if summary.complete() {
|
||||||
|
ui.takeout.archives.borrow_mut().clear();
|
||||||
|
repaint_archives(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint where a running import is.
|
||||||
|
fn paint_progress(ui: &Rc<Ui>, progress: Option<&ImportProgress>) {
|
||||||
|
let Some(progress) = progress else {
|
||||||
|
ui.takeout.progress_bar.pulse();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let done = progress.done.to_string();
|
||||||
|
let total = progress.total.to_string();
|
||||||
|
let current = progress.current.clone().unwrap_or_default();
|
||||||
|
let counted = progress.total > 0;
|
||||||
|
let args = [
|
||||||
|
("done", done.as_str()),
|
||||||
|
("total", total.as_str()),
|
||||||
|
("name", current.as_str()),
|
||||||
|
];
|
||||||
|
let text = match (progress.phase, counted) {
|
||||||
|
(ImportPhase::Scanning, _) => gettext("Reading the archives…"),
|
||||||
|
(ImportPhase::Checking, false) => gettext("Checking for photos you already have…"),
|
||||||
|
// Translators: {done} and {total} are counts of photos.
|
||||||
|
(ImportPhase::Checking, true) => gettext_f(
|
||||||
|
"Checking for photos you already have — {done} of {total}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
(ImportPhase::Uploading, false) => gettext("Uploading…"),
|
||||||
|
(ImportPhase::Uploading, true) if current.is_empty() => {
|
||||||
|
// Translators: {done} and {total} are counts of photos.
|
||||||
|
gettext_f("Uploading {done} of {total}", &args)
|
||||||
|
}
|
||||||
|
// Translators: {done} and {total} are counts of photos; {name} is the file being uploaded.
|
||||||
|
(ImportPhase::Uploading, true) => gettext_f("Uploading {done} of {total} — {name}", &args),
|
||||||
|
(ImportPhase::Albums, false) => gettext("Sorting photos into albums…"),
|
||||||
|
// Translators: {done} and {total} are counts of albums.
|
||||||
|
(ImportPhase::Albums, true) => {
|
||||||
|
gettext_f("Sorting photos into albums — {done} of {total}", &args)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ui.takeout.progress_label.set_text(&text);
|
||||||
|
if counted {
|
||||||
|
ui.takeout
|
||||||
|
.progress_bar
|
||||||
|
.set_fraction((progress.done as f64 / progress.total as f64).min(1.0));
|
||||||
|
} else {
|
||||||
|
ui.takeout.progress_bar.pulse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offer to resume the last run when the service says it can be.
|
||||||
|
fn paint_resume(ui: &Rc<Ui>, running: bool, summary: Option<&ImportSummary>, resumable: &[String]) {
|
||||||
|
let offer = !running && !resumable.is_empty();
|
||||||
|
ui.takeout.resume_group.set_visible(offer);
|
||||||
|
if !offer {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let uploaded = summary.map_or(0, |s| s.uploaded) as u64;
|
||||||
|
// Translators: {n} is the number of photos uploaded before the import stopped.
|
||||||
|
ui.takeout.resume_row.set_subtitle(&ngettext_f(
|
||||||
|
"{n} photo uploaded before it stopped. Resuming skips what is already there.",
|
||||||
|
"{n} photos uploaded before it stopped. Resuming skips what is already there.",
|
||||||
|
uploaded,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint the counts of a running import, or the report of the last one.
|
||||||
|
fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary, running: bool) {
|
||||||
for row in ui.takeout.summary_rows.borrow_mut().drain(..) {
|
for row in ui.takeout.summary_rows.borrow_mut().drain(..) {
|
||||||
ui.takeout.summary_group.remove(&row);
|
ui.takeout.summary_group.remove(&row);
|
||||||
}
|
}
|
||||||
let dry = ui.takeout.dry_run.get();
|
let dry = summary.dry_run;
|
||||||
ui.takeout.summary_group.set_title(&if dry {
|
ui.takeout.summary_group.set_title(&if running {
|
||||||
|
gettext("So far")
|
||||||
|
} else if dry {
|
||||||
gettext("Scan result")
|
gettext("Scan result")
|
||||||
} else {
|
} else {
|
||||||
gettext("Last import")
|
gettext("Last import")
|
||||||
});
|
});
|
||||||
ui.takeout.summary_group.set_description(Some(&if let Some(error) = &summary.error {
|
let description = if running {
|
||||||
// The reason is the service's own text, which is not translated. A
|
None
|
||||||
// group's description is markup, and the reason may name a path.
|
} else if let Some(error) = &summary.error {
|
||||||
|
// The reason is the service's own text, which is not translated; the
|
||||||
|
// headline in front of it is. A group's description is markup, and the
|
||||||
|
// reason may name a path.
|
||||||
|
let what = gettext("Couldn't import from Google Photos");
|
||||||
|
Some(
|
||||||
glib::markup_escape_text(&gettext_f(
|
glib::markup_escape_text(&gettext_f(
|
||||||
"{what}: {detail}",
|
"{what}: {detail}",
|
||||||
&[
|
&[
|
||||||
("what", &gettext("Couldn't import from Google Photos")),
|
(
|
||||||
|
"what",
|
||||||
|
error_headline(summary.error_kind.unwrap_or_default(), &what),
|
||||||
|
),
|
||||||
("detail", error),
|
("detail", error),
|
||||||
],
|
],
|
||||||
))
|
))
|
||||||
.to_string()
|
.to_string(),
|
||||||
|
)
|
||||||
} else if dry {
|
} else if dry {
|
||||||
gettext("Nothing was uploaded — this is what an import would do.")
|
Some(gettext(
|
||||||
|
"Nothing was uploaded — this is what an import would do.",
|
||||||
|
))
|
||||||
|
} else if summary.interrupted {
|
||||||
|
Some(gettext(
|
||||||
|
"The Proton Drive service stopped while the import ran. Resume it to continue where it left off.",
|
||||||
|
))
|
||||||
} else if summary.cancelled {
|
} else if summary.cancelled {
|
||||||
gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
|
Some(gettext(
|
||||||
|
"Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.",
|
||||||
|
))
|
||||||
|
} else if !summary.complete() {
|
||||||
|
Some(gettext(
|
||||||
|
"Finished with problems. Run it again to retry what didn't upload.",
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
gettext("Finished.")
|
Some(gettext("Finished."))
|
||||||
}));
|
};
|
||||||
|
ui.takeout
|
||||||
|
.summary_group
|
||||||
|
.set_description(description.as_deref());
|
||||||
|
|
||||||
// Only lines that carry information: a run with no failures should not have
|
// Only lines that carry information: a run with no failures should not have
|
||||||
// to show a "0 failed" row for the user to read past.
|
// to show a "0 failed" row for the user to read past.
|
||||||
let mut lines: Vec<(String, String)> = vec![
|
let mut lines: Vec<(String, String)> =
|
||||||
(gettext("Photos in the export"), summary.found.to_string()),
|
vec![(gettext("Photos in the export"), summary.found.to_string())];
|
||||||
(
|
|
||||||
if dry {
|
if dry {
|
||||||
gettext("Would upload")
|
lines.push((gettext("Would upload"), summary.to_upload.to_string()));
|
||||||
} else {
|
} else {
|
||||||
gettext("Uploaded")
|
lines.push((gettext("Uploaded"), summary.uploaded.to_string()));
|
||||||
},
|
}
|
||||||
summary.uploaded.to_string(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if summary.duplicates > 0 {
|
if summary.duplicates > 0 {
|
||||||
lines.push((
|
lines.push((
|
||||||
gettext("Already in Proton Photos"),
|
gettext("Already in Proton Photos"),
|
||||||
summary.duplicates.to_string(),
|
summary.duplicates.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if summary.live_photos > 0 {
|
||||||
|
lines.push((
|
||||||
|
gettext("Live photos kept together"),
|
||||||
|
summary.live_photos.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if summary.albums_created > 0 {
|
if summary.albums_created > 0 {
|
||||||
lines.push((
|
lines.push((
|
||||||
if dry {
|
gettext("Albums created"),
|
||||||
gettext("Albums to create")
|
|
||||||
} else {
|
|
||||||
gettext("Albums created")
|
|
||||||
},
|
|
||||||
summary.albums_created.to_string(),
|
summary.albums_created.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -767,25 +961,31 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
|||||||
summary.skipped_trashed.to_string(),
|
summary.skipped_trashed.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if summary.bytes > 0 {
|
if dry && summary.to_upload_bytes > 0 {
|
||||||
lines.push((
|
lines.push((
|
||||||
if dry {
|
gettext("Data to upload"),
|
||||||
gettext("Data to upload")
|
human_bytes(summary.to_upload_bytes),
|
||||||
} else {
|
|
||||||
gettext("Data uploaded")
|
|
||||||
},
|
|
||||||
human_bytes(summary.bytes),
|
|
||||||
));
|
));
|
||||||
|
} else if summary.bytes > 0 {
|
||||||
|
lines.push((gettext("Data uploaded"), human_bytes(summary.bytes)));
|
||||||
}
|
}
|
||||||
if summary.failed > 0 {
|
if summary.failed > 0 {
|
||||||
lines.push((pgettext("state", "Failed"), summary.failed.to_string()));
|
lines.push((pgettext("state", "Failed"), summary.failed.to_string()));
|
||||||
}
|
}
|
||||||
|
for path in &summary.unreadable_archives {
|
||||||
|
let name = Path::new(path)
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| path.clone());
|
||||||
|
lines.push((gettext("Couldn't read this archive"), name));
|
||||||
|
}
|
||||||
|
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
for (title, value) in lines {
|
for (title, value) in lines {
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(title)
|
.title(title)
|
||||||
.subtitle(value)
|
.subtitle(value)
|
||||||
|
.use_markup(false)
|
||||||
.build();
|
.build();
|
||||||
row.add_css_class("property");
|
row.add_css_class("property");
|
||||||
ui.takeout.summary_group.add(&row);
|
ui.takeout.summary_group.add(&row);
|
||||||
@@ -794,35 +994,3 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
|||||||
*ui.takeout.summary_rows.borrow_mut() = rows;
|
*ui.takeout.summary_rows.borrow_mut() = rows;
|
||||||
ui.takeout.summary_group.set_visible(true);
|
ui.takeout.summary_group.set_visible(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paint the import's progress from the transfer snapshot the daemon pushed,
|
|
||||||
/// so this page costs no extra request. A job with no known total
|
|
||||||
/// pulses instead of showing a fraction, the same as the Activity list.
|
|
||||||
pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
|
|
||||||
let Some(job) = jobs.iter().find(|job| job.title == IMPORT_JOB) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
ui.takeout.progress_group.set_visible(true);
|
|
||||||
if job.total > 0 {
|
|
||||||
ui.takeout
|
|
||||||
.progress_bar
|
|
||||||
.set_fraction((job.done as f64 / job.total as f64).min(1.0));
|
|
||||||
let done = job.done.to_string();
|
|
||||||
let total = job.total.to_string();
|
|
||||||
// Translators: progress such as "120 of 4000 — IMG_0001.jpg"; {detail} is what is being worked on.
|
|
||||||
let text = gettext_f(
|
|
||||||
"{done} of {total} — {detail}",
|
|
||||||
&[("done", &done), ("total", &total), ("detail", &job.detail)],
|
|
||||||
);
|
|
||||||
ui.takeout.progress_label.set_text(&text);
|
|
||||||
} else {
|
|
||||||
ui.takeout.progress_bar.pulse();
|
|
||||||
ui.takeout
|
|
||||||
.progress_label
|
|
||||||
.set_text(&if job.detail.is_empty() {
|
|
||||||
gettext("Working…")
|
|
||||||
} else {
|
|
||||||
job.detail.clone()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,356 @@
|
|||||||
|
//! 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;
|
||||||
|
|
||||||
|
/// The edge of a song's cover in the My files player, in logical pixels.
|
||||||
|
const COVER_SIZE: i32 = 320;
|
||||||
|
|
||||||
|
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: its cover stands in for it, the one its listing tile shows,
|
||||||
|
// and a note while there is none.
|
||||||
|
let note = file_thumbnail_widget(COVER_SIZE, 128);
|
||||||
|
note.set_can_target(false);
|
||||||
|
note.set_visible(false);
|
||||||
|
note.add_css_class("media-player-note");
|
||||||
|
if let Some(cover) = note
|
||||||
|
.child()
|
||||||
|
.and_then(|fallback| fallback.next_sibling())
|
||||||
|
.and_downcast::<gtk4::Picture>()
|
||||||
|
{
|
||||||
|
// The whole cover, not a square cut out of it.
|
||||||
|
cover.set_content_fit(gtk4::ContentFit::Contain);
|
||||||
|
}
|
||||||
|
bind_file_thumbnail(ui, ¬e, entry, true);
|
||||||
|
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,11 +6,12 @@
|
|||||||
//! 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;
|
||||||
const THUMB_MISSING_MAX: usize = 2048;
|
const THUMB_MISSING_MAX: usize = 2048;
|
||||||
|
const THUMB_BLANK_MAX: usize = 1024;
|
||||||
const THUMB_DEBOUNCE: Duration = Duration::from_millis(40);
|
const THUMB_DEBOUNCE: Duration = Duration::from_millis(40);
|
||||||
const THUMB_RETRY: Duration = Duration::from_secs(2);
|
const THUMB_RETRY: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
@@ -20,6 +21,9 @@ pub(crate) struct FileThumbnailState {
|
|||||||
missing: RefCell<HashSet<String>>,
|
missing: RefCell<HashSet<String>>,
|
||||||
missing_order: RefCell<VecDeque<String>>,
|
missing_order: RefCell<VecDeque<String>>,
|
||||||
wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
|
wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
|
||||||
|
/// Tiles on screen still showing their icon, by key, kept across a
|
||||||
|
/// cancelled request so [`refresh_file_thumbnails`] can ask for them again.
|
||||||
|
blank: RefCell<HashMap<String, BlankTile>>,
|
||||||
queue: RefCell<VecDeque<FileThumbRequest>>,
|
queue: RefCell<VecDeque<FileThumbRequest>>,
|
||||||
inflight: Cell<bool>,
|
inflight: Cell<bool>,
|
||||||
source: RefCell<Option<glib::SourceId>>,
|
source: RefCell<Option<glib::SourceId>>,
|
||||||
@@ -27,6 +31,18 @@ pub(crate) struct FileThumbnailState {
|
|||||||
generation_loading: Cell<bool>,
|
generation_loading: Cell<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a tile left blank needs to be asked for again.
|
||||||
|
struct BlankTile {
|
||||||
|
request: FileThumbRequest,
|
||||||
|
pictures: Vec<glib::WeakRef<gtk4::Picture>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a picture registered as blank under `key` still is: bound to that
|
||||||
|
/// key, not painted, and in a window rather than in a list's spare pool.
|
||||||
|
fn tile_still_blank(key: &str, bound: &str, painted: bool, attached: bool) -> bool {
|
||||||
|
bound == key && !painted && attached
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum ReplyDisposition {
|
enum ReplyDisposition {
|
||||||
Ignore,
|
Ignore,
|
||||||
@@ -55,6 +71,7 @@ impl FileThumbnailState {
|
|||||||
missing: RefCell::new(HashSet::new()),
|
missing: RefCell::new(HashSet::new()),
|
||||||
missing_order: RefCell::new(VecDeque::new()),
|
missing_order: RefCell::new(VecDeque::new()),
|
||||||
wanted: RefCell::new(HashMap::new()),
|
wanted: RefCell::new(HashMap::new()),
|
||||||
|
blank: RefCell::new(HashMap::new()),
|
||||||
queue: RefCell::new(VecDeque::new()),
|
queue: RefCell::new(VecDeque::new()),
|
||||||
inflight: Cell::new(false),
|
inflight: Cell::new(false),
|
||||||
source: RefCell::new(None),
|
source: RefCell::new(None),
|
||||||
@@ -88,6 +105,85 @@ impl FileThumbnailState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remember_blank(&self, key: &str, request: &FileThumbRequest, picture: >k4::Picture) {
|
||||||
|
let mut blank = self.blank.borrow_mut();
|
||||||
|
if blank.len() >= THUMB_BLANK_MAX {
|
||||||
|
prune_blank(&mut blank);
|
||||||
|
}
|
||||||
|
let tile = blank.entry(key.to_string()).or_insert_with(|| BlankTile {
|
||||||
|
request: request.clone(),
|
||||||
|
pictures: Vec::new(),
|
||||||
|
});
|
||||||
|
if !tile
|
||||||
|
.pictures
|
||||||
|
.iter()
|
||||||
|
.any(|weak| weak.upgrade().as_ref() == Some(picture))
|
||||||
|
{
|
||||||
|
tile.pictures.push(picture.downgrade());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the tiles that were painted, rebound to another file, or freed.
|
||||||
|
fn prune_blank(blank: &mut HashMap<String, BlankTile>) {
|
||||||
|
blank.retain(|key, tile| {
|
||||||
|
tile.pictures.retain(|weak| {
|
||||||
|
weak.upgrade().is_some_and(|picture| {
|
||||||
|
tile_still_blank(
|
||||||
|
key,
|
||||||
|
&picture.widget_name(),
|
||||||
|
picture.is_visible(),
|
||||||
|
picture.root().is_some(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
!tile.pictures.is_empty()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask again for every tile on screen still showing its icon. Sent when the
|
||||||
|
/// daemon says thumbnails landed, so a folder fills in while a build runs in
|
||||||
|
/// it, or after its own requests were cancelled, without being opened again.
|
||||||
|
pub(crate) fn refresh_file_thumbnails(ui: &Rc<Ui>) {
|
||||||
|
let tiles: Vec<(String, FileThumbRequest, Vec<gtk4::Picture>)> = {
|
||||||
|
let mut blank = ui.file_thumbs.blank.borrow_mut();
|
||||||
|
prune_blank(&mut blank);
|
||||||
|
blank
|
||||||
|
.iter()
|
||||||
|
.map(|(key, tile)| {
|
||||||
|
let pictures = tile.pictures.iter().filter_map(|w| w.upgrade()).collect();
|
||||||
|
(key.clone(), tile.request.clone(), pictures)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
if tiles.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (key, request, pictures) in tiles {
|
||||||
|
let cached = ui.file_thumbs.textures.borrow().get(&key).cloned();
|
||||||
|
if let Some(texture) = cached {
|
||||||
|
for picture in &pictures {
|
||||||
|
paint(picture, &key, &texture);
|
||||||
|
}
|
||||||
|
ui.file_thumbs.blank.borrow_mut().remove(&key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A miss from before the thumbnail landed is no verdict any more.
|
||||||
|
ui.file_thumbs.missing.borrow_mut().remove(&key);
|
||||||
|
let mut wanted = ui.file_thumbs.wanted.borrow_mut();
|
||||||
|
let waiters = wanted.entry(key).or_default();
|
||||||
|
for picture in pictures {
|
||||||
|
if !waiters.contains(&picture) {
|
||||||
|
waiters.push(picture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(wanted);
|
||||||
|
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
||||||
|
queue.retain(|item| item.uid != request.uid);
|
||||||
|
queue.push_back(request);
|
||||||
|
}
|
||||||
|
schedule_file_thumbs(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Abandon every opportunistic ordinary-file thumbnail associated with the
|
/// Abandon every opportunistic ordinary-file thumbnail associated with the
|
||||||
@@ -125,6 +221,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 +241,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 +299,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,13 +316,22 @@ 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) {
|
||||||
paint(&picture, &key, texture);
|
paint(&picture, &key, texture);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let request = FileThumbRequest {
|
||||||
|
uid: entry.uid.clone(),
|
||||||
|
modified: entry.modified,
|
||||||
|
name: entry.name.clone(),
|
||||||
|
};
|
||||||
|
ui.file_thumbs.remember_blank(&key, &request, &picture);
|
||||||
if ui.file_thumbs.missing.borrow().contains(&key) {
|
if ui.file_thumbs.missing.borrow().contains(&key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -205,21 +344,11 @@ pub(crate) fn bind_file_thumbnail(
|
|||||||
.push(picture);
|
.push(picture);
|
||||||
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
||||||
queue.retain(|item| item.uid != entry.uid);
|
queue.retain(|item| item.uid != entry.uid);
|
||||||
queue.push_back(FileThumbRequest {
|
queue.push_back(request);
|
||||||
uid: entry.uid.clone(),
|
|
||||||
modified: entry.modified,
|
|
||||||
name: entry.name.clone(),
|
|
||||||
});
|
|
||||||
drop(queue);
|
drop(queue);
|
||||||
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 +367,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>()
|
||||||
{
|
{
|
||||||
@@ -353,6 +485,7 @@ fn flush_file_thumbs(ui: &Rc<Ui>) {
|
|||||||
match gtk4::gdk::Texture::from_filename(&path) {
|
match gtk4::gdk::Texture::from_filename(&path) {
|
||||||
Ok(texture) => {
|
Ok(texture) => {
|
||||||
ui.file_thumbs.store_texture(&key, texture.clone());
|
ui.file_thumbs.store_texture(&key, texture.clone());
|
||||||
|
ui.file_thumbs.blank.borrow_mut().remove(&key);
|
||||||
if let Some(pictures) =
|
if let Some(pictures) =
|
||||||
ui.file_thumbs.wanted.borrow_mut().remove(&key)
|
ui.file_thumbs.wanted.borrow_mut().remove(&key)
|
||||||
{
|
{
|
||||||
@@ -413,7 +546,20 @@ 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, tile_still_blank};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_tile_still_showing_its_icon_is_asked_for_again() {
|
||||||
|
assert!(tile_still_blank("a:1", "a:1", false, true));
|
||||||
|
assert!(!tile_still_blank("a:1", "a:1", true, true), "painted");
|
||||||
|
assert!(!tile_still_blank("a:1", "b:1", false, true), "rebound");
|
||||||
|
assert!(!tile_still_blank("a:1", "a:2", false, true), "file changed");
|
||||||
|
assert!(!tile_still_blank("a:1", "a:1", false, false), "spare row");
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ pub(crate) fn init() {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|dirs| dirs.load_config().language)
|
.and_then(|dirs| dirs.load_config().language)
|
||||||
.filter(|code| LANGUAGES.iter().any(|(known, _)| known == code));
|
.filter(|code| LANGUAGES.iter().any(|(known, _)| known == code));
|
||||||
gettextrs::setlocale(LocaleCategory::LcAll, "");
|
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
|
||||||
|
unsafe { gettextrs::setlocale(LocaleCategory::LcAll, "") };
|
||||||
if let Some(code) = language {
|
if let Some(code) = language {
|
||||||
apply_language(&code);
|
apply_language(&code);
|
||||||
}
|
}
|
||||||
@@ -97,14 +98,17 @@ pub(crate) fn country_name(code: &str) -> String {
|
|||||||
fn apply_language(code: &str) {
|
fn apply_language(code: &str) {
|
||||||
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
|
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
|
||||||
unsafe { std::env::set_var("LANGUAGE", code) };
|
unsafe { std::env::set_var("LANGUAGE", code) };
|
||||||
let current = gettextrs::setlocale(LocaleCategory::LcMessages, "")
|
// SAFETY: as above, still single-threaded.
|
||||||
|
let current = unsafe { gettextrs::setlocale(LocaleCategory::LcMessages, "") }
|
||||||
.map(|name| String::from_utf8_lossy(&name).into_owned())
|
.map(|name| String::from_utf8_lossy(&name).into_owned())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !is_c_locale(¤t) {
|
if !is_c_locale(¤t) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for candidate in message_locales(code) {
|
for candidate in message_locales(code) {
|
||||||
if gettextrs::setlocale(LocaleCategory::LcMessages, candidate.as_str()).is_some() {
|
// SAFETY: as above, still single-threaded.
|
||||||
|
if unsafe { gettextrs::setlocale(LocaleCategory::LcMessages, candidate.as_str()) }.is_some()
|
||||||
|
{
|
||||||
// SAFETY: as above, still single-threaded.
|
// SAFETY: as above, still single-threaded.
|
||||||
unsafe { std::env::set_var("LC_MESSAGES", &candidate) };
|
unsafe { std::env::set_var("LC_MESSAGES", &candidate) };
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
+511
-13
@@ -70,7 +70,6 @@ original wording.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [B275](#b275--on-a-slow-link-a-files-last-write-can-land-only-in-a-conflict-copy) | On a slow link, a file's last write can land only in a conflict copy | Cause not found; seen once in CI |
|
| [B275](#b275--on-a-slow-link-a-files-last-write-can-land-only-in-a-conflict-copy) | On a slow link, a file's last write can land only in a conflict copy | Cause not found; seen once in CI |
|
||||||
| [B273](#b273--a-restore-proposes-another-computers-paths-when-the-two-share-a-hostname) | A restore proposes another computer's paths when the two share a hostname | Not fixed |
|
| [B273](#b273--a-restore-proposes-another-computers-paths-when-the-two-share-a-hostname) | A restore proposes another computer's paths when the two share a hostname | Not fixed |
|
||||||
| [B270](#b270--one-file-left-in-a-mirror-folder-clears-the-wipe-guard-for-all-the-others) | One file left in a mirror folder clears the wipe guard for all the others | Not fixed |
|
|
||||||
| [B267](#b267--a-key-failure-at-a-start-from-a-warm-cache-shows-as-offline-for-good) | A key failure at a start from a warm cache shows as offline for good | Not fixed |
|
| [B267](#b267--a-key-failure-at-a-start-from-a-warm-cache-shows-as-offline-for-good) | A key failure at a start from a warm cache shows as offline for good | Not fixed |
|
||||||
| [B134](#b134--a-file-deleted-online-while-the-drain-checks-its-write-is-kept-as-a-conflict-copy) | A file deleted online while the drain checks its write is kept as a conflict copy | Only with `local_first` off |
|
| [B134](#b134--a-file-deleted-online-while-the-drain-checks-its-write-is-kept-as-a-conflict-copy) | A file deleted online while the drain checks its write is kept as a conflict copy | Only with `local_first` off |
|
||||||
| [B91](#b91--the-daemon-hung-overnight-and-nothing-could-say-what-it-was-waiting-on) | The daemon hung overnight, and nothing could say what it was waiting on | The cause is not diagnosed; the daemon now reports where it is stuck and is restarted |
|
| [B91](#b91--the-daemon-hung-overnight-and-nothing-could-say-what-it-was-waiting-on) | The daemon hung overnight, and nothing could say what it was waiting on | The cause is not diagnosed; the daemon now reports where it is stuck and is restarted |
|
||||||
@@ -84,6 +83,7 @@ original wording.
|
|||||||
|
|
||||||
| Entry | Title | What is left |
|
| Entry | Title | What is left |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| [B270](#b270--one-file-left-in-a-mirror-folder-clears-the-wipe-guard-for-all-the-others) | One file left in a mirror folder clears the wipe guard for all the others | A loss under nine in ten still goes through; no choice offered in the app |
|
||||||
| [B269](#b269--a-restore-that-skipped-every-folder-reports-success) | A restore that skipped every folder reports success | The app shows the result only in the activity feed; no offer to replace or reset the folder in the way |
|
| [B269](#b269--a-restore-that-skipped-every-folder-reports-success) | A restore that skipped every folder reports success | The app shows the result only in the activity feed; no offer to replace or reset the folder in the way |
|
||||||
| [B268](#b268--pdfs-diagnose-says-no-problems-found-while-sync-is-stuck) | `pdfs diagnose` says "No problems found" while sync is stuck | The sync engine's state is not checked |
|
| [B268](#b268--pdfs-diagnose-says-no-problems-found-while-sync-is-stuck) | `pdfs diagnose` says "No problems found" while sync is stuck | The sync engine's state is not checked |
|
||||||
| [B263](#b263--a-mount-that-can-never-succeed-retries-every-5-s-and-tells-only-the-journal) | A mount that can never succeed retries every 5 s and tells only the journal | Errors no retry fixes back off like network errors |
|
| [B263](#b263--a-mount-that-can-never-succeed-retries-every-5-s-and-tells-only-the-journal) | A mount that can never succeed retries every 5 s and tells only the journal | Errors no retry fixes back off like network errors |
|
||||||
@@ -109,7 +109,19 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [B276](#b276--a-stop-can-end-with-a-panic-in-a-thread-the-stop-never-waited-for) | A stop can end with a panic in a thread the stop never waited for |
|
| [B289](#b289--the-takeout-import-mixes-in-other-services-and-drops-location-and-live-photos) | The Takeout import mixes in other services and drops location and live photos |
|
||||||
|
| [B288](#b288--an-interrupted-takeout-import-reads-as-finished-and-one-error-ends-it) | An interrupted Takeout import reads as finished, and one error ends it |
|
||||||
|
| [B287](#b287--a-photo-draft-left-by-an-interrupted-upload-blocks-that-name) | A photo draft left by an interrupted upload blocks that name |
|
||||||
|
| [B286](#b286--resuming-a-takeout-import-fails-on-a-draft-with-no-content-hash) | Resuming a Takeout import fails on a draft with no content hash |
|
||||||
|
| [B285](#b285--a-restarted-secret-service-leaves-the-process-on-a-dead-session) | A restarted Secret Service leaves the process on a dead session |
|
||||||
|
| [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 |
|
||||||
|
| [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 |
|
||||||
| [B272](#b272--after-a-folder-is-removed-and-added-again-its-file-events-go-to-the-old-id) | After a folder is removed and added again, its file events go to the old id |
|
| [B272](#b272--after-a-folder-is-removed-and-added-again-its-file-events-go-to-the-old-id) | After a folder is removed and added again, its file events go to the old id |
|
||||||
| [B271](#b271--one-failed-listing-makes-a-large-first-sync-start-over-from-nothing) | One failed listing makes a large first sync start over from nothing |
|
| [B271](#b271--one-failed-listing-makes-a-large-first-sync-start-over-from-nothing) | One failed listing makes a large first sync start over from nothing |
|
||||||
@@ -118,9 +130,7 @@ a live account since.
|
|||||||
| [B264](#b264--a-mount-that-fails-after-starting-its-workers-leaves-them-running) | A mount that fails after starting its workers leaves them running |
|
| [B264](#b264--a-mount-that-fails-after-starting-its-workers-leaves-them-running) | A mount that fails after starting its workers leaves them running |
|
||||||
| [B261](#b261--a-folder-that-has-never-synced-reads-up-to-date) | A folder that has never synced reads "up to date" |
|
| [B261](#b261--a-folder-that-has-never-synced-reads-up-to-date) | A folder that has never synced reads "up to date" |
|
||||||
| [B260](#b260--the-poll-pushes-a-new-folders-first-sync-behind-every-other-folder) | The poll pushes a new folder's first sync behind every other folder |
|
| [B260](#b260--the-poll-pushes-a-new-folders-first-sync-behind-every-other-folder) | The poll pushes a new folder's first sync behind every other folder |
|
||||||
| [B258](#b258--journalctl--p-warning-shows-nothing-whatever-the-daemon-logged) | `journalctl -p warning` shows nothing, whatever the daemon logged |
|
| [B257](#b257--deleting-a-new-file-while-it-is-open-can-drop-the-bytes-its-handle-reads) | Deleting a new file while it is open can drop the bytes its handle reads |
|
||||||
| [B256](#b256--pdfs-copy-right-after-a-save-copies-the-old-version) | `pdfs copy` right after a save copies the old version |
|
|
||||||
| [B255](#b255--a-synced-folder-whose-files-are-all-gone-here-is-stuck-for-good) | A synced folder whose files are all gone here is stuck for good |
|
|
||||||
| [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives |
|
| [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives |
|
||||||
| [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked |
|
| [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked |
|
||||||
| [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile |
|
| [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile |
|
||||||
@@ -295,6 +305,10 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [B276](#b276--a-stop-can-end-with-a-panic-in-a-thread-the-stop-never-waited-for) | A stop can end with a panic in a thread the stop never waited for |
|
||||||
|
| [B258](#b258--journalctl--p-warning-shows-nothing-whatever-the-daemon-logged) | `journalctl -p warning` shows nothing, whatever the daemon logged |
|
||||||
|
| [B256](#b256--pdfs-copy-right-after-a-save-copies-the-old-version) | `pdfs copy` right after a save copies the old version |
|
||||||
|
| [B255](#b255--a-synced-folder-whose-files-are-all-gone-here-is-stuck-for-good) | A synced folder whose files are all gone here is stuck for good |
|
||||||
| [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives |
|
| [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives |
|
||||||
| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later |
|
| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later |
|
||||||
| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English |
|
| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English |
|
||||||
@@ -378,9 +392,440 @@ a live account since.
|
|||||||
|
|
||||||
Newest first.
|
Newest first.
|
||||||
|
|
||||||
### B276 — A stop can end with a panic in a thread the stop never waited for
|
### B289 — The Takeout import mixes in other services and drops location and live photos
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-15, auditing the Google Photos import alongside
|
||||||
|
[issue #94](https://github.com/narrrl/proton-drive-linux/issues/94).
|
||||||
|
**Where:** `crates/pdfs-core/src/takeout.rs`; `plan` and `upload_takeout_photo` in
|
||||||
|
`crates/pdfs-fuse/src/takeout.rs`
|
||||||
|
|
||||||
|
**Repro:** Import a Takeout export that also holds `Drive/` or `YouTube/`, with Pixel motion
|
||||||
|
photos, iPhone live photos and photos that have a place in Google Photos.
|
||||||
|
|
||||||
|
**Cause.** Five gaps in what the import carried over:
|
||||||
|
- The service folder was dropped without being checked, so media from other Google services in
|
||||||
|
the same export landed in the photo timeline.
|
||||||
|
- `geoData` was never read, although `docs/PHOTOS.md` said the import takes locations from the
|
||||||
|
export, and the SDK had no way to upload a location.
|
||||||
|
- The two halves of a live or motion photo uploaded as unrelated photos, the `.MOV` half usually
|
||||||
|
without a date, and the `.MP` half of a Pixel motion photo was skipped as unknown.
|
||||||
|
- `creationTime`, the day the photo reached Google, outranked the date in the file name and the
|
||||||
|
photo's own EXIF.
|
||||||
|
- A sidecar with `null` in `favorited`, a numeric `timestamp` or a `0` timestamp lost its other
|
||||||
|
fields or filed the photo under 1970.
|
||||||
|
|
||||||
|
**Fix:** The Photos service is the folder whose sidecars carry `photoTakenTime`, and only it is
|
||||||
|
imported (everything, when no folder does). `geoData`, else `geoDataExif`, becomes the photo's
|
||||||
|
`Location` attribute through SDK 0.12.0's `PhotoUploadMetadata::location`; `0, 0` is no position.
|
||||||
|
Stills and videos with the same stem in one folder (`IMG_1.HEIC` + `IMG_1.MOV`,
|
||||||
|
`PXL_1.MP.jpg` + `PXL_1.MP`) are paired unless their dates are minutes apart: the still is
|
||||||
|
tagged as a live or motion photo, the video is uploaded after it with `main_photo_uid` and takes
|
||||||
|
its date and place. The capture time is `photoTakenTime`, then the name, then EXIF, then
|
||||||
|
`creationTime`. Favorites are also set on photos that were already on the account.
|
||||||
|
|
||||||
|
**Verified:** `takeout::tests` in `pdfs-core` (services, locations, timestamps, live keys) and
|
||||||
|
`pdfs-fuse` (pairing, skew, folded copies). Not run against a real export and account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B288 — An interrupted Takeout import reads as finished, and one error ends it
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-15, auditing the Google Photos import alongside
|
||||||
|
[issue #94](https://github.com/narrrl/proton-drive-linux/issues/94).
|
||||||
|
**Where:** `crates/pdfs-fuse/src/takeout.rs`; `refresh_takeout` in
|
||||||
|
`crates/pdfs-gui/src/app/pages/takeout.rs`; `cmd_import_google_photos` in
|
||||||
|
`crates/pdfs-cli/src/main.rs`
|
||||||
|
|
||||||
|
**Repro:** Start an import of several archives, one of them damaged, and restart the service
|
||||||
|
while it uploads.
|
||||||
|
|
||||||
|
**Cause.**
|
||||||
|
- The import state lived only in memory. After a restart `ImportStatus` had no summary, and the
|
||||||
|
app announced "Import finished". The archives had to be chosen again to continue.
|
||||||
|
- A daemon shutdown broke the upload loop but still filed albums and logged a success.
|
||||||
|
- One unreadable archive failed the scan, and a failed request in the middle of the run
|
||||||
|
discarded every count and skipped the albums for what had uploaded.
|
||||||
|
- The progress total counted photos already on the account, so the bar stopped short of the
|
||||||
|
end; a dry run reported "Would upload: 0"; the job's phases reached the app in English.
|
||||||
|
|
||||||
|
**Fix:** The last run's archives and summary are kept in `takeout-import.json` in the state
|
||||||
|
directory. A run still marked running at start-up is `interrupted`, and `ImportStatus` lists
|
||||||
|
its archives as `resumable`; `ResumeImport` (`pdfs import-google-photos --resume`, **Resume** in
|
||||||
|
the app) starts it again, and the duplicate check skips what is there. An unreadable archive is
|
||||||
|
listed in `unreadable_archives` and the rest imports. A failure keeps the counts and still files
|
||||||
|
albums. `ImportStatus` carries the phase and its own progress, the summary says `dry_run`,
|
||||||
|
`to_upload` and `error_kind`, and the app words them in the user's language. The inline
|
||||||
|
timeline refresh of an empty gallery waits for a refresh already running instead of starting a
|
||||||
|
second one (`refresh_timeline_joined`).
|
||||||
|
|
||||||
|
**Verified:** `takeout::tests` in `pdfs-fuse` (saved state, resumable runs) and `pdfs-core`
|
||||||
|
(unreadable archive), and the CLI tests for `--resume` and the exit status. Not run live.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B287 — A photo draft left by an interrupted upload blocks that name
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-15, reading the SDK upload path for
|
||||||
|
[issue #94](https://github.com/narrrl/proton-drive-linux/issues/94).
|
||||||
|
**Where:** `upload_photo_from`, `upload_file_from_inner` and `abandon_file_draft` in
|
||||||
|
`proton-drive-rs` (SDK 0.12.0)
|
||||||
|
|
||||||
|
**Repro:** Interrupt a photo upload after its draft is created, sign in again, and upload a
|
||||||
|
photo with the same name.
|
||||||
|
|
||||||
|
**Cause.** Photo uploads replaced a leftover draft only when its `ClientUID` was the current
|
||||||
|
session's. A new login is a new session, so the next upload of that name failed with 2500. A
|
||||||
|
failed upload also never deleted its own draft, which the C# and TypeScript SDKs do.
|
||||||
|
|
||||||
|
**Fix:** SDK 0.12.0 adds `PhotoUploadMetadata::replace_existing_draft`, which the Takeout import
|
||||||
|
sets, and deletes the draft of a new file or photo whose upload fails. After a failed seal it
|
||||||
|
only does so on a definite rejection, because a seal lost in transit may have been applied.
|
||||||
|
|
||||||
|
**Verified:** SDK unit test `draft_cleanup_only_after_a_definite_seal_rejection`. Not run live.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B286 — Resuming a Takeout import fails on a draft with no content hash
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-15, [issue #94](https://github.com/narrrl/proton-drive-linux/issues/94)
|
||||||
|
(3.7.0, Fedora). An import stopped by a restart failed every re-run at once with
|
||||||
|
`check for existing photos: serialization error: invalid type: null, expected a string at line
|
||||||
|
1 column 2063`.
|
||||||
|
**Where:** `FoundPhotoDuplicateDto` in `proton-drive-rs` (SDK 0.11.0)
|
||||||
|
|
||||||
|
**Repro:** Interrupt a photo upload after its draft is created, then run the import again.
|
||||||
|
|
||||||
|
**Cause.** The duplicates endpoint sends `ContentHash` (and can send `Hash`) as `null` for a
|
||||||
|
draft, as its API schema says. The SDK declared both as `String` with `#[serde(default)]`,
|
||||||
|
which covers a missing field but not `null`, so one draft failed the whole batch of 150 names
|
||||||
|
and the import ended before uploading anything.
|
||||||
|
|
||||||
|
**Fix:** SDK 0.12.0 makes both fields optional and skips drafts, trashed links and candidates
|
||||||
|
without hashes, like the TypeScript SDK. Every other defaulted DTO field reads `null` as its
|
||||||
|
default too, and an undecodable response is logged with its path.
|
||||||
|
|
||||||
|
**Verified:** SDK unit tests `duplicate_response_tolerates_null_fields_and_skips_drafts` and
|
||||||
|
`duplicate_response_tolerates_a_null_list`. Not run against the reporter's account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B285 — A restarted Secret Service leaves the process on a dead session
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified)
|
||||||
|
**Found:** 2026-11-14, following up [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78)
|
||||||
|
(signed out after every cold boot on Fedora 45, which replaced gnome-keyring with oo7). See
|
||||||
|
[the investigation](ISSUE-78.md#a-restarted-secret-service).
|
||||||
|
**Where:** `crates/pdfs-core/src/auth.rs`, `with_keyring_entry`
|
||||||
|
|
||||||
|
**Repro:** Write the session blob, restart the Secret Service provider
|
||||||
|
(`gnome-keyring-daemon --replace`, or `oo7-daemon --replace`), then read or write it again from
|
||||||
|
the same process. `dbus-secret-service-keyring-store` keeps one D-Bus connection and one Secret
|
||||||
|
Service session for the life of the process, and pdfs installed it once. The session exists only
|
||||||
|
in the provider's memory, so the new provider answers every `GetSecret` and `SetSecret` with
|
||||||
|
`org.freedesktop.Secret.Error.NoSession`, for as long as the process runs. A fresh store in the
|
||||||
|
same process reads the same entry fine. oo7's systemd unit restarts the daemon on failure, and
|
||||||
|
both providers can be replaced, so the daemon could meet this mid-session. A token refresh after
|
||||||
|
that point reached only memory and the kernel keyring: B61's retry kept asking on the dead
|
||||||
|
session, and the next cold boot read the spent refresh token (`InvalidRefreshToken`).
|
||||||
|
|
||||||
|
**Fix:** Every keyring call goes through `with_keyring_entry`. A platform failure (which is what
|
||||||
|
`NoSession` and other D-Bus errors become) logs "the Secret Service failed this process's
|
||||||
|
connection; reconnecting", drops the store unless another thread already replaced it, and makes
|
||||||
|
the call once more on a new connection and session. A locked keyring, a dismissed prompt and a
|
||||||
|
missing entry come back unchanged. Affects the daemon, the app and the tray alike.
|
||||||
|
|
||||||
|
**Verified:** `auth::tests::a_dropped_secret_service_session_is_reconnected_and_retried`,
|
||||||
|
`a_rotation_lands_at_once_after_the_keyring_restarts` and `a_locked_keyring_is_not_reconnected`
|
||||||
|
on the in-memory store. The ignored `auth::tests::real_secret_service_survives_a_provider_restart`
|
||||||
|
passes against gnome-keyring 51.1 and oo7 0.7.1 (built from its tag), each in a throwaway session
|
||||||
|
bus and restarted with `--replace`; it checks that a reconnect actually happened. A standalone
|
||||||
|
program holding one store, as before the fix, gets `NoSession` on read and write after the same
|
||||||
|
restart under both providers. Whether the reporter's oo7 restarts during a session is not known.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-11-02, issue #46. The reporter's `pdfs sync list` on 3.6.0 read
|
||||||
|
`synced: 1793570850`, a pass that finished at 2026-11-01 22:07 UTC.
|
||||||
|
**Where:** `cmd_sync` and `cmd_devices` in `crates/pdfs-cli/src/main.rs`
|
||||||
|
|
||||||
|
**Repro:** Let a synced folder finish one pass and run `pdfs sync list`, or `pdfs devices list`
|
||||||
|
on an account with a device that synced.
|
||||||
|
|
||||||
|
**Cause.** Both commands printed `last_sync` with `to_string()`. The activity feed already
|
||||||
|
formats the same epoch seconds with `format_epoch`.
|
||||||
|
|
||||||
|
**Fix.** Both columns go through `last_synced`, which prints `MM-DD HH:MM` in UTC like
|
||||||
|
`pdfs activity`, and `never` for 0 or no value. `--json` output is unchanged.
|
||||||
|
|
||||||
|
**Test:** `last_synced_shows_a_time_not_epoch_seconds` in `crates/pdfs-cli/src/main.rs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B277 — My files mounted over a folder with files in it, and hid them
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-31, issue #67. The reporter chose an existing working folder as the My files
|
||||||
|
location. Its folders disappeared and looked deleted; they came back when the service stopped.
|
||||||
|
**Where:** `mount_with` and `check_mountpoint` in `crates/pdfs-fuse/src/mount.rs`;
|
||||||
|
`crates/pdfs-core/src/mountpoint.rs`; `prompt_mountpoint` in
|
||||||
|
`crates/pdfs-gui/src/app/pages/status.rs`
|
||||||
|
|
||||||
|
**Repro:** Stop the service. Put a file and a folder in `~/ProtonDrive`, or choose a folder with
|
||||||
|
files in it under Preferences → General → Location. Start the service. The files are hidden
|
||||||
|
under the mount: not uploaded, not in the queue, and back only once the service stops.
|
||||||
|
|
||||||
|
**Cause.** FUSE mounts over a folder that is not empty without a word, and nothing checked the
|
||||||
|
folder first. `mount_once` only created it. On-demand synced folders were already checked when
|
||||||
|
they were mounted again after a restart (`restore_ondemand_mounts`); My files was not. The
|
||||||
|
folder picker took any folder.
|
||||||
|
|
||||||
|
**Fix.**
|
||||||
|
|
||||||
|
- The daemon refuses to mount My files over a folder with anything in it, hidden files
|
||||||
|
included, before it fetches the root or starts a worker. A dead mount is cleared first, so it
|
||||||
|
can be listed. One of this client's own live mounts is left to the mount, which clears it as
|
||||||
|
before.
|
||||||
|
- The refusal is a mount failure like any other: it backs off and tries again, so emptying the
|
||||||
|
folder is enough. The failure record now carries the folder and its first entries
|
||||||
|
(`MountFailure::not_empty`), which an older reader ignores.
|
||||||
|
- The app explains it in the user's language, and offers to open the folder or choose another
|
||||||
|
one. The folder picker refuses a folder with files in it.
|
||||||
|
|
||||||
|
Merging an existing folder into My files is not done here. #67 proposes `pdfs sync attach` for
|
||||||
|
that, as a separate change.
|
||||||
|
|
||||||
|
**Test:** `a_folder_with_files_is_refused_and_named`, `a_hidden_file_alone_is_enough_to_refuse`,
|
||||||
|
`an_empty_or_missing_folder_takes_the_mount` in `crates/pdfs-core/src/mountpoint.rs`;
|
||||||
|
`a_failure_an_older_daemon_wrote_still_reads` in `mountfail.rs`;
|
||||||
|
`a_mount_refused_over_files_is_recorded_as_such` in `crates/pdfs-cli/src/main.rs`; the
|
||||||
|
simulation `a_mount_over_local_files_is_refused_and_leaves_them_alone`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B276 — A stop can end with a panic in a thread the stop never waited for
|
||||||
|
|
||||||
|
**Status:** Fixed, checked on 2026-11-01 by an account run of `scripts/fuse-acceptance.sh
|
||||||
|
--journal-check --durability`: eight daemon restarts, and no panic in the journal
|
||||||
**Found:** 2026-10-31, by an account run of `scripts/fuse-acceptance.sh --journal-check
|
**Found:** 2026-10-31, by an account run of `scripts/fuse-acceptance.sh --journal-check
|
||||||
--durability` against the audit branches. Every case passed, and the run still failed. During
|
--durability` against the audit branches. Every case passed, and the run still failed. During
|
||||||
the restart in the durability case on My files, the journal had three
|
the restart in the durability case on My files, the journal had three
|
||||||
@@ -531,7 +976,9 @@ folder list has now.
|
|||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
**Found:** 2026-10-31, code audit after issue #46: a folder of about 19,900 items restored on a
|
**Found:** 2026-10-31, code audit after issue #46: a folder of about 19,900 items restored on a
|
||||||
second computer sat at `scanning…` with nothing downloaded, and the journal showed
|
second computer sat at `scanning…` with nothing downloaded, and the journal showed
|
||||||
`RequestTimeout (http 408)`.
|
`RequestTimeout (http 408)`. On 2026-11-01 the reporter's 3.6.0 journal showed the same pass
|
||||||
|
failing four times in four minutes with `reconcile failed id=1 error=enumerate …: HTTP transport
|
||||||
|
error`, after the laptop woke from sleep; a later pass finished.
|
||||||
**Where:** `walk_remote` in `crates/pdfs-fuse/src/sync.rs`
|
**Where:** `walk_remote` in `crates/pdfs-fuse/src/sync.rs`
|
||||||
|
|
||||||
**Repro:** Restore or add a mirror folder with tens of thousands of items on Drive, on a link
|
**Repro:** Restore or add a mirror folder with tens of thousands of items on Drive, on a link
|
||||||
@@ -557,7 +1004,7 @@ nothing left to apply. The progress count inside one large folder is unchanged.
|
|||||||
|
|
||||||
### B270 — One file left in a mirror folder clears the wipe guard for all the others
|
### B270 — One file left in a mirror folder clears the wipe guard for all the others
|
||||||
|
|
||||||
**Status:** Open
|
**Status:** Partly fixed
|
||||||
**Found:** 2026-10-31, code audit after issue #46, whose reporter emptied a synced folder
|
**Found:** 2026-10-31, code audit after issue #46, whose reporter emptied a synced folder
|
||||||
several times.
|
several times.
|
||||||
**Where:** `guard_local_wipe` and `guard_remote_wipe` in `crates/pdfs-fuse/src/sync/planner.rs`
|
**Where:** `guard_local_wipe` and `guard_remote_wipe` in `crates/pdfs-fuse/src/sync/planner.rs`
|
||||||
@@ -571,8 +1018,20 @@ that two computers sync, the other computer then deletes its copies too.
|
|||||||
path is enough to clear it, so it guards against an empty folder and nothing short of that.
|
path is enough to clear it, so it guards against an empty folder and nothing short of that.
|
||||||
The remote guard has the same shape.
|
The remote guard has the same shape.
|
||||||
|
|
||||||
**Fix.** Not done. Also refuse when most of a large baseline is missing at once, and leave the
|
**Fix.** Both guards also refuse a pass when nine in ten or more of the tracked paths of a
|
||||||
choice to the user: delete on the other side, or sync from scratch.
|
folder with at least 20 are missing at once (`looks_like_a_wipe`). The folder goes into error with
|
||||||
|
the count in its reason, `pdfs diagnose` reports it, and **Sync From Scratch…** brings the files
|
||||||
|
back. A smaller folder still stops only on a total loss, since an ordinary cleanup can delete most
|
||||||
|
of it.
|
||||||
|
|
||||||
|
**What is left.** A loss under nine in ten, such as a disk that is half there, still goes
|
||||||
|
through. The app does not offer the choice yet: confirm the deletions on the other side, or sync
|
||||||
|
from scratch.
|
||||||
|
|
||||||
|
**Test:** `one_survivor_of_a_large_folder_does_not_clear_the_wipe_guards`,
|
||||||
|
`a_small_folder_trips_the_wipe_guards_only_when_everything_is_gone` in
|
||||||
|
`crates/pdfs-fuse/src/sync/planner.rs`.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -911,7 +1370,8 @@ is why it helps.
|
|||||||
|
|
||||||
### B258 — `journalctl -p warning` shows nothing, whatever the daemon logged
|
### B258 — `journalctl -p warning` shows nothing, whatever the daemon logged
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, checked on 2026-11-01 on a live account: after an acceptance run,
|
||||||
|
`journalctl --user -u proton-drive.service -p warning` listed all 76 `WARN` lines the run logged
|
||||||
**Found:** 2026-10-31, issue #53: the reporter ran `journalctl --user -u proton-drive.service -b
|
**Found:** 2026-10-31, issue #53: the reporter ran `journalctl --user -u proton-drive.service -b
|
||||||
-p warning`, as `docs/TROUBLESHOOTING.md` tells them to, and got `-- No entries --`.
|
-p warning`, as `docs/TROUBLESHOOTING.md` tells them to, and got `-- No entries --`.
|
||||||
**Where:** `main` in `crates/pdfs-cli/src/main.rs`; `packaging/proton-drive.service`
|
**Where:** `main` in `crates/pdfs-cli/src/main.rs`; `packaging/proton-drive.service`
|
||||||
@@ -936,9 +1396,46 @@ are off. A terminal that merely inherited `JOURNAL_STREAM` keeps the plain forma
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### B256 — `pdfs copy` right after a save copies the old version
|
### B257 — Deleting a new file while it is open can drop the bytes its handle reads
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-27, the simulation
|
||||||
|
`a_file_deleted_offline_while_open_still_reads_and_takes_writes` failing once on CI with a cache
|
||||||
|
tag left behind, then on every run with a 50 ms pause added to `ContentCache::store_file`.
|
||||||
|
**Where:** `retire_create` in `crates/pdfs-fuse/src/drain.rs`; `queue_trash` and
|
||||||
|
`remove_replaced` in `crates/pdfs-fuse/src/lib.rs`; `trash_child` in
|
||||||
|
`crates/pdfs-fuse/src/filesystem.rs`; `ContentCache::reconcile` in `crates/pdfs-core/src/cache.rs`
|
||||||
|
|
||||||
|
**Repro:** Create a file in the mount, wait until `pdfs status` shows nothing queued, open it,
|
||||||
|
and delete it while the link is down. If the create was still landing, a partial write or a read
|
||||||
|
through the open handle fails with "Input/output error", and the cache can keep a tag with no
|
||||||
|
bytes until the next start.
|
||||||
|
|
||||||
|
**Cause.** A create that carries the file's bytes retires its op, then reads the node back,
|
||||||
|
caches the bytes and only then gives the tree the real uid. Nothing counted that window, so the
|
||||||
|
queue read empty while it ran, which B119 promises means the file reads without the network. A
|
||||||
|
delete in that window found the placeholder uid in the tree, and trashed the node by its real
|
||||||
|
uid. `queue_trash` asked the tree whether that uid was open, found no entry for it, and dropped
|
||||||
|
the cached bytes the open handle reads, which B186 had kept. When the cache store was running at
|
||||||
|
the same moment, its tag was written after the eviction, and nothing removed it.
|
||||||
|
|
||||||
|
**Fix.** The create's landing is counted like an upload's, from the op's retirement until the
|
||||||
|
tree has the real uid. The delete and the replace pass whether they found the file open, so
|
||||||
|
`queue_trash` no longer depends on the tree having the new uid. Opening the cache removes a tag
|
||||||
|
whose bytes are gone, which also covers a crash between an eviction's two removals.
|
||||||
|
|
||||||
|
**Test:** The simulation `a_new_file_deleted_while_open_as_its_create_lands_still_reads` holds a
|
||||||
|
create's landing and fails without the count. The simulation above passes again with the 50 ms
|
||||||
|
pause, since it now waits for `Daemon::drained`, which counts the landing. Unit test
|
||||||
|
`a_tag_without_its_blob_is_swept_at_open` in `crates/pdfs-core/src/cache.rs`. No test hits the
|
||||||
|
delete inside the cache store itself: the window is too short to hold from the simulation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B256 — `pdfs copy` right after a save copies the old version
|
||||||
|
|
||||||
|
**Status:** Fixed, checked on 2026-10-27 by the acceptance case "regression B256" on a live
|
||||||
|
account
|
||||||
**Found:** 2026-10-26, the simulation `a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes`
|
**Found:** 2026-10-26, the simulation `a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes`
|
||||||
failing on most runs. The feature was not released yet.
|
failing on most runs. The feature was not released yet.
|
||||||
**Where:** `release` in `crates/pdfs-fuse/src/filesystem.rs`; `copy_to` in
|
**Where:** `release` in `crates/pdfs-fuse/src/filesystem.rs`; `copy_to` in
|
||||||
@@ -962,7 +1459,8 @@ A move checks the same set.
|
|||||||
|
|
||||||
### B255 — A synced folder whose files are all gone here is stuck for good
|
### B255 — A synced folder whose files are all gone here is stuck for good
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, checked on 2026-10-27 by the acceptance case "regression B255" on a live
|
||||||
|
account
|
||||||
**Found:** 2026-10-26, issue #46: a folder restored from another computer never downloaded.
|
**Found:** 2026-10-26, issue #46: a folder restored from another computer never downloaded.
|
||||||
**Where:** `push_pass` in `crates/pdfs-fuse/src/sync.rs`; `reset_sync_folder` in
|
**Where:** `push_pass` in `crates/pdfs-fuse/src/sync.rs`; `reset_sync_folder` in
|
||||||
`crates/pdfs-fuse/src/devices.rs`
|
`crates/pdfs-fuse/src/devices.rs`
|
||||||
|
|||||||
@@ -11,6 +11,322 @@ 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.
|
||||||
|
|
||||||
|
- **Album covers for songs.** A song in My files, in search and in Shared shows its cover as its
|
||||||
|
thumbnail, and the player shows it above the controls instead of a music note. The cover is the
|
||||||
|
picture in the song's tags (MP3, FLAC, Ogg, Opus, M4A, WAV, AIFF, WavPack, Monkey's Audio), or
|
||||||
|
else a `cover`, `folder`, `front` or `album` image (`.jpg`, `.png`, `.webp`) in the same folder.
|
||||||
|
Only the song's tags are downloaded for it, not the whole song. Songs uploaded from this computer
|
||||||
|
carry their cover as their thumbnail, so they show it at once on other devices too. A cover
|
||||||
|
image added to a folder later is picked up once the song changes or the service restarts.
|
||||||
|
|
||||||
|
- **Resume a Google Photos import** (B288). The archives and the outcome of the last import are
|
||||||
|
kept, so an import cut short by a restart or a reboot no longer has to be set up again. The
|
||||||
|
Import page offers **Resume**, and `pdfs import-google-photos --resume` does the same. Photos
|
||||||
|
that are already uploaded are skipped as before.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`proton-sdk` and `proton-drive-rs` 0.12.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. 0.12.0 fixes the photo duplicate check and failed-upload drafts below.
|
||||||
|
[The 0.11.0 review](SDK-0.11.0.md) and [the 0.12.0 review](SDK-0.12.0.md) list the new
|
||||||
|
features and breaking changes.
|
||||||
|
|
||||||
|
- **The Google Photos import brings more of the export along** (B289).
|
||||||
|
- Places from Google Photos are kept, so imported photos show up under **Places**.
|
||||||
|
- Live photos and Pixel motion photos stay one photo: the video is filed with its still and
|
||||||
|
takes its date. The `.MP` half of a motion photo is no longer skipped.
|
||||||
|
- Only the Google Photos part of an export is imported. Files from Google Drive, YouTube and
|
||||||
|
other services in the same Takeout stay out of the timeline.
|
||||||
|
- A photo without a date of its own is dated by its file name and then by its EXIF before
|
||||||
|
the day it reached Google Photos.
|
||||||
|
- Favorites are also set on photos that were already in Proton Photos.
|
||||||
|
|
||||||
|
- **The import reports what it is doing** (B288). The Import page shows the current step
|
||||||
|
(reading, checking for photos you already have, uploading, sorting into albums), the counts
|
||||||
|
so far, and a progress bar that reaches the end. A scan says how many photos and how much
|
||||||
|
data an import would upload. `pdfs import-google-photos --wait` prints its progress, and
|
||||||
|
`pdfs import-status` shows the step.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Resuming a Google Photos import no longer fails at once** (#94, B286, B287). A photo upload
|
||||||
|
that was cut short left a draft behind, and every later run stopped with
|
||||||
|
`check for existing photos: serialization error: invalid type: null`. Such drafts are now
|
||||||
|
skipped by the duplicate check and replaced by the next upload of that photo, also after
|
||||||
|
signing in again. A failed upload removes its draft.
|
||||||
|
|
||||||
|
- **One bad archive or error no longer ends the whole import** (B288). An archive that cannot be
|
||||||
|
read is left out and named in the report, and the rest of the export imports. When an import
|
||||||
|
stops early, it keeps the counts of what it did and still files the uploaded photos into their
|
||||||
|
albums. A restart of the service while importing is reported as an interruption, not as a
|
||||||
|
finished import.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
- **Signed out after a cold boot on Fedora 45** (issue #78, B284, B285). Fedora 45 replaced the
|
||||||
|
GNOME keyring with oo7, and two things there could keep a refreshed sign-in from being saved:
|
||||||
|
oo7 reports the entries of a locked keyring as missing, which the client took for a sign-out,
|
||||||
|
and a restarted oo7 no longer knew the client's connection, which then failed every save until
|
||||||
|
the service restarted. The refreshed sign-in lived only in memory, and the next boot found the
|
||||||
|
spent one. The client now keeps such a sign-in and saves it once the keyring can be read, and
|
||||||
|
reconnects to a keyring service that restarted.
|
||||||
|
|
||||||
|
- **Thumbnails appear in the folder you are looking at as soon as they are made.** During
|
||||||
|
**Build thumbnails**, the folder on screen kept its file icons until you left it and opened it
|
||||||
|
again. Its tiles now fill in while the build runs, and the same goes for any tile whose
|
||||||
|
thumbnail is made while it is on screen.
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
Schema: **44**, unchanged.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **The app and the tray say why your Drive does not open** (B263, #40). When the service runs
|
||||||
|
but its mount keeps failing, the app shows **Can't open your Drive** with the last error and a
|
||||||
|
**Retry** button, instead of "Connecting…" for as long as it lasts. The tray says "can't open
|
||||||
|
your Drive", `pdfs diagnose` reports `mount: failing for …`, and
|
||||||
|
`systemctl --user status proton-drive` shows the error as the unit's status.
|
||||||
|
|
||||||
|
- **`pdfs diagnostics` shows what the sync engine is doing**, in a new `sync engine:` line:
|
||||||
|
idle, the folder it is on and for how long, or stopped. The journal warns when the engine has
|
||||||
|
stopped, or when a pass makes no progress for 15 minutes (B259, #53).
|
||||||
|
|
||||||
|
- **`pdfs diagnose` reports more problems** (B268, #53): writes that keep failing, data that has
|
||||||
|
waited a day or more to be sent, a paused sync, synced folders in error or conflict, and a
|
||||||
|
control socket file that nothing listens on.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **A mount that keeps failing retries less often**, doubling from 5 seconds to every 2 minutes,
|
||||||
|
instead of every 5 seconds forever (B263).
|
||||||
|
|
||||||
|
- **`proton-sdk` and `proton-drive-rs` 0.10.1.** Dependencies are updated, among them
|
||||||
|
`rusqlite` 0.41 with a newer bundled SQLite.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **My files no longer opens over a folder that already has files in it** (#67, B277). The files
|
||||||
|
were hidden under it, not uploaded, and came back only when the service stopped, so they looked
|
||||||
|
deleted. The service now waits until the folder is empty, and the app says why and offers to
|
||||||
|
open the folder or choose another one. The folder picker in Preferences refuses a folder with
|
||||||
|
files in it. **After upgrading:** if your `~/ProtonDrive` has files underneath, for example
|
||||||
|
saved there while the service was stopped, My files does not open until you move them out.
|
||||||
|
|
||||||
|
- **The sidebar no longer says "Proton Drive isn't running" while the service is running**
|
||||||
|
(B263, B277). When the service runs but cannot open My files, for example because its folder
|
||||||
|
has files in it, the status at the bottom of the sidebar and on the Sync page now reads
|
||||||
|
"Can't open your Drive" with the reason, like the page itself.
|
||||||
|
|
||||||
|
- **A synced folder stops instead of deleting when nearly all of it vanishes at once** (B270).
|
||||||
|
The guard against a lost folder, such as a disk that is not mounted, stopped a pass only when
|
||||||
|
every synced file was gone. One file left over was enough to clear it, and the next pass
|
||||||
|
trashed everything else on Proton Drive, and on every other computer syncing the folder. A
|
||||||
|
folder with 20 or more files and folders now also stops when nine in ten of them are missing,
|
||||||
|
on this computer or on Proton Drive. **Sync From Scratch…** brings them back.
|
||||||
|
|
||||||
|
- **My Files opens for accounts whose address keys depend on an inactive account key** (B265,
|
||||||
|
#40). The mount retried `missing key` forever. Address key tokens are now decrypted with every
|
||||||
|
account key the password opens, and one address whose keys do not open no longer breaks the
|
||||||
|
others.
|
||||||
|
|
||||||
|
- **A new folder starts its first sync at once** (B260, #53), instead of waiting until every
|
||||||
|
other folder was walked again. A folder watch that failed partway is retried every 10 minutes
|
||||||
|
instead of never (B262), and a folder removed and added again keeps its watch (B272).
|
||||||
|
|
||||||
|
- **A crash in one folder's sync no longer stops all syncing until a restart** (B259, #53). That
|
||||||
|
folder shows a sync error, and the next pass tries it again.
|
||||||
|
|
||||||
|
- **A folder reads "waiting for first sync" until its first sync finishes**, instead of "up to
|
||||||
|
date", on **Sync → Folders** and on the Computers page (B261, #53).
|
||||||
|
|
||||||
|
- **A large first sync goes on when one folder on Proton Drive fails to list** (B271, #46). It
|
||||||
|
used to start over from nothing. The folder that failed is tried again on the next pass, and
|
||||||
|
nothing under it is deleted.
|
||||||
|
|
||||||
|
- **A restore that skipped folders is reported as failed**, with the reason for each, instead of
|
||||||
|
"restored 0 folder(s)" as a success (B269).
|
||||||
|
|
||||||
|
- **`pdfs sync list` and `pdfs devices list` show when a folder last synced as a time**, such as
|
||||||
|
`synced: 11-01 22:07` in UTC like `pdfs activity`, instead of seconds since 1970 (B278, #46).
|
||||||
|
|
||||||
|
- **"You're offline" no longer appears for a cache or database error** when pinning, unpinning,
|
||||||
|
searching or loading a folder (B266, #38). The app shows the actual error.
|
||||||
|
|
||||||
|
- **A save no longer fails with "Input/output error"** when the file is opened for writing just
|
||||||
|
as its last change finishes uploading (B274).
|
||||||
|
|
||||||
|
- **`journalctl --user -u proton-drive.service -p warning` shows the service's warnings and
|
||||||
|
errors** (B258, #53). Every line was stored at info level, so the filter showed nothing.
|
||||||
|
|
||||||
|
- **Stopping the service no longer ends with a panic in the journal** (B276).
|
||||||
|
|
||||||
|
- **A failed mount attempt stops the workers it started** (B264). They kept running beside the
|
||||||
|
next attempt and held the database.
|
||||||
|
|
||||||
|
- **A new file reads without the network as soon as `pdfs status` shows nothing queued**
|
||||||
|
(B257). A new file's upload counted as done before its bytes were cached, so a script that
|
||||||
|
waited for an empty queue and then went offline could not read it. A new file deleted while
|
||||||
|
open in that moment could also lose the bytes its open handle reads, which made a write or
|
||||||
|
read through that handle fail with "Input/output error". The cache also removes leftover
|
||||||
|
tags of evicted files when it opens.
|
||||||
|
|
||||||
## [3.6.0] — 2026-10-27
|
## [3.6.0] — 2026-10-27
|
||||||
|
|
||||||
Schema: **44**, unchanged.
|
Schema: **44**, unchanged.
|
||||||
|
|||||||
+35
-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`). |
|
||||||
@@ -164,6 +196,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| `pdfs add-to-album UID PHOTOS...` | Add photos to an album. |
|
| `pdfs add-to-album UID PHOTOS...` | Add photos to an album. |
|
||||||
| `pdfs remove-from-album UID PHOTOS...` | Take photos out of an album. |
|
| `pdfs remove-from-album UID PHOTOS...` | Take photos out of an album. |
|
||||||
| `pdfs import-google-photos ARCHIVES... [--dry-run] [--wait]` | Import a Google Takeout export. Pass every `.zip` of the export. |
|
| `pdfs import-google-photos ARCHIVES... [--dry-run] [--wait]` | Import a Google Takeout export. Pass every `.zip` of the export. |
|
||||||
|
| `pdfs import-google-photos --resume [--wait]` | Start the last import again with the same archives after it was interrupted, stopped or failed. |
|
||||||
| `pdfs import-status` / `pdfs cancel-import` | Follow or stop a running import. |
|
| `pdfs import-status` / `pdfs cancel-import` | Follow or stop a running import. |
|
||||||
| `pdfs redate-photos [--dry-run] [--from DATE] [--to DATE] [--limit N] [--wait]` | Fix photos filed under their upload day when the file name carries the real date. |
|
| `pdfs redate-photos [--dry-run] [--from DATE] [--to DATE] [--limit N] [--wait]` | Fix photos filed under their upload day when the file name carries the real date. |
|
||||||
| `pdfs redate-status` / `pdfs cancel-redate` | Follow or stop a running re-date. |
|
| `pdfs redate-status` / `pdfs cancel-redate` | Follow or stop a running re-date. |
|
||||||
@@ -184,6 +217,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.
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ fix it.
|
|||||||
|
|
||||||
| Key | Type | Default | In the app | Meaning |
|
| Key | Type | Default | In the app | Meaning |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `mountpoint` | path | `~/ProtonDrive` | General → Location | Where My files is mounted |
|
| `mountpoint` | path | `~/ProtonDrive` | General → Location | Where My files is mounted. Must be empty: the service does not mount over files |
|
||||||
| `cache_budget` | bytes | 5 GiB | Storage → Cache | Soft limit for cached content. `0` means unlimited. Available-offline files do not count against it |
|
| `cache_budget` | bytes | 5 GiB | Storage → Cache | Soft limit for cached content. `0` means unlimited. Available-offline files do not count against it |
|
||||||
| `upload_limit` | bytes/s | none | General → Network | Shared cap for all uploads. `0` or `null` means none |
|
| `upload_limit` | bytes/s | none | General → Network | Shared cap for all uploads. `0` or `null` means none |
|
||||||
| `download_limit` | bytes/s | none | General → Network | Shared cap for all downloads, reads from the mount included |
|
| `download_limit` | bytes/s | none | General → Network | Shared cap for all downloads, reads from the mount included |
|
||||||
|
|||||||
+3
-1
@@ -28,7 +28,9 @@ Yes. `~/ProtonDrive` is your Proton Drive, not a copy. A deleted file moves to t
|
|||||||
Trash, and you can restore it from **Trash** in the app or the web. See [Trash](FILES.md#trash).
|
Trash, and you can restore it from **Trash** in the app or the web. See [Trash](FILES.md#trash).
|
||||||
|
|
||||||
Stopping the service is not the same: once the service stops, `~/ProtonDrive` is just an empty
|
Stopping the service is not the same: once the service stops, `~/ProtonDrive` is just an empty
|
||||||
folder, and your files stay on Proton Drive.
|
folder, and your files stay on Proton Drive. Keep it empty: the service does not open My files
|
||||||
|
over a folder with files in it, because it would hide them. See
|
||||||
|
[My files does not open: the folder is not empty](TROUBLESHOOTING.md#my-files-does-not-open-the-folder-is-not-empty).
|
||||||
|
|
||||||
## Online only, available offline or synced folder: which do I want?
|
## Online only, available offline or synced folder: which do I want?
|
||||||
|
|
||||||
|
|||||||
+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.6.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,129 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## A restarted Secret Service
|
||||||
|
|
||||||
|
A second path to the same symptom, independent of locking (B285). The keyring store pdfs used
|
||||||
|
holds one D-Bus connection and one Secret Service session for the whole process, and was
|
||||||
|
installed once. The session lives only in the provider's memory. When the provider restarts,
|
||||||
|
the new one answers every `GetSecret` and `SetSecret` on the old session with
|
||||||
|
`org.freedesktop.Secret.Error.NoSession`, until the pdfs process itself restarts. Reproduced
|
||||||
|
with both gnome-keyring 51.1 and oo7 0.7.1 using `--replace`.
|
||||||
|
|
||||||
|
gnome-keyring practically never restarts within a desktop session. oo7 can:
|
||||||
|
`oo7-daemon.service` has `Restart=on-failure`, the daemon allows itself to be replaced, and it
|
||||||
|
exits on a wrong credential password and is D-Bus-activated again. A daemon that met a restarted
|
||||||
|
provider kept every later token refresh only in memory and the kernel keyring, and a cold boot
|
||||||
|
then left the spent refresh token in the saved blob.
|
||||||
|
|
||||||
|
Now a platform failure drops the store and the call is retried once on a new connection. The
|
||||||
|
daemon log shows `the Secret Service failed this process's connection; reconnecting` when that
|
||||||
|
happens. To check whether a machine meets this, look for oo7 restarts in the same boot:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
journalctl --user -b -1 -u oo7-daemon.service | grep -E 'Started|Stopped|Starting oo7|Main process exited'
|
||||||
|
journalctl --user -b -1 -u proton-drive.service | grep -iE 'keyring|reconnecting|refresh'
|
||||||
|
```
|
||||||
|
|
||||||
|
Reproduce in a throwaway session bus (never against the desktop's keyring; the runtime directory
|
||||||
|
must be short, as it holds a Unix socket):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
T=$(mktemp -d /tmp/ss.XXXX); mkdir -p $T/data $T/run; chmod 700 $T/run
|
||||||
|
XDG_DATA_HOME=$T/data XDG_RUNTIME_DIR=$T/run PDFS_SECRET_SERVICE_RESTART_TEST=1 \
|
||||||
|
PDFS_RESTART_PROVIDER='echo -n pw | gnome-keyring-daemon --replace --unlock --components=secrets >/dev/null' \
|
||||||
|
dbus-run-session -- sh -c 'echo -n pw | gnome-keyring-daemon --unlock --components=secrets >/dev/null; sleep 1;
|
||||||
|
cargo test -p pdfs-core real_secret_service -- --ignored'
|
||||||
|
```
|
||||||
|
|
||||||
|
For oo7, start `oo7-daemon --login` with the password on stdin instead, and restart it with
|
||||||
|
`--login --replace` in the background.
|
||||||
|
|
||||||
|
## 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.
|
||||||
+17
-2
@@ -55,6 +55,7 @@ server learns which areas you look at.
|
|||||||
2. In the app, open the **Upload** menu on the Photos page and choose **Import from Google
|
2. In the app, open the **Upload** menu on the Photos page and choose **Import from Google
|
||||||
Photos…**. Select all the `.zip` files at once, because a photo and its details are often in
|
Photos…**. Select all the `.zip` files at once, because a photo and its details are often in
|
||||||
different parts of the export.
|
different parts of the export.
|
||||||
|
3. Choose **Scan** to see what an import would do without uploading anything, then **Import**.
|
||||||
|
|
||||||
From a terminal:
|
From a terminal:
|
||||||
|
|
||||||
@@ -63,8 +64,22 @@ pdfs import-google-photos --dry-run ~/Downloads/takeout-*.zip # show what woul
|
|||||||
pdfs import-google-photos --wait ~/Downloads/takeout-*.zip
|
pdfs import-google-photos --wait ~/Downloads/takeout-*.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
The import recreates your albums and takes capture dates and locations from the export. Photos
|
What the import takes from the export:
|
||||||
already in your library are skipped, so an interrupted import can simply be run again.
|
|
||||||
|
- **Albums** are recreated. A photo that is in several albums is uploaded once and added to each.
|
||||||
|
- **Dates** come from the export's details, else from a date in the file name, else from the
|
||||||
|
photo's EXIF.
|
||||||
|
- **Places** come from the export, so imported photos appear under [Places](#places).
|
||||||
|
- **Favorites** are kept.
|
||||||
|
- **Live photos and motion photos** stay one photo: the video is filed with its still.
|
||||||
|
|
||||||
|
What it leaves out: photos in Google's trash, and files from other Google services (Drive,
|
||||||
|
YouTube) that are in the same export. A `.zip` that cannot be read is left out and named in the
|
||||||
|
report; the rest still imports.
|
||||||
|
|
||||||
|
Photos already in your library are skipped, so an interrupted import can simply be run again.
|
||||||
|
If the import was cut short, for example by a restart, the Import page offers **Resume**, and
|
||||||
|
`pdfs import-google-photos --resume` does the same without naming the archives again.
|
||||||
`pdfs import-status` shows how far an import is, and `pdfs cancel-import` stops it.
|
`pdfs import-status` shows how far an import is, and `pdfs cancel-import` stops it.
|
||||||
|
|
||||||
## Fixing photos filed under the wrong date
|
## Fixing photos filed under the wrong date
|
||||||
|
|||||||
+2
-1
@@ -63,7 +63,8 @@ them along.
|
|||||||
| Add someone else's photo to your album | Blocked | Needs `copyPhoto` in the SDK |
|
| Add someone else's photo to your album | Blocked | Needs `copyPhoto` in the SDK |
|
||||||
| Photo name search | Not started | Photos search by date and filters only |
|
| Photo name search | Not started | Photos search by date and filters only |
|
||||||
| Automatic photo upload from a folder | Not started | |
|
| Automatic photo upload from a folder | Not started | |
|
||||||
| Faster Google Photos import | Not started | Uploads run one at a time. Trash folders of locales missing from `TRASH_FOLDERS` import as photos; motion-photo `.MP` parts are skipped |
|
| Faster Google Photos import | Not started | Uploads run one at a time. Trash folders of locales missing from `TRASH_FOLDERS` import as photos |
|
||||||
|
| More of a Google Photos export | Not started | `.tgz` exports and extracted folders; shared albums told apart from own ones; album descriptions and covers; photo descriptions |
|
||||||
| Content-addressed block cache | Idea | Store blocks by hash to deduplicate identical content |
|
| Content-addressed block cache | Idea | Store blocks by hash to deduplicate identical content |
|
||||||
|
|
||||||
## Performance and correctness
|
## Performance and correctness
|
||||||
|
|||||||
@@ -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.
|
||||||
Whitespace-only changes.
+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.
|
||||||
+18
-3
@@ -141,6 +141,20 @@ fusermount3 -uz ~/ProtonDrive
|
|||||||
systemctl --user restart proton-drive.service
|
systemctl --user restart proton-drive.service
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### My files does not open: the folder is not empty
|
||||||
|
|
||||||
|
The app says **Your Proton Drive Folder Isn't Empty**, and `pdfs diagnose` reports
|
||||||
|
`mount: failing … is not empty`. The folder chosen for My files has files in it, and the service
|
||||||
|
does not open My files over them: they would be hidden, and not uploaded, for as long as the
|
||||||
|
service runs.
|
||||||
|
|
||||||
|
Move the files out of the folder, or choose another folder in **Preferences → General →
|
||||||
|
Location**. The service tries again on its own and opens once the folder is empty. To put the
|
||||||
|
files on Proton Drive, copy them into My files once it is open.
|
||||||
|
|
||||||
|
Files saved into `~/ProtonDrive` while the service was stopped are in the same place: in the
|
||||||
|
local folder, not on Proton Drive.
|
||||||
|
|
||||||
### The folder is empty or missing
|
### The folder is empty or missing
|
||||||
|
|
||||||
Check that the service runs and is signed in, with `pdfs status`. The service waits for a stored
|
Check that the service runs and is signed in, with `pdfs status`. The service waits for a stored
|
||||||
@@ -234,9 +248,10 @@ Check that:
|
|||||||
|
|
||||||
### A synced folder stays in error and nothing downloads
|
### A synced folder stays in error and nothing downloads
|
||||||
|
|
||||||
The log says `every one of the N synced paths is missing locally; refusing to trash them on
|
The log says `M of the N synced paths are missing locally; refusing to trash them on Drive`.
|
||||||
Drive`. The folder synced before, and now none of its files are on this computer. The sync
|
The folder synced before, and now all or nearly all of its files are gone from this computer:
|
||||||
stops so that it does not delete them on Proton Drive too. Check that the folder is the right
|
everything, or nine in ten of a folder with 20 or more files and folders. The sync stops so that
|
||||||
|
it does not delete them on Proton Drive too. Check that the folder is the right
|
||||||
one and that its disk is mounted. To get the files back, choose **Sync From Scratch…** in the
|
one and that its disk is mounted. To get the files back, choose **Sync From Scratch…** in the
|
||||||
folder's menu on **Sync → Folders**, or run:
|
folder's menu on **Sync → Folders**, or run:
|
||||||
|
|
||||||
|
|||||||
@@ -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.6.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.
|
||||||
|
|||||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user