Compare commits

..
Author SHA1 Message Date
nvrl 7d445bb02c Merge pull request #84 from narrrl/feat/video-thumbnails
feat(gui,fuse,core): video thumbnails in file views
2026-11-14 03:43:36 +01:00
65 changed files with 9459 additions and 16233 deletions

No files matched your search

Generated
+8 -51
View File
@@ -1114,12 +1114,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]] [[package]]
name = "dbl" name = "dbl"
version = "0.3.2" version = "0.3.2"
@@ -2908,32 +2902,6 @@ dependencies = [
"scopeguard", "scopeguard",
] ]
[[package]]
name = "lofty"
version = "0.25.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35d45c446382e04663705aa8e786157f64ef1fa85755d410121ffbf08f54b0db"
dependencies = [
"byteorder",
"data-encoding",
"flate2",
"lofty_attr",
"log",
"ogg_pager",
"paste",
]
[[package]]
name = "lofty_attr"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdbca1b60ba7cea959ffc84ac27f3dbdbacd6dfda168c7eabbc00e48654bb9fd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.35" version = "0.4.35"
@@ -3355,15 +3323,6 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "ogg_pager"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "166611b491f939446e64c6be930843a31eb8e6798204ec3e9f57e084c18e7446"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -3582,7 +3541,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "3.8.0" version = "3.7.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3599,7 +3558,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "3.8.0" version = "3.7.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3627,7 +3586,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "3.8.0" version = "3.7.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3635,12 +3594,10 @@ dependencies = [
"futures", "futures",
"image", "image",
"libc", "libc",
"lofty",
"notify", "notify",
"parking_lot 0.13.0", "parking_lot 0.13.0",
"pdfs-core", "pdfs-core",
"proton-drive-rs", "proton-drive-rs",
"serde",
"serde_json", "serde_json",
"sha1 0.11.0", "sha1 0.11.0",
"tokio", "tokio",
@@ -3651,7 +3608,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "3.8.0" version = "3.7.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3891,9 +3848,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.12.0" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4265c2cf3bdc59ee273d7363c8b6a7cb4e98be6102ffa18144c4d8ebda00737b" checksum = "14a22e621abbc1c61b4640728a7de969c16b66d5d983b59c638d501fda25a7b9"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3919,9 +3876,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.12.0" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb98ab3e336ed95f82368a50348dad00e5fc919e982d748e83e844afcadbd5d8" checksum = "bd55a5e8b6600f3aa282ebbdf13ff366431ff9aae84d4eb20b0ef06d598919fe"
dependencies = [ dependencies = [
"aes-gcm 0.11.1", "aes-gcm 0.11.1",
"async-trait", "async-trait",
+3 -7
View File
@@ -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.8.0" version = "3.7.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"] categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.12.0" } proton-sdk = { version = "0.11.0" }
proton-drive-rs = { version = "0.12.0" } proton-drive-rs = { version = "0.11.0" }
chrono = { version = "0.4", default-features = false, features = ["std"] } chrono = { version = "0.4", default-features = false, features = ["std"] }
# Internal crates. # Internal crates.
@@ -50,10 +50,6 @@ 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"] }
# 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"] } 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
+38 -180
View File
@@ -228,19 +228,14 @@ 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, or resumed with /// so an interrupted import can simply be run again.
/// `--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_unless_present = "resume", value_hint = ValueHint::FilePath)] #[arg(required = true, value_hint = ValueHint::FilePath)]
archives: Vec<PathBuf>, archives: Vec<PathBuf>,
/// Report what would be imported without uploading anything. /// Report what would be imported without uploading anything.
#[arg(long, conflicts_with = "resume")] #[arg(long)]
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,
@@ -1035,9 +1030,8 @@ fn main() -> Result<()> {
Command::ImportGooglePhotos { Command::ImportGooglePhotos {
archives, archives,
dry_run, dry_run,
resume,
wait, wait,
} => cmd_import_google_photos(archives, dry_run, resume, wait), } => cmd_import_google_photos(archives, dry_run, wait),
Command::RedatePhotos { Command::RedatePhotos {
dry_run, dry_run,
limit, limit,
@@ -2922,34 +2916,24 @@ fn photo_count(n: usize) -> String {
} }
} }
fn cmd_import_google_photos( fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -> Result<()> {
archives: Vec<PathBuf>, // The daemon opens the archives itself off the shared filesystem, so it gets
dry_run: bool, // absolute paths — the same arrangement `pdfs upload` uses.
resume: bool, let mut absolute = Vec::with_capacity(archives.len());
wait: bool, for archive in &archives {
) -> Result<()> { let path = std::fs::canonicalize(archive)
let request = if resume { .with_context(|| format!("resolve {}", archive.display()))?;
CtlRequest::ResumeImport absolute.push(
} else { path.to_str()
// The daemon opens the archives itself off the shared filesystem, so it .ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display()))?
// gets absolute paths — the same arrangement `pdfs upload` uses. .to_owned(),
let mut absolute = Vec::with_capacity(archives.len()); );
for archive in &archives { }
let path = std::fs::canonicalize(archive)
.with_context(|| format!("resolve {}", archive.display()))?;
absolute.push(
path.to_str()
.ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display()))?
.to_owned(),
);
}
CtlRequest::ImportTakeout {
archives: absolute,
dry_run,
}
};
match control_request(request)? { match control_request(CtlRequest::ImportTakeout {
archives: absolute,
dry_run,
})? {
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:?}"),
@@ -2961,34 +2945,20 @@ fn cmd_import_google_photos(
// 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. A line is printed when the step changes and // connection the daemon has.
// 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,
progress, summary: _,
.. } => continue,
} => { CtlResponse::ImportStatus { summary, .. } => {
let line = progress.as_ref().map(progress_line).unwrap_or_default(); print_import_summary(summary.as_ref());
let step_changed = line.split(':').next() != last_line.split(':').next(); return finished_run(
if !line.is_empty() "the import",
&& line != last_line summary.map(|s| (s.failed, s.cancelled, s.error)),
&& (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:?}"),
@@ -2996,47 +2966,6 @@ fn cmd_import_google_photos(
} }
} }
/// 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>,
@@ -3171,19 +3100,11 @@ 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 { CtlResponse::ImportStatus { running, summary } => {
running,
summary,
progress,
resumable,
} => {
if running { if running {
match progress.as_ref() { println!("Import running. `pdfs transfers` shows its progress.");
Some(progress) => println!("Import running. {}", progress_line(progress)),
None => println!("Import running."),
}
} }
print_import_summary(summary.as_ref(), &resumable); print_import_summary(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:?}"),
@@ -3200,28 +3121,18 @@ fn cmd_cancel_import() -> Result<()> {
Ok(()) Ok(())
} }
fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>, resumable: &[String]) { fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>) {
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!("Stopped by : {error}"); println!("Failed : {error}");
return;
} }
println!("Photos in export : {}", summary.found); println!("Photos in export : {}", summary.found);
if summary.dry_run { println!("Uploaded : {}", summary.uploaded);
println!("Would upload : {}", summary.to_upload);
println!("Bytes to upload : {}", summary.to_upload_bytes);
} else {
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);
} }
@@ -3234,20 +3145,12 @@ fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>, res
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.interrupted { if summary.cancelled {
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<()> {
@@ -4606,51 +4509,6 @@ 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;
+33 -253
View File
@@ -123,84 +123,13 @@ fn client_config() -> ProtonClientConfiguration {
ProtonClientConfiguration::new(app_version).with_user_agent(user_agent) ProtonClientConfiguration::new(app_version).with_user_agent(user_agent)
} }
/// Run `op` on the session's keyring entry. fn keyring_entry() -> Result<Entry> {
fn with_keyring<T>(op: impl Fn(&Entry) -> keyring_core::Result<T>) -> Result<T> { // Installed lazily rather than once at startup so a session bus that was not
with_keyring_entry(KEYRING_SERVICE, KEYRING_USER, op) // up yet on the first attempt is retried on the next one.
} 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.
@@ -492,15 +421,17 @@ 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)?);
with_keyring(|entry| entry.set_password(&json)) keyring_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> {
match with_keyring(|entry| entry.get_password()) { let entry = keyring_entry()?;
match entry.get_password() {
Ok(json) => Ok(serde_json::from_str(&json)?), Ok(json) => Ok(serde_json::from_str(&json)?),
Err(Error::Keyring(keyring_core::Error::NoEntry)) => Err(Error::NotLoggedIn), Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
Err(e) => Err(e), Err(e) => Err(e.into()),
} }
} }
@@ -658,9 +589,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 with_keyring(|entry| entry.delete_credential()) { match keyring_entry()?.delete_credential() {
Ok(()) | Err(Error::Keyring(keyring_core::Error::NoEntry)) => Ok(ended), Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(ended),
Err(e) => Err(e), Err(e) => Err(e.into()),
} }
} }
@@ -1065,15 +996,21 @@ impl TokenSink {
} }
} }
/// Write the unsaved rotation, if there is one. A blob that moved on drops /// Write the unsaved rotation, if there is one. A blob that moved on, or
/// it. A missing blob is retried: some Secret Service providers also report /// is gone with a logout, drops it: there is nothing it may still go into.
/// 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 = load()?; let mut stored = match 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,
@@ -1276,138 +1213,12 @@ 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();
let store: Arc<keyring_core::CredentialStore> = keyring_core::mock::Store::new().unwrap(); keyring_core::set_default_store(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()
} }
@@ -1462,7 +1273,12 @@ 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()));
fail_next(keyring_core::Error::NoStorageAccess( let entry = keyring_entry().unwrap();
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());
@@ -1490,42 +1306,6 @@ 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 {
-6
View File
@@ -562,12 +562,6 @@ 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")
+11 -135
View File
@@ -279,11 +279,6 @@ 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
@@ -773,9 +768,6 @@ 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,
@@ -785,7 +777,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; 10] = [ pub const ALL: [Topic; 9] = [
Topic::Files, Topic::Files,
Topic::Photos, Topic::Photos,
Topic::Trash, Topic::Trash,
@@ -795,7 +787,6 @@ impl Topic {
Topic::Queue, Topic::Queue,
Topic::Conflicts, Topic::Conflicts,
Topic::Activity, Topic::Activity,
Topic::Thumbnails,
]; ];
} }
@@ -1694,75 +1685,10 @@ 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 stopped early, when it did, such as the /// Why the run as a whole failed, when it did, such as an archive that
/// account being unreachable. The counts say how far it got. /// is not a zip. The counts are then empty.
#[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.
@@ -1866,11 +1792,6 @@ 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
@@ -1951,26 +1872,11 @@ pub fn is_video_file_name(name: &str, size: Option<u64>) -> bool {
!transport_stream || size.is_none_or(|size| size >= TS_VIDEO_MIN_BYTES) !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` /// 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, /// bytes when known: an image [`is_thumbnail_image_name`] accepts, or a video,
/// whose thumbnail is a frame the daemon takes with ffmpeg, or a song, whose /// whose thumbnail is a frame the daemon takes with ffmpeg.
/// 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 { 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) is_thumbnail_image_name(name) || is_video_file_name(name, size)
} }
/// Whether `name` is one of the concrete RAW formats handled by the exiftool /// Whether `name` is one of the concrete RAW formats handled by the exiftool
@@ -2278,17 +2184,8 @@ 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
@@ -2761,16 +2658,6 @@ 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");
@@ -2835,7 +2722,7 @@ mod tests {
} }
#[test] #[test]
fn listings_thumbnail_images_videos_and_songs() { fn listings_thumbnail_images_and_videos() {
assert!(is_thumbnail_media_name("photo.JPG", Some(10))); assert!(is_thumbnail_media_name("photo.JPG", Some(10)));
assert!(is_thumbnail_media_name("camera.nef", None)); assert!(is_thumbnail_media_name("camera.nef", None));
assert!(is_thumbnail_media_name("clip.mp4", Some(10))); assert!(is_thumbnail_media_name("clip.mp4", Some(10)));
@@ -2844,24 +2731,13 @@ mod tests {
Some(64 * 1024 * 1024) Some(64 * 1024 * 1024)
)); ));
assert!(!is_thumbnail_media_name("app.ts", Some(2048))); 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(
assert!(is_thumbnail_media_name("Track 01.MP3", None)); "song.flac",
Some(64 * 1024 * 1024)
));
assert!(!is_thumbnail_media_name("notes.txt", 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;
-4
View File
@@ -186,8 +186,6 @@ 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<_>>()?;
@@ -249,8 +247,6 @@ 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<_>>()?;
+1 -4
View File
@@ -40,8 +40,6 @@ 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
@@ -258,7 +256,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.place_id \ p.kind, p.favorite, g.n, g.raw \
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)";
@@ -274,7 +272,6 @@ 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)?,
}) })
} }
-17
View File
@@ -363,23 +363,6 @@ 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]
+70 -488
View File
@@ -25,9 +25,7 @@
//! 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>`. The Photos service //! second is the service, and the rest is `<folder>/<file>`. A folder is an
//! 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
@@ -123,14 +121,8 @@ 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> {
@@ -155,13 +147,6 @@ 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 {
@@ -177,49 +162,14 @@ 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 the sidecar's `photoTakenTime`, /// Capture time in epoch seconds, from `photoTakenTime` (falling back to
/// else from a date in the file name. `None` when neither has one; the /// `creationTime`). `None` when the photo has no sidecar — the upload then
/// importer then tries the file's EXIF, then [`creation_time`](Self::creation_time). /// defaults it to now, as the SDK does.
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.
@@ -233,16 +183,8 @@ 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), and media from other Google services in the same /// activity `.csv`s, `.mp` motion-photo parts).
/// 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 {
@@ -253,7 +195,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 people tags, a description and view counts. /// file also carries geo data, people tags and view counts.
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct PhotoSidecar { struct PhotoSidecar {
#[serde(default)] #[serde(default)]
@@ -263,69 +205,19 @@ struct PhotoSidecar {
#[serde(rename = "creationTime", default)] #[serde(rename = "creationTime", default)]
creation_time: Option<SidecarTime>, creation_time: Option<SidecarTime>,
#[serde(default)] #[serde(default)]
favorited: Option<bool>, favorited: 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>,
} }
impl PhotoSidecar { /// A sidecar timestamp: epoch seconds, as a *string*.
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<SidecarNumber>, timestamp: Option<String>,
}
#[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> {
let seconds = match self.timestamp.as_ref()? { self.timestamp.as_ref()?.parse().ok()
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,
})
} }
} }
@@ -370,58 +262,21 @@ 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> {
scan_archives_until(archives, || false) let mut entries: Vec<RawEntry> = Vec::new();
} 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() {
if stop() { let file = std::fs::File::open(path)
return Err(CoreError::conflict("the import was cancelled")); .map_err(|e| CoreError::invalid(format!("cannot open {}: {e}", path.display())))?;
} let mut zip = zip::ZipArchive::new(std::io::BufReader::new(file)).map_err(|e| {
let opened = std::fs::File::open(path) CoreError::invalid(format!("{} is not a readable zip: {e}", path.display()))
.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() {
if position % 512 == 0 && stop() { let mut entry = zip.by_index(position).map_err(|e| {
return Err(CoreError::conflict("the import was cancelled")); CoreError::invalid(format!("{}: unreadable entry: {e}", path.display()))
} })?;
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;
} }
@@ -431,92 +286,32 @@ pub fn scan_archives_until(
continue; continue;
}; };
let name = name.to_string_lossy().replace('\\', "/"); let name = name.to_string_lossy().replace('\\', "/");
let Some((service, relative)) = strip_export_prefix(&name) else { let Some(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") {
if size <= SIDECAR_MAX_BYTES { let mut body = Vec::with_capacity(size.min(64 * 1024) as usize);
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((
service, entries.push(RawEntry {
RawEntry { archive: index,
archive: index, entry: name.clone(),
entry: name, relative: relative.to_string(),
relative, size,
size, });
},
));
} }
} }
let (entries, sidecars, other_services) = keep_photos_services(entries, sidecars); Ok(assemble(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
@@ -608,19 +403,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. `creationTime` // Sidecar first, then the date stamped into the name.
// 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,
@@ -629,57 +424,31 @@ 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,
creation_time, favorite: sidecar.favorited,
location: sidecar.location(),
favorite: sidecar.favorited.unwrap_or(false),
bucket, bucket,
live_key: live_key(&folder, &file_name),
}); });
} }
scan scan
} }
/// Split a path inside the archive into the localized service folder and the /// Drop the export root (`Takeout/`) and the localized service folder from a
/// rest, dropping the export root (`Takeout/`): `("Google Fotos", /// path inside the archive, leaving `<folder>/<file>` or `<file>`.
/// "<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, &str)> { fn strip_export_prefix(path: &str) -> Option<&str> {
let mut rest = path; let mut rest = path;
// The root is `Takeout/`, but an export unzipped and re-zipped by the user // The root is usually `Takeout/`, but an export unzipped and re-zipped by
// may not have it. `Google Photos/<album>/<file>` then has three parts like // the user may not have it — so the root is dropped only when a service
// `Takeout/Google Photos/<file>`, so the count alone cannot tell them // folder follows it, which the component count decides.
// 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 (first.eq_ignore_ascii_case("takeout") && components >= 3) || components >= 4 { if components >= 3 {
rest = after; rest = rest.split_once('/')?.1;
} }
// The service folder (`Google Photos`, `Google Fotos`, …). // Drop the service folder (`Google Photos`, `Google Fotos`, …).
rest.split_once('/') let (_service, tail) = 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.
@@ -1089,13 +858,13 @@ mod tests {
RawEntry { RawEntry {
archive, archive,
entry: path.to_string(), entry: path.to_string(),
relative: strip_export_prefix(path).unwrap().1.to_string(), relative: strip_export_prefix(path).unwrap().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().1.to_string(); let relative = strip_export_prefix(path).unwrap().to_string();
map.insert(relative, body.as_bytes().to_vec()); map.insert(relative, body.as_bytes().to_vec());
} }
@@ -1103,17 +872,12 @@ 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(("Google Fotos", "Island 2019/IMG_1.jpg")) Some("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(("Google Photos", "IMG_1.jpg")) Some("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);
@@ -1193,144 +957,23 @@ mod tests {
} }
#[test] #[test]
fn creation_time_is_kept_apart_as_the_last_resort() { fn creation_time_is_the_fallback_capture_time() {
// `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![ vec![entry(
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_1.jpg", 10), 0,
entry( "Takeout/Google Photos/Photos from 2019/IMG_1.jpg",
0, 10,
"Takeout/Google Photos/Photos from 2019/IMG_20190612_101500.jpg", )],
10,
),
],
&sidecars, &sidecars,
); );
assert_eq!(scan.photos[0].capture_time, None); assert_eq!(scan.photos[0].capture_time, Some(1_500_000_000));
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]
@@ -1744,77 +1387,16 @@ mod tests {
} }
#[test] #[test]
fn a_file_that_is_not_a_zip_is_left_out_and_the_rest_imports() { fn a_file_that_is_not_a_zip_is_a_readable_error() {
let broken = let path =
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(&broken, b"not a zip at all").unwrap(); std::fs::write(&path, b"not a zip at all").unwrap();
let good = write_zip( let error = scan_archives(std::slice::from_ref(&path)).unwrap_err();
"beside-broken", let _ = std::fs::remove_file(&path);
&[("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!(
scan.unreadable_archives[0] error.message.contains("not a readable zip"),
.reason "unexpected message: {}",
.contains("not a readable zip"), error.message
"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());
}
} }
-2
View File
@@ -20,11 +20,9 @@ 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
+1 -35
View File
@@ -157,17 +157,6 @@ 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 {
@@ -183,12 +172,9 @@ 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 && kept.is_none() { if had_node {
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`].
@@ -219,8 +205,6 @@ 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();
@@ -312,21 +296,6 @@ 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
@@ -507,9 +476,6 @@ 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
+34 -83
View File
@@ -204,42 +204,18 @@ 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 in_mirror = core let mirrored = 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 in_mirror { if mirrored {
CoreError::invalid(mirrored(path)) CoreError::invalid(format!(
"{path} is in a mirrored folder; change the local file there instead"
))
} else { } else {
e e
} }
@@ -255,18 +231,6 @@ fn route_within(
} }
} }
/// 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
@@ -557,8 +521,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 route_keep(core, mountpoint, &path) { Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
Ok((routed, rel)) => match routed.pin(&rel, &pin_record(&routed, &rel, &path)) { Ok(rel) => match core.pin(&rel) {
Ok(name) => { Ok(name) => {
core.touch_profile(); core.touch_profile();
CtlResponse::Ok { CtlResponse::Ok {
@@ -569,8 +533,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 route_keep(core, mountpoint, &path) { Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
Ok((routed, rel)) => match routed.unpin(&rel) { Ok(rel) => match core.unpin(&rel) {
Ok(name) => { Ok(name) => {
core.touch_profile(); core.touch_profile();
CtlResponse::Ok { CtlResponse::Ok {
@@ -877,23 +841,35 @@ 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 = archives.into_iter().map(PathBuf::from).collect(); let paths: Vec<PathBuf> = archives.into_iter().map(PathBuf::from).collect();
start_takeout_import(core, paths, dry_run) let missing: Vec<String> = paths
.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 report = crate::takeout::import_status(); let (running, summary) = crate::takeout::import_status();
CtlResponse::ImportStatus { CtlResponse::ImportStatus { running, summary }
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 {
@@ -1997,31 +1973,6 @@ 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::*;
-271
View File
@@ -1,271 +0,0 @@
//! 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());
}
}
+17 -54
View File
@@ -91,7 +91,6 @@ 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;
@@ -590,8 +589,6 @@ 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>,
@@ -3168,28 +3165,6 @@ 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
@@ -4295,12 +4270,7 @@ 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();
@@ -4324,7 +4294,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, recorded, true) .add_pin(&uid, rel, 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();
@@ -4334,15 +4304,9 @@ 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, recorded, false) .add_pin(&uid, rel, false)
.map_err(|e| CoreError::from_api(&e, "pin"))?; .map_err(|e| CoreError::from_api(&e, "pin"))?;
Ok(name) Ok(name)
} }
@@ -4360,20 +4324,14 @@ 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 ino in files { for (uid, file, mtime, size) in files {
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) { if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
return; return;
} }
// A file with a queued write, or no readable size, is job.detail(file.clone());
// left to the drain and the keeper (`keeper::kept_file`). if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
if let Some(file) = self.kept_file(ino) { warn!(%uid, error = %e, "pin folder: download failed");
job.detail(file.name.clone()); failed += 1;
if let Err(e) =
self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size)
{
warn!(uid = %file.uid, error = %e, "pin folder: download failed");
failed += 1;
}
} }
job.step(); job.step();
} }
@@ -4407,9 +4365,9 @@ impl Core {
Ok(()) Ok(())
} }
/// The inode of every file in the subtree rooted at folder `ino`. /// Every file in the subtree rooted at folder `ino`.
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<u64>> { fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
let mut files: Vec<u64> = Vec::new(); let mut files: Vec<(NodeUid, String, i64, 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() {
@@ -4424,7 +4382,12 @@ impl Core {
if e.node.is_folder() { if e.node.is_folder() {
stack.push(k); stack.push(k);
} else { } else {
files.push(k); files.push((
e.uid.clone(),
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
));
} }
} }
} }
-3
View File
@@ -286,9 +286,6 @@ 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.
-12
View File
@@ -511,7 +511,6 @@ 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())),
@@ -622,17 +621,6 @@ 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) {
+5 -561
View File
@@ -19,7 +19,7 @@ use std::time::{Duration, Instant};
use base64::Engine as _; use base64::Engine as _;
use pdfs_core::control::{ use pdfs_core::control::{
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo, FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
ThumbnailBuildStatus, Topic, is_audio_file_name, is_raw_image_name, is_thumbnail_image_name, ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name,
is_thumbnail_media_name, is_thumbnail_media_name,
}; };
use pdfs_core::db::{self, StoredPhoto}; use pdfs_core::db::{self, StoredPhoto};
@@ -76,36 +76,6 @@ const VIDEO_BOX_WALK_MAX: usize = 32;
/// A single frame is seconds of work even for 4K; this only stops a wedged /// A single frame is seconds of work even for 4K; this only stops a wedged
/// ffmpeg from holding a thumbnail permit. /// ffmpeg from holding a thumbnail permit.
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30); const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
/// A song no larger than this is downloaded whole for its cover.
const AUDIO_WHOLE_MAX_BYTES: u64 = 4 * 1024 * 1024;
/// How much of a song's start is read first, to learn where its tags end.
const AUDIO_PROBE_BYTES: u64 = 64 * 1024;
/// How much of a song's start is read when its first bytes don't say where its
/// tags end: an Ogg comment packet, which holds the cover, has no size up front.
const AUDIO_HEAD_BYTES: u64 = 4 * 1024 * 1024;
/// Read past the tags at a song's start, so the first audio frames are there
/// for a reader that looks for them.
const AUDIO_HEAD_SLACK: u64 = 64 * 1024;
/// Largest start of a song read for its tags. A cover bigger than this is not
/// worth the download for a 512-pixel thumbnail.
const AUDIO_TAG_MAX_BYTES: u64 = 16 * 1024 * 1024;
/// How much of a song's end is read in formats that keep their tags there
/// (APE tags in WavPack and Monkey's Audio, `id3 ` chunks in WAV and AIFF).
const AUDIO_TAIL_BYTES: u64 = 4 * 1024 * 1024;
/// Names of a folder's cover image, best first, matched without case and
/// extension. A song without a picture in its tags gets this image's.
const FOLDER_COVER_STEMS: [&str; 6] = [
"cover",
"folder",
"front",
"album",
"albumart",
"albumartsmall",
];
/// Image types a folder cover is taken in.
const FOLDER_COVER_EXTENSIONS: [&str; 4] = ["jpg", "jpeg", "png", "webp"];
/// Largest folder cover image downloaded whole for a song's thumbnail.
const FOLDER_COVER_MAX_BYTES: u64 = 32 * 1024 * 1024;
/// Largest thumbnail attached to an upload. Proton's own clients hold theirs to /// Largest thumbnail attached to an upload. Proton's own clients hold theirs to
/// this, stepping the JPEG quality down until it fits. /// this, stepping the JPEG quality down until it fits.
const UPLOAD_THUMB_MAX_BYTES: usize = 60 * 1024; const UPLOAD_THUMB_MAX_BYTES: usize = 60 * 1024;
@@ -660,7 +630,6 @@ enum Fetched {
/// A whole image. /// A whole image.
Whole(Vec<u8>), Whole(Vec<u8>),
Video(VideoSample), Video(VideoSample),
Audio(AudioSample),
} }
/// The parts of a video its thumbnail needs, downloaded: the start of the /// The parts of a video its thumbnail needs, downloaded: the start of the
@@ -797,181 +766,13 @@ fn video_sample_thumbnail(
video_file_thumbnail(&file.0, ffmpeg, timeout) video_file_thumbnail(&file.0, ffmpeg, timeout)
} }
/// The parts of a song its cover needs, downloaded: the start, where most
/// formats keep their tags, an M4A's index, and for formats that keep their
/// tags at the end, the end.
struct AudioSample {
/// The whole file's length.
size: u64,
/// Each part and the offset it came from.
parts: Vec<(u64, Vec<u8>)>,
}
/// Where the tags at the start of a song end, read from its first `probe`
/// bytes: past an ID3v2 tag, and past FLAC's metadata blocks, which hold its
/// picture. `None` when the start says nothing, as for Ogg. A FLAC block
/// header past `probe` stops the walk with a guess of [`AUDIO_HEAD_BYTES`]
/// more.
fn audio_tags_end(probe: &[u8]) -> Option<u64> {
let mut offset = 0u64;
let mut known = false;
if probe.get(0..3) == Some(b"ID3") {
let size = probe.get(6..10)?;
if size.iter().any(|byte| byte & 0x80 != 0) {
return None;
}
let size = size
.iter()
.fold(0u64, |acc, byte| (acc << 7) | u64::from(*byte));
let footer = probe.get(5).is_some_and(|flags| flags & 0x10 != 0);
offset = 10 + size + if footer { 10 } else { 0 };
known = true;
}
let start = usize::try_from(offset).ok()?;
if probe.get(start..start + 4) == Some(b"fLaC") {
offset += 4;
loop {
let at = usize::try_from(offset).ok()?;
let Some(header) = probe.get(at..at + 4) else {
return Some(offset + AUDIO_HEAD_BYTES);
};
let len = u64::from_be_bytes([0, 0, 0, 0, 0, header[1], header[2], header[3]]);
offset += 4 + len;
if header[0] & 0x80 != 0 {
return Some(offset);
}
}
}
known.then_some(offset)
}
/// Whether a song in format `name` may keep its tags at its end.
fn audio_tags_at_end(name: &str) -> bool {
let extension = name
.rsplit_once('.')
.map(|(_, extension)| extension.to_ascii_lowercase())
.unwrap_or_default();
matches!(extension.as_str(), "wv" | "ape" | "wav" | "aif" | "aiff")
}
/// Download what the cover of the song `reader` reads, named `name`, needs. A
/// song of up to [`AUDIO_WHOLE_MAX_BYTES`] comes whole; a larger one brings its
/// tags, never its audio.
async fn sample_audio(reader: &dyn RevisionRead, name: &str) -> DriveResult<AudioSample> {
let size = reader.size();
if size <= AUDIO_WHOLE_MAX_BYTES {
let whole = reader.read_at(0, size).await?;
return Ok(AudioSample {
size,
parts: vec![(0, whole)],
});
}
let probe = reader.read_at(0, AUDIO_PROBE_BYTES.min(size)).await?;
// An M4A keeps its cover in its index, fetched below wherever it sits.
let iso_media = probe.get(4..8) == Some(b"ftyp");
let want = match audio_tags_end(&probe) {
_ if iso_media => 0,
Some(end) => end.saturating_add(AUDIO_HEAD_SLACK),
None => AUDIO_HEAD_BYTES,
}
.min(AUDIO_TAG_MAX_BYTES)
.min(size);
let head = if want > probe.len() as u64 {
reader.read_at(0, want).await?
} else {
probe
};
let mut parts = Vec::new();
match iso_media_index(reader, &head, size).await? {
Some((offset, len)) if offset.saturating_add(len) > head.len() as u64 => {
parts.push((offset, reader.read_at(offset, len).await?));
}
_ => {}
}
let head_len = head.len() as u64;
parts.push((0, head));
if audio_tags_at_end(name) {
let start = size.saturating_sub(AUDIO_TAIL_BYTES).max(head_len);
if start < size {
parts.push((start, reader.read_at(start, size - start).await?));
}
}
Ok(AudioSample { size, parts })
}
/// The picture a song's tags carry: the front cover, or else the first one.
/// `None` for a song without one, or one lofty can't read.
fn embedded_cover(path: &Path) -> Option<Vec<u8>> {
use lofty::config::{ParseOptions, ParsingMode};
use lofty::file::TaggedFileExt as _;
use lofty::picture::PictureType;
let tagged = lofty::probe::Probe::open(path)
.ok()?
.options(
ParseOptions::new()
.read_properties(false)
.parsing_mode(ParsingMode::Relaxed),
)
.guess_file_type()
.ok()?
.read()
.ok()?;
let pictures = || tagged.tags().iter().flat_map(|tag| tag.pictures());
pictures()
.find(|picture| picture.pic_type() == PictureType::CoverFront)
.or_else(|| pictures().next())
.map(|picture| picture.data().to_vec())
.filter(|data| !data.is_empty())
}
/// A thumbnail from the cover in a downloaded [`AudioSample`]'s tags. The
/// sample is laid out in a staging file at the offsets it came from, so the
/// tags are where the file's own headers say they are; the audio between
/// holds no data and costs no disk. A song without a cover, or with one the
/// image decoder can't read, is [`ScaleAttempt::Undecodable`].
fn audio_cover_thumbnail(sample: &AudioSample, name: &str, staging_dir: &Path) -> ScaleAttempt {
let Some(file) = RawTempFile::create_in(staging_dir, &[], name) else {
return ScaleAttempt::Unavailable;
};
let laid_out = OpenOptions::new()
.write(true)
.open(&file.0)
.and_then(|staged| {
for (offset, bytes) in &sample.parts {
staged.write_all_at(bytes, *offset)?;
}
staged.set_len(sample.size)
});
if laid_out.is_err() {
return ScaleAttempt::Unavailable;
}
match embedded_cover(&file.0) {
Some(picture) => scale_thumbnail(&picture, "cover", staging_dir),
None => ScaleAttempt::Undecodable,
}
}
/// How good a folder cover `name` is: its place in [`FOLDER_COVER_STEMS`],
/// lowest best, or `None` for a file that is no cover image.
fn cover_rank(name: &str) -> Option<usize> {
let (stem, extension) = name.rsplit_once('.')?;
let extension = extension.to_ascii_lowercase();
if !FOLDER_COVER_EXTENSIONS.contains(&extension.as_str()) {
return None;
}
let stem = stem.to_ascii_lowercase();
FOLDER_COVER_STEMS.iter().position(|known| *known == stem)
}
/// The thumbnail to attach to an upload of the local file at `path`, which is /// The thumbnail to attach to an upload of the local file at `path`, which is
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and /// named `name` in Drive. Without one, Drive's own apps show a generic icon and
/// the gallery has to download the whole file to make its own. /// the gallery has to download the whole file to make its own.
/// ///
/// Best effort: a file that isn't an image, a video or a song with a cover in /// Best effort: a file that isn't an image or a video, is too large to read, or
/// its tags, is too large to read, or can't be decoded uploads without a /// can't be decoded uploads without a thumbnail, as it always did. Blocking and
/// thumbnail, as it always did. Blocking and CPU-bound, so async callers run it /// CPU-bound, so async callers run it on the blocking pool.
/// on the blocking pool.
pub(crate) fn upload_thumbnails(path: &Path, name: &str, staging_dir: &Path) -> Vec<Thumbnail> { pub(crate) fn upload_thumbnails(path: &Path, name: &str, staging_dir: &Path) -> Vec<Thumbnail> {
if is_video_name(name) && !ffmpeg_available() { if is_video_name(name) && !ffmpeg_available() {
return Vec::new(); return Vec::new();
@@ -1005,14 +806,6 @@ fn upload_thumbnails_with(
return Vec::new(); return Vec::new();
}; };
scale_thumbnail_unbudgeted(&bytes, name, staging_dir) scale_thumbnail_unbudgeted(&bytes, name, staging_dir)
} else if is_audio_file_name(name) {
// Only the song's own picture: a cover image beside it is not known
// here, where `path` may be a staging copy rather than the song's
// folder. The daemon finds that one when a listing asks.
match embedded_cover(path) {
Some(picture) => scale_thumbnail(&picture, "cover", staging_dir),
None => return Vec::new(),
}
} else { } else {
return Vec::new(); return Vec::new();
}; };
@@ -1193,7 +986,7 @@ impl Core {
if known_empty && !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) { if known_empty && !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
return Ok(None); return Ok(None);
} }
if !self.refresh_timeline_joined()? { if !self.rt.block_on(self.refresh_timeline())? {
return Ok(None); return Ok(None);
} }
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) { } else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
@@ -1234,7 +1027,6 @@ impl Core {
favorite: photo.favorite, favorite: photo.favorite,
group_size: photo.group_size as u32, group_size: photo.group_size as u32,
has_raw: photo.has_raw, has_raw: photo.has_raw,
place_id: photo.place_id,
} }
} }
@@ -1910,7 +1702,6 @@ impl Core {
return (uid, ThumbAttempt::Cancelled); return (uid, ThumbAttempt::Cancelled);
} }
let video = is_video_name(&name); let video = is_video_name(&name);
let audio = is_audio_file_name(&name);
// Without ffmpeg a video can still have its uploader's // Without ffmpeg a video can still have its uploader's
// thumbnail, but Photos has asked the server already. // thumbnail, but Photos has asked the server already.
let can_sample = !video || ffmpeg_available(); let can_sample = !video || ffmpeg_available();
@@ -1965,7 +1756,6 @@ impl Core {
let download_uid = uid.clone(); let download_uid = uid.clone();
let drive = core.drive.clone(); let drive = core.drive.clone();
let audio_name = name.clone();
let download = async move { let download = async move {
// Photos asked the server in one batch already. A // Photos asked the server in one batch already. A
// file view's or a build's file may carry a // file view's or a build's file may carry a
@@ -1985,11 +1775,6 @@ impl Core {
if video { if video {
let reader = drive.open_revision(&download_uid).await?; let reader = drive.open_revision(&download_uid).await?;
sample_video(reader.as_ref()).await.map(Fetched::Video) sample_video(reader.as_ref()).await.map(Fetched::Video)
} else if audio {
let reader = drive.open_revision(&download_uid).await?;
sample_audio(reader.as_ref(), &audio_name)
.await
.map(Fetched::Audio)
} else { } else {
client client
.download_file(&download_uid) .download_file(&download_uid)
@@ -2047,11 +1832,7 @@ impl Core {
// Decoding + scaling a 20 MP JPEG is CPU-bound and would // Decoding + scaling a 20 MP JPEG is CPU-bound and would
// stall the runtime's worker; hand it to the blocking pool. // stall the runtime's worker; hand it to the blocking pool.
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf(); let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
let staging = staging_dir.clone();
let made = tokio::task::spawn_blocking(move || match fetched { let made = tokio::task::spawn_blocking(move || match fetched {
Fetched::Audio(sample) => {
audio_cover_thumbnail(&sample, &name, &staging_dir)
}
Fetched::Video(sample) => video_sample_thumbnail( Fetched::Video(sample) => video_sample_thumbnail(
&sample, &sample,
&name, &name,
@@ -2068,14 +1849,6 @@ impl Core {
}) })
.await .await
.unwrap_or(ScaleAttempt::Unavailable); .unwrap_or(ScaleAttempt::Unavailable);
// A song without a picture of its own shows its
// folder's cover.
let made = match made {
ScaleAttempt::Undecodable if audio && core.thumb_job_current(job) => {
core.folder_cover_thumbnail(&uid, staging).await
}
made => made,
};
if !core.thumb_job_current(job) { if !core.thumb_job_current(job) {
return (uid, ThumbAttempt::Cancelled); return (uid, ThumbAttempt::Cancelled);
} }
@@ -2136,85 +1909,9 @@ impl Core {
ThumbAttempt::Cancelled => summary.cancelled += 1, ThumbAttempt::Cancelled => summary.cancelled += 1,
} }
} }
// A folder on screen whose tiles were given up on, as a recursive
// build's start does to them, fills in without being opened again.
if summary.made > 0 {
self.events.publish(&[Topic::Thumbnails]);
}
summary summary
} }
/// The cover image in the folder of the song `uid`: the best-named one of
/// [`FOLDER_COVER_STEMS`] among its siblings the database knows.
fn folder_cover(&self, uid: &NodeUid) -> Option<proton_drive_rs::Node> {
let node = self.db.node_by_uid(&uid.to_string()).ok()??;
let parent = node.parent_uid?;
let siblings = match self.db.children_if_listed(&parent) {
Ok(Some(children)) => children,
_ => self.db.known_children(&parent).ok()?,
};
siblings
.into_iter()
.filter(|sibling| !sibling.is_folder() && !sibling.trashed)
.filter_map(|sibling| cover_rank(&sibling.name).map(|rank| (rank, sibling)))
.min_by(|(a, x), (b, y)| a.cmp(b).then_with(|| x.name.cmp(&y.name)))
.map(|(_, cover)| cover)
}
/// A thumbnail of the song `uid` made from its folder's cover image: the
/// cover's own cached thumbnail when it has one, else the thumbnail its
/// uploader attached, else the image itself, up to
/// [`FOLDER_COVER_MAX_BYTES`].
///
/// The result is stored under the song, so a cover added to the folder or
/// replaced later shows only once the song changes or the daemon restarts
/// and forgets the songs it found no cover for.
async fn folder_cover_thumbnail(&self, uid: &NodeUid, staging_dir: PathBuf) -> ScaleAttempt {
let Some(cover) = self.folder_cover(uid) else {
return ScaleAttempt::Undecodable;
};
let ttype = ThumbnailType::Thumbnail;
let cached = self
.cache
.cached_thumbnail_path(&cover.uid, ttype.as_i32(), cover.modification_time)
.and_then(|path| std::fs::read(path).ok());
let ready = match cached {
Some(bytes) => Some(bytes),
None => match self.drive.download_thumbnail(&cover.uid, ttype).await {
Ok(bytes) => bytes.filter(|bytes| !bytes.is_empty()),
Err(e) => {
warn!(cover = %cover.uid, error = %e, "folder cover thumbnail download failed");
return ScaleAttempt::Unavailable;
}
},
};
if let Some(bytes) = ready {
return match ratio_of(&bytes) {
Some(ratio) => ScaleAttempt::Made(GeneratedThumb { bytes, ratio }),
None => ScaleAttempt::Undecodable,
};
}
let read = async {
let reader = self.drive.open_revision(&cover.uid).await?;
if reader.size() > FOLDER_COVER_MAX_BYTES {
return Ok(None);
}
reader.read_at(0, reader.size()).await.map(Some)
};
let bytes = match read.await {
Ok(Some(bytes)) => bytes,
Ok(None) => return ScaleAttempt::Undecodable,
Err(e) => {
warn!(cover = %cover.uid, error = %e, "folder cover download failed");
return ScaleAttempt::Unavailable;
}
};
let name = cover.name;
tokio::task::spawn_blocking(move || scale_thumbnail(&bytes, &name, &staging_dir))
.await
.unwrap_or(ScaleAttempt::Unavailable)
}
pub(crate) fn thumbnail_build_status(&self) -> ThumbnailBuildStatus { pub(crate) fn thumbnail_build_status(&self) -> ThumbnailBuildStatus {
self.thumbnail_build.lock().clone() self.thumbnail_build.lock().clone()
} }
@@ -2640,36 +2337,6 @@ impl Core {
/// Refresh the timeline off the request path, so a stale page is still served /// Refresh the timeline off the request path, so a stale page is still served
/// at DB speed. At most one refresh runs at a time. /// at DB speed. At most one refresh runs at a time.
/// Refresh the timeline and wait for it, sharing a refresh already under
/// way instead of starting a second one beside it.
///
/// The first request after an empty cache has nothing to serve until the
/// fetch lands, so it waits. Two such requests (the gallery and its retry,
/// or a background refresh started by an import) used to each fetch the
/// whole library at once, sharing the session's rate limit and taking
/// minutes. Returns whether the account has a photos library.
pub(crate) fn refresh_timeline_joined(&self) -> CoreResult<bool> {
if self.timeline_refreshing.swap(true, Ordering::SeqCst) {
while self.timeline_refreshing.load(Ordering::SeqCst) && !self.shutdown.is_stopping() {
std::thread::sleep(std::time::Duration::from_millis(200));
}
// A refresh that landed stamped the timeline fresh. One that failed
// did not, and the caller gets this refresh's own error instead of
// an empty timeline.
if !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL)
|| self.timeline_refreshing.swap(true, Ordering::SeqCst)
{
return Ok(self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() != Some(0));
}
}
let refreshed = self.rt.block_on(self.refresh_timeline());
self.timeline_refreshing.store(false, Ordering::SeqCst);
if refreshed.is_ok() {
self.events.publish(&[Topic::Photos]);
}
refreshed
}
pub(crate) fn spawn_timeline_refresh(&self) { pub(crate) fn spawn_timeline_refresh(&self) {
if self.timeline_refreshing.swap(true, Ordering::SeqCst) { if self.timeline_refreshing.swap(true, Ordering::SeqCst) {
return; return;
@@ -2786,10 +2453,6 @@ mod decode_budget_tests {
#[cfg(test)] #[cfg(test)]
mod thumb_tests { mod thumb_tests {
use super::{
AUDIO_HEAD_BYTES, AUDIO_TAIL_BYTES, AudioSample, audio_cover_thumbnail, audio_tags_end,
cover_rank, sample_audio,
};
use super::{ use super::{
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES, RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
VIDEO_HEAD_BYTES, VIDEO_WHOLE_MAX_BYTES, VideoSample, can_make_thumbnail, exiftool_binary, VIDEO_HEAD_BYTES, VIDEO_WHOLE_MAX_BYTES, VideoSample, can_make_thumbnail, exiftool_binary,
@@ -3301,28 +2964,6 @@ mod thumb_tests {
assert!(thumb.ratio > 0.0); assert!(thumb.ratio > 0.0);
} }
/// Optional check against real songs, through the same partial download a
/// Drive song gets. `PDFS_AUDIO_FIXTURE` lists files separated by `:`.
#[test]
#[ignore = "set PDFS_AUDIO_FIXTURE to songs with a cover in their tags"]
fn real_audio_fixture_yields_its_cover() {
let paths = std::env::var_os("PDFS_AUDIO_FIXTURE").expect("PDFS_AUDIO_FIXTURE");
for path in std::env::split_paths(&paths) {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let bytes = std::fs::read(&path).unwrap();
let revision = FakeRevision::new(bytes.len() as u64).with(0, bytes);
let sample = block_on(sample_audio(&revision, &name)).unwrap();
let read = revision.read.load(Ordering::Relaxed);
let attempt = audio_cover_thumbnail(&sample, &name, &std::env::temp_dir());
assert!(
matches!(attempt, ScaleAttempt::Made(_)),
"{name}: no cover, read {read} of {} bytes",
revision.size
);
eprintln!("{name}: read {read} of {} bytes", revision.size);
}
}
#[test] #[test]
fn videos_are_told_apart_by_name() { fn videos_are_told_apart_by_name() {
for name in [ for name in [
@@ -3479,201 +3120,4 @@ mod thumb_tests {
"a small one is untouched" "a small one is untouched"
); );
} }
/// A `width`×`height` PNG, the cover a tag carries.
fn png(width: u32, height: u32) -> Vec<u8> {
let mut bytes = Vec::new();
image::DynamicImage::ImageRgb8(image::RgbImage::new(width, height))
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.unwrap();
bytes
}
/// An ID3v2.3 tag holding `picture` as its front cover.
fn id3_with_cover(picture: &[u8]) -> Vec<u8> {
let mut body = vec![0u8];
body.extend_from_slice(b"image/png\0");
body.push(3);
body.push(0);
body.extend_from_slice(picture);
let mut frame = b"APIC".to_vec();
frame.extend_from_slice(&(body.len() as u32).to_be_bytes());
frame.extend_from_slice(&[0, 0]);
frame.extend_from_slice(&body);
let len = frame.len() as u32;
let mut tag = b"ID3\x03\x00\x00".to_vec();
tag.extend((0..4).rev().map(|i| ((len >> (7 * i)) & 0x7f) as u8));
tag.extend_from_slice(&frame);
tag
}
/// Ten silent MPEG-1 Layer III frames at 128 kbit/s and 44.1 kHz.
fn mpeg_frames() -> Vec<u8> {
let mut frame = vec![0u8; 417];
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x64]);
frame.repeat(10)
}
/// A FLAC stream's metadata: STREAMINFO, then `picture` as its front cover.
fn flac_with_cover(picture: &[u8]) -> Vec<u8> {
let mut info = vec![0x10, 0x00, 0x10, 0x00, 0, 0, 0, 0, 0, 0];
let packed: u64 = (44_100 << 44) | (1 << 41) | (15 << 36);
info.extend_from_slice(&packed.to_be_bytes());
info.extend_from_slice(&[0; 16]);
let mut block = Vec::new();
block.extend_from_slice(&3u32.to_be_bytes());
block.extend_from_slice(&9u32.to_be_bytes());
block.extend_from_slice(b"image/png");
block.extend_from_slice(&0u32.to_be_bytes());
for value in [16u32, 8, 24, 0, picture.len() as u32] {
block.extend_from_slice(&value.to_be_bytes());
}
block.extend_from_slice(picture);
let mut out = b"fLaC".to_vec();
out.push(0);
out.extend_from_slice(&(info.len() as u32).to_be_bytes()[1..]);
out.extend_from_slice(&info);
out.push(0x80 | 6);
out.extend_from_slice(&(block.len() as u32).to_be_bytes()[1..]);
out.extend_from_slice(&block);
out
}
fn whole_song(bytes: Vec<u8>) -> AudioSample {
AudioSample {
size: bytes.len() as u64,
parts: vec![(0, bytes)],
}
}
#[test]
fn a_song_shows_the_cover_in_its_tags() {
let tmp = scratch("song-cover");
std::fs::create_dir_all(&tmp).unwrap();
let mut mp3 = id3_with_cover(&png(16, 8));
mp3.extend(mpeg_frames());
let ScaleAttempt::Made(thumb) = audio_cover_thumbnail(&whole_song(mp3), "song.mp3", &tmp)
else {
panic!("an ID3 cover scales");
};
assert!((thumb.ratio - 2.0).abs() < 1e-6);
let ScaleAttempt::Made(thumb) =
audio_cover_thumbnail(&whole_song(flac_with_cover(&png(8, 16))), "song.flac", &tmp)
else {
panic!("a FLAC picture scales");
};
assert!((thumb.ratio - 0.5).abs() < 1e-6);
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn a_song_without_a_cover_is_no_thumbnail_of_its_own() {
let tmp = scratch("song-bare");
std::fs::create_dir_all(&tmp).unwrap();
let attempt = audio_cover_thumbnail(&whole_song(mpeg_frames()), "song.mp3", &tmp);
assert!(matches!(attempt, ScaleAttempt::Undecodable));
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn the_start_of_a_song_says_where_its_tags_end() {
let tag = id3_with_cover(&png(4, 4));
assert_eq!(audio_tags_end(&tag), Some(tag.len() as u64));
let flac = flac_with_cover(&png(4, 4));
assert_eq!(audio_tags_end(&flac), Some(flac.len() as u64));
// FLAC after an ID3 tag, as some taggers write it.
let both = [tag.clone(), flac.clone()].concat();
assert_eq!(audio_tags_end(&both), Some(both.len() as u64));
// The picture's block header cut off by the end of the probe, just
// past STREAMINFO: a guess past it.
let cut = &flac[..4 + 4 + 34 + 2];
assert!(audio_tags_end(cut).is_some_and(|end| end > cut.len() as u64));
assert_eq!(audio_tags_end(b"OggS\0\0\0\0"), None);
assert_eq!(
audio_tags_end(b"ID3\x03\x00\x00\x80\0\0\0"),
None,
"not synchsafe"
);
}
#[test]
fn a_large_song_downloads_its_tags_not_its_audio() {
const SIZE: u64 = 300 << 20;
let mut start = id3_with_cover(&png(16, 8));
start.extend(mpeg_frames());
let revision = FakeRevision::new(SIZE).with(0, start);
let sample = block_on(sample_audio(&revision, "long mix.mp3")).unwrap();
let read = revision.read.load(Ordering::Relaxed);
assert!(read < 1 << 20, "read {read} bytes");
let tmp = scratch("song-sparse");
std::fs::create_dir_all(&tmp).unwrap();
let ScaleAttempt::Made(thumb) = audio_cover_thumbnail(&sample, "long mix.mp3", &tmp) else {
panic!("the cover comes from the start alone");
};
assert!((thumb.ratio - 2.0).abs() < 1e-6);
std::fs::remove_dir_all(&tmp).unwrap();
// Ogg says nothing up front: a bounded head. WavPack keeps its tags at
// the end: the tail too.
let revision = FakeRevision::new(SIZE);
block_on(sample_audio(&revision, "live.ogg")).unwrap();
assert!(revision.read.load(Ordering::Relaxed) <= AUDIO_HEAD_BYTES + (64 << 10));
let revision = FakeRevision::new(SIZE);
let sample = block_on(sample_audio(&revision, "live.wv")).unwrap();
assert!(
sample
.parts
.iter()
.any(|(offset, bytes)| *offset == SIZE - AUDIO_TAIL_BYTES
&& bytes.len() as u64 == AUDIO_TAIL_BYTES)
);
}
#[test]
fn an_uploaded_song_carries_its_cover() {
let tmp = scratch("upload-song");
std::fs::create_dir_all(&tmp).unwrap();
// A staging blob has no extension: the format comes from the bytes.
let blob = tmp.join("blob");
let mut mp3 = id3_with_cover(&png(64, 64));
mp3.extend(mpeg_frames());
std::fs::write(&blob, mp3).unwrap();
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
let thumbs = upload_thumbnails_with(&blob, "song.mp3", &tmp, ffmpeg.as_os_str());
let [thumbnail] = thumbs.as_slice() else {
panic!("one thumbnail, got {}", thumbs.len());
};
assert!(matches!(thumbnail.thumbnail_type, ThumbnailType::Thumbnail));
assert_eq!(ratio_of(&thumbnail.content), Some(1.0));
std::fs::write(&blob, mpeg_frames()).unwrap();
assert!(
upload_thumbnails_with(&blob, "song.mp3", &tmp, ffmpeg.as_os_str()).is_empty(),
"a song without a cover uploads bare"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn a_folder_cover_is_found_by_its_usual_names() {
assert_eq!(cover_rank("cover.jpg"), Some(0));
assert_eq!(cover_rank("Cover.JPG"), Some(0));
assert_eq!(cover_rank("folder.png"), Some(1));
assert_eq!(cover_rank("AlbumArtSmall.jpg"), Some(5));
assert!(cover_rank("cover.jpg") < cover_rank("front.webp"));
for name in [
"cover.txt",
"my cover.jpg",
"cover",
"back.jpg",
"cover.jpg.part",
] {
assert_eq!(cover_rank(name), None, "{name}");
}
}
} }
-5
View File
@@ -111,11 +111,6 @@ 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,
+8 -8
View File
@@ -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_shareable(uid)?; let uid = self.resolve_anywhere(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_shareable(uid)?; let uid = self.resolve_anywhere(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_shareable(uid)?; let uid = self.resolve_anywhere(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_shareable(uid)?; let uid = self.resolve_anywhere(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_shareable(uid)?; let uid = self.resolve_anywhere(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_shareable(uid)?; let uid = self.resolve_anywhere(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_shareable(uid)?; let uid = self.resolve_anywhere(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_shareable(uid)?; let uid = self.resolve_anywhere(uid)?;
self.stop_sharing_for_uid(&uid) self.stop_sharing_for_uid(&uid)
} }
-194
View File
@@ -3447,118 +3447,6 @@ 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) {
@@ -3732,88 +3620,6 @@ mod tests {
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 /// 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 /// 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 for it. A video with a thumbnail from its uploader is not
File diff suppressed because it is too large. Load diff
+4 -18
View File
@@ -61,28 +61,14 @@ scale.browser-status-meter trough { min-width: 140px; 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; }
.media-player-window video { background-color: black; } .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-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); }
/* Dark discs with a white glyph read on a white photo as well as on a black one. */ .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; }
.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(255, 255, 255, 0.28); color: white; }
.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 { margin-left: 12px; } .viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
.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; }
+2 -16
View File
@@ -128,22 +128,8 @@ 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);
} }
// On any page: a grid, a list or a player may show a tile still blank. match ui.stack.visible_child_name().as_deref() {
if changed(Topic::Thumbnails) { Some("browser") if changed(Topic::Files) => load_browser(ui),
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")
+15 -103
View File
@@ -40,8 +40,6 @@ 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::*;
@@ -77,11 +75,11 @@ 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, ImportPhase, ImportProgress, ImportSummary, InvitationInfo, JobItem, DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, LinkChange, MappingProgress,
LinkChange, MappingProgress, PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope,
PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, RestoreItem, RevisionInfo, Request, Response, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry,
SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase, ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress,
SyncProgress, ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send, ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
}; };
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
@@ -459,8 +457,6 @@ 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(),
@@ -527,7 +523,6 @@ 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(),
@@ -668,11 +663,9 @@ 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,
}); });
@@ -733,26 +726,17 @@ 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") => reload_browser_view(&ui_nav), Some("browser") => load_browser(&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),
_ if ui_nav.gallery.timeline_stale.replace(false) _ => load_gallery(&ui_nav, false),
|| ui_nav.gallery.model.n_items() == 0 =>
{
load_gallery(&ui_nav, false)
}
_ => {}
} }
} }
Some("gallery") if ui_nav.gallery.model.n_items() == 0 => load_gallery(&ui_nav, false), Some("gallery") => 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),
@@ -1082,7 +1066,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 }, reload_browser_view); refresh_then(ui, RefreshScope::Dir { path }, load_browser);
} }
// 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.
@@ -1344,7 +1328,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(), None)); new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new()));
window.add_action(&new_album); window.add_action(&new_album);
let shortcuts = gio::SimpleAction::new("shortcuts", None); let shortcuts = gio::SimpleAction::new("shortcuts", None);
@@ -1522,7 +1506,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)]); 5] = [ const GROUPS: [(&str, &[(&str, &str)]); 4] = [
( (
gettext_noop("General"), gettext_noop("General"),
&[ &[
@@ -1555,7 +1539,6 @@ 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")),
], ],
), ),
( (
@@ -1565,20 +1548,8 @@ 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 <Alt>Left", gettext_noop("Close")), ("Escape", 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")),
], ],
), ),
]; ];
@@ -1604,17 +1575,8 @@ 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 page = ui.stack.visible_child_name(); let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
let on_browser = page.as_deref() == Some("browser"); let on_trash = ui.stack.visible_child_name().as_deref() == Some("trash");
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.
@@ -1671,21 +1633,6 @@ 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) {
@@ -2027,7 +1974,6 @@ 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,
} }
} }
@@ -2050,40 +1996,6 @@ 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]
+17 -30
View File
@@ -225,13 +225,12 @@ fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: &gtk4::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 = parent.or_else(|| ui_window(ui)); let win = 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()
@@ -261,12 +260,10 @@ 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, parent, &heading, &action, "", move |name| { prompt_album_name(ui, &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();
@@ -299,7 +296,7 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<
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, None, &heading, &action, &album.name, move |name| { prompt_album_name(ui, &heading, &action, &album.name, move |name| {
run_album_request( run_album_request(
&ui_c, &ui_c,
Request::RenameAlbum { Request::RenameAlbum {
@@ -371,7 +368,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>, parent: Option<gtk4::Window>) { pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
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 {
@@ -393,7 +390,7 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Opti
} }
}; };
if albums.is_empty() { if albums.is_empty() {
prompt_new_album(&ui, photos, parent); prompt_new_album(&ui, photos);
return; return;
} }
@@ -439,11 +436,11 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Opti
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, parent_c) = (ui.clone(), parent.clone()); let ui_c = ui.clone();
dialog.connect_response(Some("new"), move |_, _| { dialog.connect_response(Some("new"), move |_, _| {
prompt_new_album(&ui_c, photos.clone(), parent_c.clone()) prompt_new_album(&ui_c, photos.clone())
}); });
dialog.present(parent.or_else(|| ui_window(&ui)).as_ref()); dialog.present(ui_window(&ui).as_ref());
}); });
} }
@@ -598,7 +595,10 @@ pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: &gtk4::Picture) {
if ui.gallery.photo_nothumb.borrow().contains(&uid) { if ui.gallery.photo_nothumb.borrow().contains(&uid) {
return; return;
} }
ui.gallery.await_thumb(&uid, picture); ui.gallery
.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);
@@ -664,25 +664,12 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
} }
close_album(&ui_photos); close_album(&ui_photos);
close_place(&ui_photos); close_place(&ui_photos);
let gallery = &ui_photos.gallery; ui_photos.gallery.filters.set_visible(true);
gallery.filters.set_visible(true); ui_photos.gallery.content.set_visible_child_name("timeline");
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 gallery.timeline_stale.replace(false) || refiltered || gallery.model.n_items() == 0 { if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.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);
+52 -79
View File
@@ -1,14 +1,6 @@
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.
@@ -18,13 +10,6 @@ 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,
@@ -898,15 +883,27 @@ 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 || {
activate_entry(&ui_c, &entry_c) // Open always means "download a local copy and hand off", even for a
// 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
@@ -1095,8 +1092,38 @@ pub(crate) fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Op
Some(entry) Some(entry)
} }
/// Open an entry the Nautilus way: folders descend, media plays in the app, /// Whether an entry can be opened through FUSE instead of fully materialized.
/// streamed from the mount, other files download-and-open. pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool {
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 {
@@ -1109,16 +1136,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.
open_media_player(ui, entry); stream_entry(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 a /// there, otherwise downloaded into the cache first. The open path behind both
/// double-click and the context menu's "Open" for anything but a folder or /// a plain double-click and the context menu's "Open" (including for a video,
/// media, and for media the mount doesn't show. /// when the user wants their default application rather than the player).
/// ///
/// 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
@@ -1529,29 +1556,8 @@ 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);
@@ -1576,8 +1582,6 @@ 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);
@@ -1588,8 +1592,6 @@ 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);
@@ -1602,7 +1604,6 @@ 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());
} }
@@ -2920,18 +2921,6 @@ 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.
@@ -2970,10 +2959,7 @@ 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();
// `listed` still names the folder the rows belong to only on a refresh; *ui.browser.listed.borrow_mut() = Some(ui.browser.path.borrow().clone());
// 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(),
@@ -3002,16 +2988,6 @@ 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
@@ -3190,9 +3166,6 @@ 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 {
+4 -16
View File
@@ -190,9 +190,10 @@ pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
})); }));
let ui_back = ui.clone(); let ui_back = ui.clone();
widgets widgets.back.connect_clicked(move |_| {
.back ui_back.devices.nav.borrow_mut().pop();
.connect_clicked(move |_| devices_back(&ui_back)); load_devices(&ui_back);
});
let ui_retry = ui.clone(); let ui_retry = ui.clone();
widgets widgets
.retry .retry
@@ -210,14 +211,6 @@ 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>) {
@@ -304,7 +297,6 @@ 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();
@@ -312,10 +304,6 @@ 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();
}
} }
}); });
} }
+140 -455
View File
@@ -52,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 pill that switches between the files of one shot, and the files it /// The button 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 pill hidden, for a photo stored as one file. /// clip). Empty, and the button 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.
@@ -81,25 +81,14 @@ 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 ⋮ button, and whether its menu is open, which keeps the controls /// The slideshow toggle; the timer that moves it on; and whether it put
/// up the way resting the pointer on them does. /// the window in fullscreen, so stopping it only leaves a fullscreen it
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: Cell<bool>, pub(crate) slideshow: gtk4::ToggleButton,
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
@@ -174,14 +163,6 @@ 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);
}); });
@@ -194,34 +175,6 @@ 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 {
@@ -279,7 +232,6 @@ 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);
@@ -357,7 +309,6 @@ 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());
} }
@@ -390,15 +341,14 @@ 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);
} }
@@ -406,20 +356,13 @@ 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("pdfs-slideshow-symbolic");
if viewer.slideshow.is_active() {
/// Start the slideshow, or stop the one running. viewer.slideshow.set_active(false);
fn toggle_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if viewer.slideshow.get() {
stop_slideshow(viewer);
} else {
start_slideshow(ui, viewer);
} }
} }
@@ -427,7 +370,7 @@ fn toggle_slideshow(ui: &Rc<Ui>, 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.get() { if !viewer.slideshow.is_active() {
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");
@@ -847,7 +790,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() && !viewer.menu_open.get() { if !viewer.chrome_hover.get() {
hide_chrome(&viewer); hide_chrome(&viewer);
} }
}); });
@@ -865,14 +808,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(viewer: &Rc<Viewer>, source: &str, name: &str) { pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: &gtk4::Window, 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 toasts = viewer.toasts.clone(); let ui = ui.clone();
dialog.save(Some(&viewer.window), gio::Cancellable::NONE, move |res| { dialog.save(Some(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;
@@ -881,67 +824,26 @@ pub(crate) fn save_photo_to_disk(viewer: &Rc<Viewer>, source: &str, name: &str)
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;
let message = match copied { 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.
gettext_f("Saved “{name}”", &[("name", &saved)]) toast(&ui, &gettext_f("Saved “{name}”", &[("name", &saved)]));
} }
Ok(Err(e)) => failure(&gettext("Couldn't save the copy"), &e.to_string()), Ok(Err(e)) => toast_error(&ui, &gettext("Couldn't save the copy"), &e.to_string()),
Err(_) => failure( Err(_) => toast_error(
&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
@@ -1081,124 +983,16 @@ pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option<
} }
} }
/// The lightbox's title: when the photo was taken, as "Wednesday, October 7, /// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
/// 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(&photo_title(capture_time)); viewer.title.set_label(&format_capture_time(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
.strip_reveal .counter
.set_visible(viewer.strip_selection.n_items() > 1 && !viewer.slideshow.get() && !video); .set_label(&position_label(index, loaded, more, gallery_total(ui)));
}
/// 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,
@@ -1207,10 +1001,7 @@ fn build_strip(ui: &Rc<Ui>) -> (gtk4::ListView, gtk4::SingleSelection) {
/// ///
/// 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();
// An `adw::Window` for its breakpoints: the details panel lies over the // An `adw::Window` for its breakpoints: the details panel lies over the
@@ -1247,7 +1038,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
.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"));
@@ -1332,21 +1122,22 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
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");
// Only shown for a shot stored as more than one file; `load_photo_group` let slideshow_btn = gtk4::ToggleButton::builder()
// decides that per photo and labels it ("RAW", "Live", "2 of 3"). .icon_name("pdfs-slideshow-symbolic")
let group_btn = gtk4::Button::builder() .tooltip_text(gettext("Slideshow (F5)"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.visible(false)
.build(); .build();
group_btn.add_css_class("flat"); slideshow_btn.add_css_class("flat");
group_btn.add_css_class("viewer-group-pill"); slideshow_btn.add_css_class("viewer-action-btn");
// Everything used less often than the favorite and the details sits in // Only shown for a shot stored as more than one file; `load_photo_group`
// one menu, named in words rather than in icons. // decides that per photo.
let more_btn = action("view-more-symbolic", &gettext("More")); let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
let fullscreen_btn = fullscreen_button(&window); group_btn.set_visible(false);
fullscreen_btn.add_css_class("flat");
fullscreen_btn.add_css_class("viewer-action-btn"); let download_btn = action("document-save-symbolic", &gettext("Save a Copy…"));
let delete_btn = action("user-trash-symbolic", &gettext("Move to Trash (Delete)"));
let open_ext_btn = action("document-open-symbolic", &gettext("Open With…"));
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");
@@ -1356,26 +1147,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
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(&fullscreen_btn); top_bar.append(&slideshow_btn);
top_bar.append(&more_btn); top_bar.append(&download_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);
@@ -1463,9 +1242,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
narrow.add_setter(&info_split, "collapsed", Some(&true.to_value())); narrow.add_setter(&info_split, "collapsed", Some(&true.to_value()));
adw_window.add_breakpoint(narrow); adw_window.add_breakpoint(narrow);
let body = info_split.clone(); let body = info_split.clone();
let toasts = adw::ToastOverlay::new(); adw_window.set_content(Some(&body));
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(),
@@ -1496,23 +1273,12 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
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![ chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
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),
more: more_btn.clone(), slideshow: slideshow_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);
@@ -1538,7 +1304,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
} }
}); });
body.add_controller(motion); body.add_controller(motion);
for revealer in [&top_reveal, &prev_reveal, &next_reveal, &strip_reveal] { for revealer in [&top_reveal, &prev_reveal, &next_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 |_, _, _| {
@@ -1654,52 +1420,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
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() {
@@ -1725,14 +1452,36 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
} }
}); });
let ui_slides = ui.clone();
let viewer_slides = Rc::downgrade(&viewer);
slideshow_btn.connect_toggled(move |toggle| {
let Some(viewer_slides) = viewer_slides.upgrade() else {
return;
};
if toggle.is_active() {
start_slideshow(&ui_slides, &viewer_slides);
} else {
stop_slideshow(&viewer_slides);
}
});
let toggle_off = info_toggle.clone(); let toggle_off = info_toggle.clone();
info_close.connect_clicked(move |_| toggle_off.set_active(false)); info_close.connect_clicked(move |_| toggle_off.set_active(false));
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 |_| {
if let Some(viewer_map) = viewer_map.upgrade() { let Some(w_map) = w_map.upgrade() else {
show_photo_on_map(&ui_map, &viewer_map); return;
};
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);
} }
}); });
@@ -1761,9 +1510,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
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),
@@ -1784,6 +1530,24 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
}); });
}); });
// 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 |_| {
@@ -1799,11 +1563,38 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
} }
}); });
let ui_more = ui.clone(); let ui_download = ui.clone();
let viewer_more = Rc::downgrade(&viewer); let w_download = window.downgrade();
more_btn.connect_clicked(move |_| { let viewer_download = Rc::downgrade(&viewer);
if let Some(viewer_more) = viewer_more.upgrade() { download_btn.connect_clicked(move |_| {
open_viewer_menu(&ui_more, &viewer_more); let Some(w_download) = w_download.upgrade() else {
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());
} }
}); });
@@ -1881,22 +1672,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
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),
@@ -1905,20 +1687,30 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
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") => toggle_slideshow(&ui_key, &viewer_key), Some("F5") => 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.get() => stop_slideshow(&viewer_key), Some("Escape") if viewer_key.slideshow.is_active() => {
Some("F10" | "Menu") => open_viewer_menu(&ui_key, &viewer_key), viewer_key.slideshow.set_active(false)
}
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") => toggle_fullscreen(&w_key), Some("f" | "F11") => {
Some("Delete" | "KP_Delete") => trash_shown_photo(&ui_key, &viewer_key), if w_key.is_fullscreen() {
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
@@ -1926,113 +1718,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
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
+50 -318
View File
@@ -37,9 +37,6 @@ pub(crate) struct GalleryState {
pub(crate) burst: Cell<bool>, pub(crate) burst: Cell<bool>,
/// Says a Google Photos import is running, with a way to its page. /// Says a Google Photos import is running, with a way to its page.
pub(crate) import_banner: adw::Banner, pub(crate) import_banner: adw::Banner,
/// The bar that counts uploads from this computer through, under the
/// header while any run.
pub(crate) upload_bar: UploadBar,
pub(crate) upload: adw::SplitButton, pub(crate) upload: adw::SplitButton,
/// Upload / Import offered on the *empty timeline* status page, so a fresh /// Upload / Import offered on the *empty timeline* status page, so a fresh
/// account is a place to start rather than a dead end. Hidden on every other /// account is a place to start rather than a dead end. Hidden on every other
@@ -160,11 +157,10 @@ pub(crate) struct GalleryState {
/// Photos the daemon reported as having no thumbnail at all, so a tile that /// Photos the daemon reported as having no thumbnail at all, so a tile that
/// can never be filled isn't requested again on every scroll past it. /// can never be filled isn't requested again on every scroll past it.
pub(crate) photo_nothumb: RefCell<HashSet<String>>, pub(crate) photo_nothumb: RefCell<HashSet<String>>,
/// Pictures on screen still waiting for their thumbnail, by uid. Populated /// Tiles on screen still waiting for their thumbnail, by uid. Populated as
/// as they are bound, drained as batches land, cleared on unbind — so a /// sections are bound, drained as batches land, cleared on unbind — so a
/// batch only ever paints a widget that is still showing that photo. One /// batch only ever paints a widget that is still showing that photo.
/// photo can be waited for twice: by its tile and by the lightbox's strip. pub(crate) thumb_wanted: RefCell<HashMap<String, gtk4::Picture>>,
pub(crate) thumb_wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
/// Uids queued for the next [`Request::PhotoThumbs`] batch, and whether a /// Uids queued for the next [`Request::PhotoThumbs`] batch, and whether a
/// batch is already in flight (only one at a time, so a long scroll can't /// batch is already in flight (only one at a time, so a long scroll can't
/// stack requests on the daemon). /// stack requests on the daemon).
@@ -247,27 +243,6 @@ impl GalleryState {
pub(crate) fn in_collection(&self) -> bool { pub(crate) fn in_collection(&self) -> bool {
self.album.borrow().is_some() || self.place.borrow().is_some() self.album.borrow().is_some() || self.place.borrow().is_some()
} }
/// Have `picture` painted with the thumbnail of `uid` once it lands.
pub(crate) fn await_thumb(&self, uid: &str, picture: &gtk4::Picture) {
let mut wanted = self.thumb_wanted.borrow_mut();
let waiting = wanted.entry(uid.to_string()).or_default();
if !waiting.contains(picture) {
waiting.push(picture.clone());
}
}
/// Stop waiting for `uid` in the pictures `drop` picks, because they show
/// another photo now.
pub(crate) fn release_thumb(&self, uid: &str, drop: impl Fn(&gtk4::Picture) -> bool) {
let mut wanted = self.thumb_wanted.borrow_mut();
if let Some(waiting) = wanted.get_mut(uid) {
waiting.retain(|picture| !drop(picture));
if waiting.is_empty() {
wanted.remove(uid);
}
}
}
} }
/// How many photos to pull per [`Request::PhotosTimeline`] page. /// How many photos to pull per [`Request::PhotosTimeline`] page.
@@ -481,9 +456,6 @@ pub(crate) struct GalleryWidgets {
pub(crate) scrubber: Scrubber, pub(crate) scrubber: Scrubber,
/// Says a Google Photos import is running, with a way to its page. /// Says a Google Photos import is running, with a way to its page.
pub(crate) import_banner: adw::Banner, pub(crate) import_banner: adw::Banner,
/// The bar that counts uploads from this computer through, under the
/// header while any run.
pub(crate) upload_bar: UploadBar,
pub(crate) list: gtk4::ListView, pub(crate) list: gtk4::ListView,
pub(crate) scroll: gtk4::ScrolledWindow, pub(crate) scroll: gtk4::ScrolledWindow,
pub(crate) retry: gtk4::Button, pub(crate) retry: gtk4::Button,
@@ -909,8 +881,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.action_name("win.show-import") .action_name("win.show-import")
.build(); .build();
let upload_bar = UploadBar::new();
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12); inner.set_margin_top(12);
inner.set_margin_bottom(12); inner.set_margin_bottom(12);
@@ -921,7 +891,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0); let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
body.append(&import_banner); body.append(&import_banner);
body.append(&upload_bar.revealer);
body.append(&inner); body.append(&inner);
// The title slot holds the view switch on the page itself, the album's or // The title slot holds the view switch on the page itself, the album's or
// place's name inside one, and the count in selection mode (see // place's name inside one, and the count in selection mode (see
@@ -952,7 +921,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
pager, pager,
scrubber, scrubber,
import_banner, import_banner,
upload_bar,
list, list,
scroll, scroll,
retry, retry,
@@ -1346,10 +1314,14 @@ pub(crate) fn wire_gallery(
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
&& let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::<GalleryRow>() && let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::<GalleryRow>()
{ {
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
for tile in tiles { for tile in tiles {
ui_unbind if wanted
.gallery .get(&tile.photo.uid)
.release_thumb(&tile.photo.uid, |picture| picture.is_ancestor(&row_box)); .is_some_and(|picture| picture.is_ancestor(&row_box))
{
wanted.remove(&tile.photo.uid);
}
} }
} }
}); });
@@ -1394,13 +1366,6 @@ pub(crate) fn wire_gallery(
sync_float_heading(&ui_scroll); sync_float_heading(&ui_scroll);
ui_scroll.gallery.scrubber.flash(); ui_scroll.gallery.scrubber.flash();
}); });
// Zooming out or making the window taller can leave every loaded photo on
// one screen. Nothing scrolls then, so the scrolling above never asks for
// the next page: ask whenever the content shrinks under the viewport.
let ui_fill = ui.clone();
scroll
.vadjustment()
.connect_changed(move |_| fill_viewport(&ui_fill));
wire_scrubber(ui); wire_scrubber(ui);
wire_drag_select(ui); wire_drag_select(ui);
@@ -1476,7 +1441,7 @@ pub(crate) fn wire_gallery(
let ui_album = ui.clone(); let ui_album = ui.clone();
selection.album.connect_clicked(move |_| { selection.album.connect_clicked(move |_| {
let uids: Vec<String> = ui_album.gallery.selected.borrow().iter().cloned().collect(); let uids: Vec<String> = ui_album.gallery.selected.borrow().iter().cloned().collect();
prompt_add_to_album(&ui_album, uids, None); prompt_add_to_album(&ui_album, uids);
}); });
let ui_remove = ui.clone(); let ui_remove = ui.clone();
selection.remove.connect_clicked(move |_| { selection.remove.connect_clicked(move |_| {
@@ -1530,11 +1495,9 @@ pub(crate) fn wire_gallery(
gtk4::gdk::DragAction::COPY, gtk4::gdk::DragAction::COPY,
); );
let ui_drop = ui.clone(); let ui_drop = ui.clone();
drop.connect_accept(move |_, drop| { drop.connect_accept(move |_, _| {
let gallery = &ui_drop.gallery; let gallery = &ui_drop.gallery;
// Photos dragged out of the timeline are already in it. !gallery.in_collection()
drop.drag().is_none()
&& !gallery.in_collection()
&& gallery.view_switch.active() == VIEW_TIMELINE && gallery.view_switch.active() == VIEW_TIMELINE
&& gallery.library.get() && gallery.library.get()
&& *ui_drop.mounted.borrow() && *ui_drop.mounted.borrow()
@@ -1932,86 +1895,6 @@ pub(crate) fn copy_without_overwrite(
unreachable!("some numbered name is free") unreachable!("some numbered name is free")
} }
/// How far the uploads from this computer have come: a count and a bar, so a
/// drop of a few hundred photos shows it is moving. Batches started while one
/// runs join its count.
#[derive(Clone)]
pub(crate) struct UploadBar {
pub(crate) revealer: gtk4::Revealer,
label: gtk4::Label,
progress: gtk4::ProgressBar,
/// Uploads finished, and uploads asked for, since the bar last emptied.
count: Rc<Cell<(usize, usize)>>,
}
impl UploadBar {
fn new() -> Self {
let label = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
let progress = gtk4::ProgressBar::builder().hexpand(true).build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
content.set_margin_top(10);
content.set_margin_bottom(4);
content.set_margin_start(16);
content.set_margin_end(16);
content.append(&label);
content.append(&progress);
let revealer = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideDown)
.child(&content)
.build();
Self {
revealer,
label,
progress,
count: Rc::default(),
}
}
/// Count `n` more uploads in.
pub(crate) fn add(&self, n: usize) {
let (done, total) = self.count.get();
self.count.set((done, total + n));
self.paint();
}
/// Count one upload as finished, whether or not it went through.
pub(crate) fn finish(&self) {
let (done, total) = self.count.get();
let done = (done + 1).min(total);
if done == total {
self.count.set((0, 0));
self.revealer.set_reveal_child(false);
return;
}
self.count.set((done, total));
self.paint();
}
fn paint(&self) {
let (done, total) = self.count.get();
if total == 0 {
return;
}
self.label.set_label(&upload_progress_text(done, total));
self.progress.set_fraction(done as f64 / total as f64);
self.revealer.set_reveal_child(true);
}
}
/// "Uploading 3 of 12 items…": the upload under way is number `done + 1`.
pub(crate) fn upload_progress_text(done: usize, total: usize) -> String {
// Translators: progress above the photo timeline while photos upload; {position} is the upload under way, {n} how many there are.
ngettext_f(
"Uploading {position} of {n} item…",
"Uploading {position} of {n} items…",
total as u64,
&[("position", &thousands(done + 1))],
)
}
/// Upload photos and videos from this computer into the timeline, one after /// Upload photos and videos from this computer into the timeline, one after
/// the other, then reload it once. /// the other, then reload it once.
pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) { pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
@@ -2026,7 +1909,15 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
let total = uploads.len(); let total = uploads.len();
ui.busy_begin(); ui.busy_begin();
if total > 1 { if total > 1 {
ui.gallery.upload_bar.add(total); toast(
ui,
&ngettext_f(
"Uploading {n} item…",
"Uploading {n} items…",
total as u64,
&[],
),
);
} }
let ui = ui.clone(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
@@ -2064,9 +1955,6 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
)) ))
} }
} }
if total > 1 {
ui.gallery.upload_bar.finish();
}
} }
ui.busy_end(); ui.busy_end();
if uploaded > 0 { if uploaded > 0 {
@@ -2475,23 +2363,6 @@ impl TileWidget {
}); });
button.add_controller(context); button.add_controller(context);
// A picked photo drags out, with the rest of the selection, into a
// file manager or any app that takes files. A drag from anywhere else
// selects instead (see `wire_drag_select`).
let drag_out = gtk4::DragSource::new();
drag_out.set_actions(gtk4::gdk::DragAction::COPY);
let ui_drag = ui.clone();
let drag_uid = uid.clone();
drag_out.connect_prepare(move |source, x, y| {
let photos = dragged_photos(&ui_drag, &drag_uid()?)?;
if let Some(tile) = source.widget() {
let icon = gtk4::WidgetPaintable::new(Some(&tile));
source.set_icon(Some(&icon), x as i32, y as i32);
}
Some(PhotoDrag::new(ui_drag.dirs.control_socket(), photos).upcast())
});
button.add_controller(drag_out);
// A tile opens in the in-app lightbox, which plays a video in place. // A tile opens in the in-app lightbox, which plays a video in place.
// In selection mode a tile picks instead of opening: the lightbox is // In selection mode a tile picks instead of opening: the lightbox is
// one Escape away. // one Escape away.
@@ -2584,28 +2455,13 @@ impl TileWidget {
let Some(photo) = self.photo.borrow_mut().take() else { let Some(photo) = self.photo.borrow_mut().take() else {
return; return;
}; };
ui.gallery let mut wanted = ui.gallery.thumb_wanted.borrow_mut();
.release_thumb(&photo.uid, |picture| picture == &self.picture); if wanted.get(&photo.uid) == Some(&self.picture) {
wanted.remove(&photo.uid);
}
} }
} }
/// The photos a drag that starts on `uid` carries out: the whole selection,
/// in timeline order, as uid and name. `None` unless `uid` is picked.
fn dragged_photos(ui: &Rc<Ui>, uid: &str) -> Option<Vec<(String, Option<String>)>> {
let selected = ui.gallery.selected.borrow();
if !ui.gallery.selecting.get() || !selected.contains(uid) {
return None;
}
let model = &ui.gallery.model;
let photos: Vec<(String, Option<String>)> = (0..model.n_items())
.filter_map(|index| model.item(index).and_downcast::<BoxedAnyObject>())
.map(|boxed| boxed.borrow::<PhotoItem>().clone())
.filter(|photo| selected.contains(&photo.uid))
.map(|photo| (photo.uid, photo.name))
.collect();
(!photos.is_empty()).then_some(photos)
}
/// Add or remove one CSS class, for widgets that are rebound rather than /// Add or remove one CSS class, for widgets that are rebound rather than
/// rebuilt. /// rebuilt.
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) { fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
@@ -2684,9 +2540,6 @@ struct DragSelect {
pointer: (f64, f64), pointer: (f64, f64),
/// The edge autoscroll, while the pointer is near the top or bottom. /// The edge autoscroll, while the pointer is near the top or bottom.
scroll: Option<glib::SourceId>, scroll: Option<glib::SourceId>,
/// The drag started on a picked photo, so it carries the selection out
/// of the app rather than selecting.
out: bool,
} }
/// Dragging across the timeline selects every photo from where the drag /// Dragging across the timeline selects every photo from where the drag
@@ -2703,33 +2556,21 @@ fn wire_drag_select(ui: &Rc<Ui>) {
let state = Rc::new(RefCell::new(DragSelect::default())); let state = Rc::new(RefCell::new(DragSelect::default()));
let state_begin = state.clone(); let state_begin = state.clone();
let ui_begin = ui.clone(); drag.connect_drag_begin(move |_, x, y| {
drag.connect_drag_begin(move |gesture, x, y| {
let mut drag = state_begin.borrow_mut(); let mut drag = state_begin.borrow_mut();
if let Some(source) = drag.scroll.take() { if let Some(source) = drag.scroll.take() {
source.remove(); source.remove();
} }
let out = ui_begin.gallery.selecting.get()
&& tile_at(&ui_begin, x, y)
.is_some_and(|uid| ui_begin.gallery.selected.borrow().contains(&uid));
*drag = DragSelect { *drag = DragSelect {
start: (x, y), start: (x, y),
pointer: (x, y), pointer: (x, y),
out,
..DragSelect::default() ..DragSelect::default()
}; };
// Left to the tile's own drag source.
if out {
gesture.set_state(gtk4::EventSequenceState::Denied);
}
}); });
let ui_update = ui.clone(); let ui_update = ui.clone();
let state_update = state.clone(); let state_update = state.clone();
drag.connect_drag_update(move |gesture, dx, dy| { drag.connect_drag_update(move |gesture, dx, dy| {
if state_update.borrow().out {
return;
}
let (sx, sy) = state_update.borrow().start; let (sx, sy) = state_update.borrow().start;
state_update.borrow_mut().pointer = (sx + dx, sy + dy); state_update.borrow_mut().pointer = (sx + dx, sy + dy);
if !state_update.borrow().active { if !state_update.borrow().active {
@@ -2923,18 +2764,6 @@ pub(crate) fn delete_selected(ui: &Rc<Ui>) {
/// Confirm, then move the photos behind `uids` to Proton trash. /// Confirm, then move the photos behind `uids` to Proton trash.
pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) { pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
ask_trash_photos(ui, uids, None, || {});
}
/// [`confirm_trash_photos`] over `parent` rather than the main window, for
/// the photo viewer, which covers it. `before` runs once the user confirms,
/// before the photos go.
pub(crate) fn ask_trash_photos(
ui: &Rc<Ui>,
uids: Vec<String>,
parent: Option<gtk4::Window>,
before: impl Fn() + 'static,
) {
if uids.is_empty() { if uids.is_empty() {
return; return;
} }
@@ -2974,11 +2803,10 @@ pub(crate) fn ask_trash_photos(
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
let win = parent.or_else(|| ui_window(ui)); let win = ui_window(ui);
let ui = ui.clone(); let ui = ui.clone();
dialog.connect_response(None, move |_, response| { dialog.connect_response(None, move |_, response| {
if response == "trash" { if response == "trash" {
before();
trash_photos(&ui, uids.clone(), false); trash_photos(&ui, uids.clone(), false);
} }
}); });
@@ -3123,7 +2951,10 @@ pub(crate) fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: &gtk4::Picture
return; return;
} }
ui.gallery.await_thumb(&photo.uid, picture); ui.gallery
.thumb_wanted
.borrow_mut()
.insert(photo.uid.clone(), picture.clone());
match photo.thumb_path.as_deref() { match photo.thumb_path.as_deref() {
Some(path) => { Some(path) => {
@@ -3342,8 +3173,7 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
} }
}; };
ui.store_texture(&uid, texture.clone()); ui.store_texture(&uid, texture.clone());
let waiting = ui.gallery.thumb_wanted.borrow_mut().remove(&uid); if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
for picture in waiting.into_iter().flatten() {
picture.set_paintable(Some(&texture)); picture.set_paintable(Some(&texture));
} }
} }
@@ -3458,16 +3288,6 @@ fn top_anchor(ui: &Rc<Ui>) -> Option<(String, f32)> {
.or(first) .or(first)
} }
/// Scroll the grid just far enough to show photo `uid`, as the lightbox
/// closes: paging with the arrow keys may have left the grid far behind.
pub(crate) fn reveal_photo(ui: &Rc<Ui>, uid: &str) {
if let Some(row) = row_of_photo(&ui.gallery.groups, uid) {
ui.gallery
.list
.scroll_to(row, gtk4::ListScrollFlags::NONE, None);
}
}
/// Which row of the rendered model holds `uid`, if any. /// Which row of the rendered model holds `uid`, if any.
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> { fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
(0..store.n_items()).find(|i| { (0..store.n_items()).find(|i| {
@@ -4097,32 +3917,17 @@ fn continue_jump(ui: &Rc<Ui>) {
/// Load the next page when the timeline does not fill the window yet: with no /// Load the next page when the timeline does not fill the window yet: with no
/// scrollbar there is no scrolling to trigger it. /// scrollbar there is no scrolling to trigger it.
fn fill_viewport(ui: &Rc<Ui>) { fn fill_viewport(ui: &Rc<Ui>) {
if !ui.gallery.has_more.get() || ui.gallery.loading.get() {
return;
}
let ui = ui.clone(); let ui = ui.clone();
glib::idle_add_local_once(move || { glib::idle_add_local_once(move || {
let Some(adj) = ui.gallery.list.vadjustment() else { let Some(adj) = ui.gallery.list.vadjustment() else {
return; return;
}; };
let timeline = ui.gallery.content.visible_child_name().as_deref() == Some("timeline"); if ui.gallery.has_more.get() && adj.upper() <= adj.page_size() + 1.0 {
if timeline
&& ui.gallery.has_more.get()
&& adj.page_size() > 0.0
&& viewport_unfilled(adj.upper(), adj.page_size())
{
load_gallery(&ui, true); load_gallery(&ui, true);
} }
}); });
} }
/// Whether content `upper` px tall leaves a viewport `page_size` px tall short
/// of a screen and a half to scroll through, the margin the scrolling pager
/// keeps. Below it the pager would never fire.
pub(crate) fn viewport_unfilled(upper: f64, page_size: f64) -> bool {
upper <= page_size * 2.5 + 1.0
}
/// Select every photo loaded so far (Ctrl+A). /// Select every photo loaded so far (Ctrl+A).
pub(crate) fn select_all_photos(ui: &Rc<Ui>) { pub(crate) fn select_all_photos(ui: &Rc<Ui>) {
set_selection_mode(ui, true); set_selection_mode(ui, true);
@@ -4147,9 +3952,7 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: &gtk4::Button, x: f64
} else { } else {
pgettext("verb", "Open") pgettext("verb", "Open")
}; };
menu.item(&label, move || { menu.item(&label, move || open_photo_viewer(&ui_c, uid.clone()));
open_photo_viewer(&ui_c, uid.clone());
});
let (ui_c, uid) = (ui.clone(), photo.uid.clone()); let (ui_c, uid) = (ui.clone(), photo.uid.clone());
// Translators: a check item in a photo's menu, on while the photo is a favorite. // Translators: a check item in a photo's menu, on while the photo is a favorite.
let favorite_label = pgettext("state", "Favorite"); let favorite_label = pgettext("state", "Favorite");
@@ -4164,18 +3967,8 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: &gtk4::Button, x: f64
menu.section(); menu.section();
let (ui_c, uid) = (ui.clone(), photo.uid.clone()); let (ui_c, uid) = (ui.clone(), photo.uid.clone());
menu.item(&gettext("Add to Album…"), move || { menu.item(&gettext("Add to Album…"), move || {
prompt_add_to_album(&ui_c, vec![uid.clone()], None) prompt_add_to_album(&ui_c, vec![uid.clone()])
}); });
if photo_shareable(ui) {
let (ui_c, uid, name) = (
ui.clone(),
photo.uid.clone(),
photo.name.clone().unwrap_or_default(),
);
menu.item(&gettext("Share…"), move || {
share_photo(&ui_c, uid.clone(), name.clone(), None)
});
}
let open_album = ui.gallery.album.borrow().clone(); let open_album = ui.gallery.album.borrow().clone();
if let Some(album) = open_album.filter(|album| !album.shared) { if let Some(album) = open_album.filter(|album| !album.shared) {
let (ui_c, uid) = (ui.clone(), photo.uid.clone()); let (ui_c, uid) = (ui.clone(), photo.uid.clone());
@@ -4227,44 +4020,8 @@ fn set_photo_favorite(ui: &Rc<Ui>, uid: String, favorite: bool) {
}); });
} }
/// Whether the photos on show are the user's own to share: everything but an
/// album someone else shared, whose photos live on that person's volume.
pub(crate) fn photo_shareable(ui: &Ui) -> bool {
!ui.gallery
.album
.borrow()
.as_ref()
.is_some_and(|album| album.shared)
}
/// Open the Share dialog for one photo, addressed by uid: the photos volume
/// has no path in any mount. Over `parent` when the lightbox is open.
pub(crate) fn share_photo(ui: &Rc<Ui>, uid: String, name: String, parent: Option<gtk4::Window>) {
let entry = DirEntry {
uid,
name,
..DirEntry::default()
};
open_share_dialog_over(ui, &entry, parent);
}
/// Whether the photo grid is the view the Photos page shows: an album or a
/// place, or the timeline itself. Under Albums or Places it is out of sight.
pub(crate) fn photo_grid_shown(ui: &Ui) -> bool {
ui.gallery.in_collection() || ui.gallery.view_switch.active() == VIEW_TIMELINE
}
pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) { pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
sync_filter_badge(ui); sync_filter_badge(ui);
// A load would put the grid, or its status page, on screen in place of
// the Albums or Places view the user is looking at. Leave the timeline to
// be read again when they switch back to it.
if !photo_grid_shown(ui) {
ui.gallery.timeline_stale.set(true);
// A scrubber jump was paging the grid the user has just left.
ui.gallery.jump.set(None);
return;
}
if ui.gallery.loading.get() { if ui.gallery.loading.get() {
// A filter flipped while a page is on its way: that page answers the // A filter flipped while a page is on its way: that page answers the
// old question, so fetch again once it lands. More pages can wait. // old question, so fetch again once it lands. More pages can wait.
@@ -4280,9 +4037,6 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
let place = ui.gallery.place.borrow().as_ref().map(|p| p.id); let place = ui.gallery.place.borrow().as_ref().map(|p| p.id);
let collection = album.is_some() || place.is_some(); let collection = album.is_some() || place.is_some();
if !append { if !append {
// A scrubber jump looks for a month of the listing being replaced:
// another filter, album or place has its own months, if any.
ui.gallery.jump.set(None);
// Fresh load: clear the timeline and show Loading until the first page lands. // Fresh load: clear the timeline and show Loading until the first page lands.
ui.gallery.model.remove_all(); ui.gallery.model.remove_all();
gallery_status( gallery_status(
@@ -4344,12 +4098,6 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
if !waiters.is_empty() { if !waiters.is_empty() {
glib::idle_add_local_once(move || waiters.into_iter().for_each(|waiter| waiter())); glib::idle_add_local_once(move || waiters.into_iter().for_each(|waiter| waiter()));
} }
// The user left for Albums or Places while the page was on its way;
// painting it now would switch the view under them.
if !photo_grid_shown(&ui) {
ui.gallery.timeline_stale.set(true);
return;
}
match result { match result {
Ok(Ok(Response::Photos { Ok(Ok(Response::Photos {
available, available,
@@ -4390,20 +4138,6 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
ui.gallery.model.append(&BoxedAnyObject::new(item.clone())); ui.gallery.model.append(&BoxedAnyObject::new(item.clone()));
} }
repaint_gallery(&ui); repaint_gallery(&ui);
// The rows are swapped in place, which keeps the old scroll
// offset; a fresh load (another kind, month, album or place)
// reads from its newest photo.
if !append
&& ui
.gallery
.list
.model()
.is_some_and(|rows| rows.n_items() > 0)
{
ui.gallery
.list
.scroll_to(0, gtk4::ListScrollFlags::NONE, None);
}
if ui.gallery.model.n_items() == 0 { if ui.gallery.model.n_items() == 0 {
let filtered = ui.gallery.kind.get().is_some() let filtered = ui.gallery.kind.get().is_some()
|| ui.gallery.favorites.get() || ui.gallery.favorites.get()
@@ -4502,13 +4236,24 @@ pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false)); service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false));
} }
/// Play a video with an external player. Prefers `mpv` — it sniffs the container
/// from the bytes, so the cache's extensionless blob plays fine, and it is the
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
/// user's default handler when mpv isn't installed.
pub(crate) fn play_external(path: &str) {
match Command::new("mpv").arg(path).spawn() {
Ok(child) => pdfs_core::opener::reap(child),
Err(_) => open_path(path),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
Grouping, PHOTOS_PAGE, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX, Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX,
TILE_RATIO_MIN, active_filters, copy_without_overwrite, empty_timeline_text, grouping_for, TILE_RATIO_MIN, active_filters, copy_without_overwrite, empty_timeline_text, grouping_for,
memory_years, month_index, month_range, plan_grid, plan_justified, section_heading, memory_years, month_index, month_range, plan_grid, plan_justified, section_heading,
tile_ratio, viewport_unfilled, tile_ratio,
}; };
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth}; use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth};
@@ -4631,18 +4376,6 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap(); std::fs::remove_dir_all(&dir).unwrap();
} }
/// A first page zoomed all the way out fits on one screen, with nothing
/// to scroll: the next page has to load without a scroll asking for it.
#[test]
fn a_page_zoomed_out_onto_one_screen_asks_for_more() {
let (columns, size) = plan_grid(1580, ROW_MIN);
let rows = PHOTOS_PAGE.div_ceil(columns) as i32;
let height = f64::from(rows * (size + TILE_GAP) + 40);
assert!(viewport_unfilled(height, 880.0));
assert!(viewport_unfilled(880.0, 880.0));
assert!(!viewport_unfilled(880.0 * 3.0, 880.0));
}
#[test] #[test]
fn zooming_in_puts_fewer_tiles_in_a_row() { fn zooming_in_puts_fewer_tiles_in_a_row() {
assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0); assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0);
@@ -4707,7 +4440,6 @@ mod tests {
favorite: false, favorite: false,
group_size: 1, group_size: 1,
has_raw: false, has_raw: false,
place_id: None,
} }
}; };
// Newest first, the way the daemon sends them. // Newest first, the way the daemon sends them.
+9 -43
View File
@@ -267,12 +267,7 @@ 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 let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
.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);
@@ -367,12 +362,7 @@ 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: &gtk4::Picture) { fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: &gtk4::Picture) {
let waiting = ui let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
.gallery
.thumb_wanted
.borrow()
.get(&photo.uid)
.and_then(|waiting| waiting.first().cloned());
match waiting { match waiting {
Some(waiting) => { Some(waiting) => {
waiting waiting
@@ -402,45 +392,21 @@ fn load_place_locations(ui: &Rc<Ui>, id: u32) {
}); });
} }
/// Open photo `uid` of `place` from the map or a spot's list, paging through /// Open `place` and then photo `uid` in it, paging through the town until
/// the town until the photo has loaded. /// 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, id, uid); view_when_loaded(ui, uid);
} }
/// The open place's id, if a place is open. fn view_when_loaded(ui: &Rc<Ui>, uid: String) {
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() {
let window = open_photo_viewer(&ui, uid); open_photo_viewer(&ui, uid);
let ui_close = ui.clone(); } else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() {
window.connect_close_request(move |_| { view_when_loaded(&ui, uid);
// 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);
} }
+5 -14
View File
@@ -279,7 +279,10 @@ 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 |_| shared_back(&ui_back)); widgets.back.connect_clicked(move |_| {
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
@@ -315,14 +318,6 @@ 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.
/// ///
@@ -448,7 +443,6 @@ 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();
@@ -473,11 +467,8 @@ 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. Another folder starts from its first entry. // and the scroll position.
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
+6 -8
View File
@@ -1115,6 +1115,9 @@ 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() {
@@ -1208,18 +1211,13 @@ 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", &title), ("title", &j.title),
("detail", &detail), ("detail", &j.detail),
("done", &done), ("done", &done),
("total", &total), ("total", &total),
]; ];
let text = match (j.total > 0, detail.is_empty()) { let text = match (j.total > 0, j.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),
+158 -326
View File
@@ -15,30 +15,17 @@
//! 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, and asks [`Request::ImportStatus`] for the step, the //! stages paths, launches, asks [`Request::ImportStatus`] for the final report,
//! running counts and the final report. It asks again whenever the import's job //! and reads live progress off the import's job in the transfer snapshot the
//! moves in the transfer snapshot the daemon already pushes. A run the service //! daemon already pushes (see [`takeout_progress`]).
//! 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`].
/// Seeing it move is the cue to ask for the import's status; the Activity list /// Matching on it is how this page borrows the progress the daemon already
/// shows it under its translated name ([`import_job_text`]). /// pushes instead of asking for it separately.
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.
@@ -108,19 +95,18 @@ 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,
/// The running counts, or the report for the last finished run; hidden /// Report group for the last finished run, hidden until there is one.
/// 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.
@@ -140,9 +126,6 @@ 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) {
@@ -278,23 +261,7 @@ 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);
@@ -334,9 +301,6 @@ 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)
} }
@@ -463,28 +427,6 @@ 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
@@ -617,7 +559,6 @@ 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);
} }
@@ -636,6 +577,7 @@ 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(),
@@ -646,7 +588,27 @@ 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 { .. })) => import_started(&ui, dry_run), Ok(Ok(Response::Ok { .. })) => {
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);
} }
@@ -659,39 +621,9 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
}); });
} }
/// Switch the page over to reporting progress once the daemon took the run. /// Ask the daemon for the import's state, and paint the report once a run has
fn import_started(ui: &Rc<Ui>, dry_run: bool) { /// finished. Asked on arrival and while import progress keeps arriving. Cheap
ui.takeout.running.set(true); /// and inflight-guarded.
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;
@@ -702,250 +634,124 @@ 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 { let Ok(Ok(Response::ImportStatus { running, summary })) = result else {
running,
summary,
progress,
resumable,
})) = result
else {
return; return;
}; };
let was_running = ui.takeout.running.replace(running); let was_running = ui.takeout.running.replace(running);
let dry = summary.as_ref().is_some_and(|s| s.dry_run); ui.gallery.import_banner.set_revealed(running);
ui.gallery.import_banner.set_revealed(running && !dry);
ui.takeout.progress_group.set_visible(running);
if running { if running {
ui.takeout.progress_group.set_title(&if dry { ui.takeout.progress_group.set_visible(true);
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, running), Some(summary) => repaint_summary(&ui, &summary),
None => ui.takeout.summary_group.set_visible(false), None => ui.takeout.summary_group.set_visible(false),
} }
paint_resume(&ui, running, summary.as_ref(), &resumable);
}); });
} }
/// Tell the user how a run ended. An import runs for hours, so its ending is /// Paint the last run's report.
/// announced rather than left to be discovered: a toast for whoever is looking fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
/// 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 = summary.dry_run; let dry = ui.takeout.dry_run.get();
ui.takeout.summary_group.set_title(&if running { ui.takeout.summary_group.set_title(&if dry {
gettext("So far")
} else if dry {
gettext("Scan result") gettext("Scan result")
} else { } else {
gettext("Last import") gettext("Last import")
}); });
let description = if running { ui.takeout.summary_group.set_description(Some(&if let Some(error) = &summary.error {
None // The reason is the service's own text, which is not translated. A
} else if let Some(error) = &summary.error { // group's description is markup, and the reason may name a path.
// The reason is the service's own text, which is not translated; the glib::markup_escape_text(&gettext_f(
// headline in front of it is. A group's description is markup, and the "{what}: {detail}",
// reason may name a path. &[
let what = gettext("Couldn't import from Google Photos"); ("what", &gettext("Couldn't import from Google Photos")),
Some( ("detail", error),
glib::markup_escape_text(&gettext_f( ],
"{what}: {detail}", ))
&[ .to_string()
(
"what",
error_headline(summary.error_kind.unwrap_or_default(), &what),
),
("detail", error),
],
))
.to_string(),
)
} else if dry { } else if dry {
Some(gettext( gettext("Nothing was uploaded — this is what an import would do.")
"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 {
Some(gettext( gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
"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 {
Some(gettext("Finished.")) 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)> = let mut lines: Vec<(String, String)> = vec![
vec![(gettext("Photos in the export"), summary.found.to_string())]; (gettext("Photos in the export"), summary.found.to_string()),
if dry { (
lines.push((gettext("Would upload"), summary.to_upload.to_string())); if dry {
} else { gettext("Would upload")
lines.push((gettext("Uploaded"), summary.uploaded.to_string())); } else {
} gettext("Uploaded")
},
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((
gettext("Albums created"), if dry {
gettext("Albums to create")
} else {
gettext("Albums created")
},
summary.albums_created.to_string(), summary.albums_created.to_string(),
)); ));
} }
@@ -961,31 +767,25 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary, running: bool) {
summary.skipped_trashed.to_string(), summary.skipped_trashed.to_string(),
)); ));
} }
if dry && summary.to_upload_bytes > 0 { if summary.bytes > 0 {
lines.push(( lines.push((
gettext("Data to upload"), if dry {
human_bytes(summary.to_upload_bytes), gettext("Data to upload")
} 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);
@@ -994,3 +794,35 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary, running: bool) {
*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()
});
}
}
@@ -224,49 +224,6 @@ 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")
+2 -9
View File
@@ -101,14 +101,8 @@ 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. Returned for a caller that /// item that closed it still finds its action.
/// has to know when it closes. pub(crate) fn popup_at(self, anchor: &impl IsA<gtk4::Widget>, x: f64, y: f64) {
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);
@@ -128,7 +122,6 @@ 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.
-2
View File
@@ -3,8 +3,6 @@ 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;
@@ -1,237 +0,0 @@
//! 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);
}
}
-356
View File
@@ -1,356 +0,0 @@
//! 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: &gtk4::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: &gtk4::Video) {
if let Some(stream) = video.media_stream() {
stream.set_playing(!stream.is_playing());
}
}
fn toggle_muted(video: &gtk4::Video) {
if let Some(stream) = video.media_stream() {
stream.set_muted(!stream.is_muted());
}
}
fn change_volume(video: &gtk4::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: &gtk4::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: &gtk4::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: &gtk4::Video, window: &gtk4::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: &gtk4::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: &gtk4::Window) -> gtk4::Button {
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
let paint = |button: &gtk4::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, &gtk_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, &note, entry, true);
let overlay = gtk4::Overlay::new();
overlay.set_child(Some(&video));
overlay.add_overlay(&note);
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(&gtk_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,13 +334,6 @@ 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,
@@ -498,7 +491,7 @@ pub(crate) fn open_share_dialog_over(ui: &Rc<Ui>, entry: &DirEntry, parent: Opti
} }
}); });
share_dialog_reload(&state); share_dialog_reload(&state);
dialog.present(parent.or_else(|| ui_window(ui)).as_ref()); dialog.present(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.
+6 -114
View File
@@ -11,7 +11,6 @@ use pdfs_core::control::{FileThumbRequest, is_thumbnail_media_name, is_video_fil
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);
@@ -21,9 +20,6 @@ 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>>,
@@ -31,18 +27,6 @@ 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,
@@ -71,7 +55,6 @@ 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),
@@ -105,85 +88,6 @@ impl FileThumbnailState {
} }
} }
} }
fn remember_blank(&self, key: &str, request: &FileThumbRequest, picture: &gtk4::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
@@ -326,12 +230,6 @@ pub(crate) fn bind_file_thumbnail(
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;
} }
@@ -344,7 +242,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(request); queue.push_back(FileThumbRequest {
uid: entry.uid.clone(),
modified: entry.modified,
name: entry.name.clone(),
});
drop(queue); drop(queue);
schedule_file_thumbs(ui); schedule_file_thumbs(ui);
} }
@@ -485,7 +387,6 @@ 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)
{ {
@@ -546,16 +447,7 @@ fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ReplyDisposition, reply_disposition, tile_still_blank}; use super::{ReplyDisposition, reply_disposition};
#[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 { fn is_image_name(name: &str) -> bool {
pdfs_core::control::is_thumbnail_image_name(name) pdfs_core::control::is_thumbnail_image_name(name)
-3
View File
@@ -24,7 +24,6 @@ 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",
@@ -69,14 +68,12 @@ const STOCK: &[&str] = &[
"text-x-generic-symbolic", "text-x-generic-symbolic",
"user-trash-symbolic", "user-trash-symbolic",
"video-x-generic-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",
-1
View File
@@ -467,7 +467,6 @@ 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` |
-262
View File
@@ -109,15 +109,7 @@ a live account since.
| Entry | Title | | Entry | Title |
|---|---| |---|---|
| [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 | | [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
| [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed | | [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed |
| [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back | | [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back |
| [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds | | [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds |
@@ -392,190 +384,6 @@ a live account since.
Newest first. Newest first.
### B289 — The Takeout import mixes in other services and drops location and live photos
**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 ### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
**Status:** Fixed, not verified live **Status:** Fixed, not verified live
@@ -619,76 +427,6 @@ videos of over 70 MiB and takes nothing from a video with an uploader's thumbnai
`real_video_fixture_yields_a_frame` passed on 2026-11-14 with ffmpeg 9.0 for a 46 MB MP4 with its `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. index at the end, an MPEG-TS and an MKV.
### B282 — A file kept offline is downloaded again after a change made elsewhere
**Status:** Fixed, not verified live
**Found:** 2026-11-13, while looking into B281. Nothing downloaded a pinned file again after the
pin had downloaded it once.
**Where:** `apply_event` in `crates/pdfs-fuse/src/background.rs`; the new pin keeper in
`crates/pdfs-fuse/src/keeper.rs`
**Repro:** Pin a file, then change it from another device. The event deletes the cached copy.
The file stays listed as kept offline, `cached` is false, and the next read downloads it, or
fails without a network. Rename or move it elsewhere and the same happens, though its content
did not change. Add a file to a pinned folder from another device and it is never downloaded.
A folder pin whose downloads failed while the link was down never retries them.
**Cause:** A remote change to a known node called `ContentCache::evict`, which does not check
pins, and only the pin request itself downloaded anything. The copy was also stored under the
size the listing held at pin time. That is the ciphertext size until the size upgrade lands
(B12), and a copy stored under it never matches the plaintext it holds.
**Fix:** A remote change no longer evicts a pinned file's copy. The copy is checked against the
revision's `(mtime, size)`, so a read of a newer revision does not take it. A rename keeps the
revision's mtime (B123), so the copy stays valid. A pin keeper thread checks pins again and
downloads files that are missing or stale:
- the pins a batch of remote events changed, after the batch's listings are dropped;
- a pinned folder that gets a new file;
- every pin at startup, when the link comes back, and after the event feed loses continuity;
- a file whose version is restored.
Failed downloads are tried again with a backoff from 30 s to 30 min. Pinning settles a
provisional size before it stores the copy.
**Test:** `a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere` in
`crates/pdfs-fuse/src/sim/daemon.rs`. Another device edits a pinned file and a file in a pinned
folder, and adds a file to that folder. The test waits until all three are cached, then reads
them with the link down. With the keeper disabled, it fails with all three uncached.
---
### B281 — A file in a synced folder cannot be kept offline
**Status:** Fixed, not verified live
**Found:** 2026-11-13. An image was made available offline in the app, and it opened from the app
with no download. `feh` opened it from a terminal and downloaded it each time. The two were not
the same file. The app had kept `~/ProtonDrive/Pictures/photo.png`, in My files. The
terminal opened `~/Pictures/photo.png`, in the on-demand synced folder `~/Pictures`. That
copy is a separate node under Computers, uploaded on its own, with the same name and size but a
different mtime. The daemon treats every program the same way: it does not look at the process
that reads.
**Where:** the `Pin` and `Unpin` handlers in `crates/pdfs-fuse/src/control.rs`
**Repro:** Add a synced folder in on-demand mode and put a file in it. `pdfs pin` on its absolute
path fails with "is not under the mountpoint". A relative path is resolved against My files, so
it keeps a file of the same name there, if one exists, or fails. Reading the file in the synced
folder then downloads it.
**Cause:** Both handlers turned the path into a mount-relative one with `rel_to_mount`, which only
knows the primary mount. B86 moved `ls`, `refresh`, `rm` and `mv` to `route_to_mount`, which finds
the mount that serves an absolute path. Pin and unpin were left out.
**Fix:** Pin and unpin route through `route_to_mount` and run on the mount that owns the path. A
path in a mirror folder gets an error that says its files are already on this computer. The pin
registry records the absolute local path for a pin made through an on-demand folder, so
`pdfs pins`, the prompt and the HUD open the file through the folder it was kept from. Pins
under the primary mount still record the path relative to it.
**Test:** `pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy` in
`crates/pdfs-fuse/src/sim/daemon.rs`. It pins a file through an on-demand folder's absolute path,
takes the link down, and reads the file through that folder.
--- ---
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed ### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
+23 -181
View File
@@ -11,40 +11,32 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
## [3.8.0] — 2026-11-14 ### Changed
Schema: **45**. Synced folders now tell device backups apart from folders attached to My files. - Update `proton-sdk` and `proton-drive-rs` to 0.11.0.
Existing folders stay device backups. Older builds cannot open the upgraded database. Convert epoch timestamps at the SDK boundary and explicitly preserve photo modification
times now that they are independent of capture times. See [the SDK review](SDK-0.11.0.md)
for features and breaking changes.
Schema: **45**. Synced folders now distinguish device backups from My files attachments.
Existing folders remain device backups; older builds cannot open the upgraded database.
### Added ### Added
- **Sync a local folder with a folder you already have in My files** (#67, #77). - Shell completion for `pdfs` in bash, zsh and fish, installed by every package, plus elvish
`pdfs sync attach ~/Work --to Work` keeps `~/Work` an ordinary local folder and syncs it both and PowerShell through `pdfs completions SHELL`. Besides commands and options it completes
ways with My files/Work. The first sync uploads files that are only on this computer, downloads paths in My files and the ids and uids the running service knows (synced folders, the queue,
files that are only on Proton Drive, and keeps both versions of a file that differs: the one on computers, trash, albums, shares, invitations, bookmarks, conflicts and versions), each with
Proton Drive keeps its name, and the local one becomes a conflict copy next to it and is a description. Share roles are now checked by the CLI (`viewer`, `editor`, `admin`) before
uploaded too. Attached folders are always kept on this computer, never online only. They are the request.
listed apart from device backups and are not part of the device profile used to restore another - `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing
computer. Stopping one keeps both copies, and `--delete-remote` is refused for it. If the folder My files folder. The first sync preserves one-sided files and differing versions (#67).
on Proton Drive is trashed or moved out of My files, syncing stops with an error and the local Attachments stay in mirror mode, are excluded from device profiles, and cannot delete
files stay. Attaching a folder is done from a terminal for now. the remote root when syncing is stopped.
- **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 - **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. 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 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 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). video is. **Build Thumbnails** covers videos too (B283).
- **A new layout for Photos.** - **A new layout for Photos.**
- The grid shows every photo at its own shape, in rows that fill the width (portraits - 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 are no longer cropped to squares). **Square Tiles** in the page menu brings the even
@@ -69,168 +61,18 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
- The zoom level and the tile shape are remembered between runs. - 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 - In the photo viewer, the details panel lies over the photo when the window is
narrow instead of squeezing it. narrow instead of squeezing it.
- **Larger icons in My files.** The icon size slider goes up to 256 px, big enough to - **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 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. 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 ### Fixed
- **Resuming a Google Photos import no longer fails at once** (#94, B286, B287). A photo upload - Large `.ts` recordings (MPEG transport streams) show and open as videos: the video icon, a
that was cut short left a draft behind, and every later run stopped with thumbnail, and the `@media` open rule rather than `@text`. A `.ts` of 1 MiB or more counts
`check for existing photos: serialization error: invalid type: null`. Such drafts are now as a video, a smaller one stays TypeScript source, and the daemon checks the stream's sync
skipped by the duplicate check and replaced by the next upload of that photo, also after byte before it takes a frame (B283).
signing in again. A failed upload removes its draft. - A video over 256 MiB never got a thumbnail, and a smaller one was downloaded whole into
memory for one frame (B283).
- **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 ## [3.7.0] — 2026-11-02
+1 -2
View File
@@ -102,7 +102,7 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). 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 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 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. |
@@ -196,7 +196,6 @@ 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. |
+1 -1
View File
@@ -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.8.0"; inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.7.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.
-129
View File
@@ -1,129 +0,0 @@
# 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.
+2 -17
View File
@@ -55,7 +55,6 @@ 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:
@@ -64,22 +63,8 @@ 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
``` ```
What the import takes from the export: The import recreates your albums and takes capture dates and locations from the export. Photos
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
+1 -2
View File
@@ -63,8 +63,7 @@ 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 | | 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 |
| 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
View File
Whitespace-only changes.
-53
View File
@@ -1,53 +0,0 @@
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?;
+1 -1
View File
@@ -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.8.0 pkgver=3.7.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')
+1 -1
View File
@@ -10,7 +10,7 @@
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice. # Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
%global _lto_cflags %{nil} %global _lto_cflags %{nil}
%{!?version: %global version 3.8.0} %{!?version: %global version 3.7.0}
%global forge https://github.com/narrrl/proton-drive-linux %global forge https://github.com/narrrl/proton-drive-linux
# Files come from the checkout, or from the unpacked tag archive. # Files come from the checkout, or from the unpacked tag archive.
%global src %{?git_dir}%{!?git_dir:.} %global src %{?git_dir}%{!?git_dir:.}
-1
View File
@@ -24,7 +24,6 @@ crates/pdfs-gui/src/app/widgets/compat.rs
crates/pdfs-gui/src/app/widgets/details.rs crates/pdfs-gui/src/app/widgets/details.rs
crates/pdfs-gui/src/app/widgets/file_list.rs crates/pdfs-gui/src/app/widgets/file_list.rs
crates/pdfs-gui/src/app/widgets/menu.rs crates/pdfs-gui/src/app/widgets/menu.rs
crates/pdfs-gui/src/app/widgets/player.rs
crates/pdfs-gui/src/app/widgets/share_dialog.rs crates/pdfs-gui/src/app/widgets/share_dialog.rs
crates/pdfs-gui/src/app/widgets/thumbnails.rs crates/pdfs-gui/src/app/widgets/thumbnails.rs
crates/pdfs-gui/src/app/widgets/versions_dialog.rs crates/pdfs-gui/src/app/widgets/versions_dialog.rs
+938 -1122
View File
File diff suppressed because it is too large. Load diff
+996 -1180
View File
File diff suppressed because it is too large. Load diff
+938 -1122
View File
File diff suppressed because it is too large. Load diff
+938 -1122
View File
File diff suppressed because it is too large. Load diff
+938 -1122
View File
File diff suppressed because it is too large. Load diff
+938 -1122
View File
File diff suppressed because it is too large. Load diff
+929 -1082
View File
File diff suppressed because it is too large. Load diff
+939 -1125
View File
File diff suppressed because it is too large. Load diff
+938 -1122
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -91,7 +91,7 @@ SECTIONS = [
] ]
# Too long or too internal for the wiki; links to them go to GitHub. # Too long or too internal for the wiki; links to them go to GitHub.
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "ISSUE-78.md", "SDK-0.11.0.md", "SDK-0.12.0.md"} EXCLUDED = {"BUGS.md", "CHANGELOG.md", "SDK-0.11.0.md"}
# Files outside docs/ whose links are checked but not published. # Files outside docs/ whose links are checked but not published.
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"] CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]